From 80d5c8dc80c65bed9b7409f0bdd059e024a34ac4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:00:11 +0800 Subject: [PATCH] fix(comparison): preserve call procedure endpoint identity Share endpoint identity and repeated-name ambiguity across call and reference dimensions. Compare same-artifact procedure entries by canonical coordinates while retaining raw reference sites and source Evidence. Keep observed comparison status separate from reconstruction proof. Fixes #6853 --- docs/mcp-contracts.md | 20 ++- src/domain/functionComparison.test.ts | 131 ++++++++++++++++- src/domain/functionComparisonDimensions.ts | 56 +++++-- src/domain/functionEndpointIdentity.ts | 118 +++++++++++++++ src/domain/functionReferenceComparison.ts | 137 +++--------------- ...unctionComparisonReferenceFactsMcp.test.ts | 137 ++++++++++++++++-- 6 files changed, 446 insertions(+), 153 deletions(-) create mode 100644 src/domain/functionEndpointIdentity.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 2bfd55b6c8..1f4b001e2c 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1186,20 +1186,24 @@ unknown endpoint identity or kind facts remain `unknown`. Independent known facts that contradict every possible pairing still establish `changed`. Source Evidence retains the original addresses, names and unavailable facts. +The `calls` dimension uses the same endpoint identity owner. Within an artifact, +it compares procedure entry coordinates rather than display names; references +continue to compare exact source and target coordinates. Across artifacts, +caller and callee names are comparable only when each name identifies one +observed procedure entry per dossier. Procedure entries retained in callers, +callees and reference endpoints all contribute to this ambiguity check. +Repeated rows at the same entry retain their duplicate counts. An unresolved or +ambiguous call endpoint keeps the selected `calls` reconstruction claim unknown; +with resolved call endpoints, distinct entries in the same artifact establish a +changed call set. + Coordinate identity requires a common executable context: artifact identity, selected image or architecture slice, address space, and applicable load mapping. Equal coordinates in different slices of one container do not establish equal endpoints. The current digest-only selection does not enforce that context and -can report references unchanged across distinct slices +can report references and calls unchanged across distinct slices ([#6502](https://github.com/morluto/rea/issues/6502)). -The `calls` dimension separately compares direction and procedure names without -retaining endpoint addresses. Distinct same-named procedures can therefore -compare unchanged ([#6853](https://github.com/morluto/rea/issues/6853)). Check -the source dossiers' endpoint identities before using either affected dimension -as a reconstruction proof; another dimension reporting a change does not repair -the selected dimension's false equality. - ### Comparison input shapes `compare_web_captures` accepts exactly one of two input shapes: diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 11357c6500..12c7bc2f8f 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -494,7 +494,10 @@ describe("function collection collation ties", () => { (name, index) => procedure(`0x${(0x2000 + index).toString(16)}`, name), ); const canonicalProjection = callees - .map(({ name }) => ({ direction: "out", name })) + .map(({ name }) => ({ + direction: "out", + endpoint: { kind: "name", name, ambiguous: false }, + })) .sort((left, right) => compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)), ); @@ -514,12 +517,14 @@ describe("function collection collation ties", () => { }); describe("function comparison ordering controls", () => { - const composed = procedure("0x2000", "caf\u00e9"); - const decomposed = procedure("0x3000", "cafe\u0301"); - it("preserves duplicate Unicode collections and nullable endpoints", () => { const controlled = (base: "0x1000" | "0x2000", reverse: boolean) => { const endpoint = base === "0x1000" ? "0x1010" : "0x2010"; + const composed = procedure(endpoint, "caf\u00e9"); + const decomposed = procedure( + base === "0x1000" ? "0x1020" : "0x2020", + "cafe\u0301", + ); const edges = [ { source_address: base, @@ -1390,6 +1395,124 @@ it.each([ }, ); +describe("function call endpoint identity", () => { + const calling = ( + direction: "callers" | "callees", + entries: readonly ReturnType[], + ) => + functionDossierSchema.parse({ + ...dossier("return helper();", "0x1000"), + [direction]: entries, + }); + const calls = ( + left: ReturnType, + right: ReturnType, + sameArtifact: boolean, + ) => + compareFunctions( + functionSnapshotFixture(observe("a", left)), + functionSnapshotFixture(observe(sameArtifact ? "a" : "b", right)), + ).dimensions.find(({ dimension }) => dimension === "calls"); + + it.each(["callers", "callees"] as const)( + "uses known procedure entries rather than %s labels within an artifact", + (direction) => { + const left = calling(direction, [procedure("ram:0x1010", "helper")]); + expect( + calls( + left, + calling(direction, [procedure("ram:0x1020", "helper")]), + true, + ), + ).toMatchObject({ status: "changed" }); + expect( + calls( + left, + calling(direction, [procedure("ram:0X001010", "renamed")]), + true, + ), + ).toMatchObject({ status: "unchanged" }); + expect( + calls( + left, + calling(direction, [procedure("rom:0x1010", "helper")]), + true, + ), + ).toMatchObject({ status: "changed" }); + expect( + calls( + left, + calling(direction, [procedure("unparsed", "helper")]), + true, + ), + ).toMatchObject({ + status: "unknown", + limitations: [expect.stringContaining("could not be parsed")], + }); + }, + ); + + it.each(["callers", "callees"] as const)( + "keeps repeated cross-artifact %s labels ambiguous without reference edges", + (direction) => { + const left = calling(direction, [ + procedure("0x1010", "helper"), + procedure("0x1020", "helper"), + ]); + const right = calling(direction, [ + procedure("0x2010", "helper"), + procedure("0x2020", "helper"), + ]); + expect(calls(left, right, false)).toMatchObject({ + status: "unknown", + left_count: 2, + right_count: 2, + left_digest: null, + right_digest: null, + limitations: [expect.stringContaining("does not uniquely identify")], + }); + }, + ); + + it("retains duplicate counts when a name resolves to one entry per artifact", () => { + const left = calling("callees", [ + procedure("0x1010", "helper"), + procedure("0X001010", "helper"), + ]); + const right = calling("callees", [ + procedure("0x2010", "helper"), + procedure("0x2010", "helper"), + ]); + expect(calls(left, right, false)).toMatchObject({ + status: "unchanged", + left_count: 2, + right_count: 2, + }); + expect( + calls(left, calling("callees", [procedure("0x2010", "helper")]), false), + ).toMatchObject({ status: "changed" }); + }); + + it("shares procedure-name ambiguity across call and reference observations", () => { + const observed = (base: "0x1000" | "0x2000") => + functionDossierSchema.parse({ + ...dossierWithReference(base, "call"), + callees: [procedure(base === "0x1000" ? "0x1020" : "0x2020", "helper")], + }); + const result = compareFunctions( + functionSnapshotFixture(observe("a", observed("0x1000"))), + functionSnapshotFixture(observe("b", observed("0x2000"))), + ); + for (const dimension of ["calls", "references"]) + expect( + result.dimensions.find((value) => value.dimension === dimension), + ).toMatchObject({ + status: "unknown", + limitations: [expect.stringContaining("does not uniquely identify")], + }); + }); +}); + describe("function comparison unnamed reference endpoints", () => { it("distinguishes unnamed reference targets by address within one artifact", () => { const unnamedTarget = (target: string) => diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index bd4912b6c9..08af41c93c 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -1,5 +1,9 @@ import { canonicalJson } from "./comparisonSemantics.js"; import { compareFunctionReferences } from "./functionReferenceComparison.js"; +import { + functionEndpointIdentity, + type FunctionEndpointIdentity, +} from "./functionEndpointIdentity.js"; import { diffLines } from "diff"; import type { FunctionSnapshot } from "./functionDossierEvidence.js"; @@ -33,6 +37,7 @@ export const compareDimensions = ( right: FunctionSnapshot, context: ComparisonContext, ): FunctionDimension[] => { + const endpointIdentity = functionEndpointIdentity(left, right); return [ compareIdentity(left, right, context), compareText( @@ -48,8 +53,8 @@ export const compareDimensions = ( context, ), compareComments(left, right, context), - compareCalls(left, right, context), - compareReferences(left, right, context), + compareCalls(left, right, context, endpointIdentity), + compareReferences(left, right, context, endpointIdentity), compareStringsAndNames(left, right, context), compareCfg(left, right, context), ].map((result) => { @@ -524,8 +529,9 @@ const compareReferences = ( left: FunctionSnapshot, right: FunctionSnapshot, context: ComparisonContext, + identity: FunctionEndpointIdentity, ): FunctionDimension => { - const compared = compareFunctionReferences(left, right); + const compared = compareFunctionReferences(left, right, identity); if (compared.status === "unknown") return unresolvedDimension({ dimension: "references", @@ -572,9 +578,10 @@ const compareCalls = ( left: FunctionSnapshot, right: FunctionSnapshot, context: ComparisonContext, + identity: FunctionEndpointIdentity, ): FunctionDimension => { - const leftValues = callProjection(left); - const rightValues = callProjection(right); + const leftValues = callProjection(left, identity); + const rightValues = callProjection(right, identity); const coverageLimitations = [ ...left.callCoverageBlockers.map((reason) => `Left: ${reason}`), ...right.callCoverageBlockers.map((reason) => `Right: ${reason}`), @@ -591,14 +598,36 @@ const compareCalls = ( rightCount: rightValues.length, limitations: [...coverageLimitations, ...coverageDiagnostics], }); - if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name))) + const endpoints = [...leftValues, ...rightValues].map( + ({ endpoint }) => endpoint, + ); + const unknown = endpoints.filter((endpoint) => endpoint.kind === "unknown"); + const ambiguous = endpoints.some( + (endpoint) => endpoint.kind === "name" && endpoint.ambiguous, + ); + if (unknown.length > 0 || ambiguous) return unresolvedDimension({ dimension: "calls", links: context.links, leftCount: leftValues.length, rightCount: rightValues.length, limitations: [ - "Address-derived callee or caller names cannot be matched safely.", + ...(unknown.some((endpoint) => endpoint.reason === "unparsed_address") + ? [ + "Call procedure entry coordinates could not be parsed, so their identity remains unknown.", + ] + : []), + ...(unknown.some((endpoint) => endpoint.reason === "generated_name") + ? ["Address-derived callee or caller names cannot be matched safely."] + : []), + ...(unknown.some((endpoint) => endpoint.reason === "missing_name") + ? ["Empty callee or caller names cannot be matched safely."] + : []), + ...(ambiguous + ? [ + "A callee or caller name does not uniquely identify an observed procedure entry, so matching names do not establish equal call endpoints.", + ] + : []), ...coverageDiagnostics, ], }); @@ -611,14 +640,17 @@ const compareCalls = ( ); }; -const callProjection = (snapshot: FunctionSnapshot) => [ - ...snapshot.dossier.callers.map(({ name }) => ({ +const callProjection = ( + snapshot: FunctionSnapshot, + identity: FunctionEndpointIdentity, +) => [ + ...snapshot.dossier.callers.map((procedure) => ({ direction: "in" as const, - name, + endpoint: identity.endpoint(procedure, procedure.address), })), - ...snapshot.dossier.callees.map(({ name }) => ({ + ...snapshot.dossier.callees.map((procedure) => ({ direction: "out" as const, - name, + endpoint: identity.endpoint(procedure, procedure.address), })), ]; diff --git a/src/domain/functionEndpointIdentity.ts b/src/domain/functionEndpointIdentity.ts new file mode 100644 index 0000000000..5325a97686 --- /dev/null +++ b/src/domain/functionEndpointIdentity.ts @@ -0,0 +1,118 @@ +import { canonicalJson } from "./comparisonSemantics.js"; +import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import { isAutoName } from "./functionComparisonNormalization.js"; +import { nativeAddressInputSchema } from "./native/nativeAddress.js"; + +type Procedure = FunctionSnapshot["dossier"]["callers"][number]; + +export type FunctionEndpoint = + | { readonly kind: "address"; readonly address: string } + | { + readonly kind: "name"; + readonly name: string; + readonly ambiguous: boolean; + } + | { + readonly kind: "unknown"; + readonly reason: "unparsed_address" | "missing_name" | "generated_name"; + readonly reported_address: string; + readonly reported_name: string | null; + }; + +export interface FunctionEndpointIdentity { + readonly sameArtifact: boolean; + readonly endpoint: ( + procedure: Procedure | null, + address: string, + ) => FunctionEndpoint; +} + +/** Observe all retained procedure entries before using names as cross-artifact keys. */ +const ambiguousProcedureNames = ( + snapshots: readonly FunctionSnapshot[], +): Set => { + const ambiguous = new Set(); + for (const snapshot of snapshots) { + const entries = new Map(); + const observe = (procedure: Procedure | null): void => { + if (procedure === null) return; + const address = nativeAddressInputSchema.safeParse(procedure.address); + if (!address.success) { + ambiguous.add(procedure.name); + return; + } + const previous = entries.get(procedure.name); + if (previous !== undefined && previous !== address.data) + ambiguous.add(procedure.name); + entries.set(procedure.name, address.data); + }; + observe(snapshot.dossier.procedure); + for (const procedure of snapshot.dossier.callers) observe(procedure); + for (const procedure of snapshot.dossier.callees) observe(procedure); + for (const edge of snapshot.dossier.incoming_references) { + observe(edge.source_procedure); + observe(edge.target_procedure); + } + for (const edge of snapshot.dossier.outgoing_references) { + observe(edge.source_procedure); + observe(edge.target_procedure); + } + } + return ambiguous; +}; + +/** Calls use procedure entries; references supply their exact observed site coordinates. */ +export const functionEndpointIdentity = ( + left: FunctionSnapshot, + right: FunctionSnapshot, +): FunctionEndpointIdentity => { + const sameArtifact = + left.subject.digest.sha256 === right.subject.digest.sha256; + const ambiguousNames = sameArtifact + ? new Set() + : ambiguousProcedureNames([left, right]); + return { + sameArtifact, + endpoint: (procedure, address) => { + if (sameArtifact) { + const parsed = nativeAddressInputSchema.safeParse(address); + if (parsed.success) return { kind: "address", address: parsed.data }; + return { + kind: "unknown", + reason: "unparsed_address", + reported_address: address, + reported_name: procedure?.name ?? null, + }; + } + if ( + procedure === null || + procedure.name.length === 0 || + isAutoName(procedure.name) + ) + return { + kind: "unknown", + reason: + procedure !== null && isAutoName(procedure.name) + ? "generated_name" + : "missing_name", + reported_address: address, + reported_name: procedure?.name ?? null, + }; + return { + kind: "name", + name: procedure.name, + ambiguous: ambiguousNames.has(procedure.name), + }; + }, + }; +}; + +/** Ambiguity blocks equality, but a known label can still contradict another label. */ +export const functionEndpointKey = (value: FunctionEndpoint): string | null => { + if (value.kind === "unknown") return null; + return canonicalJson( + value.kind === "address" + ? { address: value.address } + : { name: value.name }, + ); +}; diff --git a/src/domain/functionReferenceComparison.ts b/src/domain/functionReferenceComparison.ts index e48e376f89..817fa0898d 100644 --- a/src/domain/functionReferenceComparison.ts +++ b/src/domain/functionReferenceComparison.ts @@ -1,35 +1,26 @@ import { canonicalJson } from "./comparisonSemantics.js"; import type { FunctionSnapshot } from "./functionDossierEvidence.js"; -import { isAutoName, sorted } from "./functionComparisonNormalization.js"; +import { sorted } from "./functionComparisonNormalization.js"; import { referenceFactsCompatible, type ReferenceCompatibilityRow, } from "./functionReferenceCompatibility.js"; -import { nativeAddressInputSchema } from "./native/nativeAddress.js"; +import { + functionEndpointKey, + type FunctionEndpoint, + type FunctionEndpointIdentity, +} from "./functionEndpointIdentity.js"; type Reference = FunctionSnapshot["dossier"]["outgoing_references"][number]; type KnownKind = Omit< Extract, "available" | "provenance" >; -type Endpoint = - | { readonly kind: "address"; readonly address: string } - | { - readonly kind: "name"; - readonly name: string; - readonly ambiguous: boolean; - } - | { - readonly kind: "unknown"; - readonly reason: "unparsed_address" | "missing_name" | "generated_name"; - readonly reported_address: string; - readonly reported_name: string | null; - }; interface ReferenceFact { readonly direction: "in" | "out"; - readonly source: Endpoint; - readonly target: Endpoint; + readonly source: FunctionEndpoint; + readonly target: FunctionEndpoint; readonly kind: KnownKind | null; } @@ -46,79 +37,9 @@ type ReferenceComparison = { } ); -/** Names observed at different procedure entries do not uniquely identify an overload. */ -const ambiguousProcedureNames = ( - snapshots: readonly FunctionSnapshot[], -): Set => { - const ambiguous = new Set(); - for (const snapshot of snapshots) { - const entries = new Map(); - const observe = (procedure: Reference["source_procedure"]): void => { - if (procedure === null) return; - const address = nativeAddressInputSchema.safeParse(procedure.address); - if (!address.success) { - ambiguous.add(procedure.name); - return; - } - const previous = entries.get(procedure.name); - if (previous !== undefined && previous !== address.data) - ambiguous.add(procedure.name); - entries.set(procedure.name, address.data); - }; - observe(snapshot.dossier.procedure); - for (const edge of snapshot.dossier.incoming_references) { - observe(edge.source_procedure); - observe(edge.target_procedure); - } - for (const edge of snapshot.dossier.outgoing_references) { - observe(edge.source_procedure); - observe(edge.target_procedure); - } - } - return ambiguous; -}; - -const endpoint = ( - procedure: Reference["source_procedure"], - address: string, - sameArtifact: boolean, - ambiguousNames: ReadonlySet, -): Endpoint => { - if (sameArtifact) { - const parsed = nativeAddressInputSchema.safeParse(address); - if (parsed.success) return { kind: "address", address: parsed.data }; - return { - kind: "unknown", - reason: "unparsed_address", - reported_address: address, - reported_name: procedure?.name ?? null, - }; - } - if ( - procedure === null || - procedure.name.length === 0 || - isAutoName(procedure.name) - ) - return { - kind: "unknown", - reason: - procedure !== null && isAutoName(procedure.name) - ? "generated_name" - : "missing_name", - reported_address: address, - reported_name: procedure?.name ?? null, - }; - return { - kind: "name", - name: procedure.name, - ambiguous: ambiguousNames.has(procedure.name), - }; -}; - const project = ( snapshot: FunctionSnapshot, - sameArtifact: boolean, - ambiguousNames: ReadonlySet, + identity: FunctionEndpointIdentity, ): ReferenceFact[] => { const facts: ReferenceFact[] = []; const collect = ( @@ -137,18 +58,8 @@ const project = ( } facts.push({ direction, - source: endpoint( - edge.source_procedure, - edge.source_address, - sameArtifact, - ambiguousNames, - ), - target: endpoint( - edge.target_procedure, - edge.target_address, - sameArtifact, - ambiguousNames, - ), + source: identity.endpoint(edge.source_procedure, edge.source_address), + target: identity.endpoint(edge.target_procedure, edge.target_address), kind, }); } @@ -158,23 +69,13 @@ const project = ( return facts; }; -/** Ambiguity blocks equality, but a known label can still contradict another label. */ -const endpointKey = (value: Endpoint): string | null => { - if (value.kind === "unknown") return null; - return canonicalJson( - value.kind === "address" - ? { address: value.address } - : { name: value.name }, - ); -}; - const constraints = ( facts: readonly ReferenceFact[], ): ReferenceCompatibilityRow[] => facts.map((fact) => ({ direction: fact.direction, - source: endpointKey(fact.source), - target: endpointKey(fact.target), + source: functionEndpointKey(fact.source), + target: functionEndpointKey(fact.target), kind: fact.kind === null ? null : canonicalJson(fact.kind), })); @@ -182,14 +83,10 @@ const constraints = ( export const compareFunctionReferences = ( left: FunctionSnapshot, right: FunctionSnapshot, + identity: FunctionEndpointIdentity, ): ReferenceComparison => { - const sameArtifact = - left.subject.digest.sha256 === right.subject.digest.sha256; - const ambiguousNames = sameArtifact - ? new Set() - : ambiguousProcedureNames([left, right]); - const leftFacts = project(left, sameArtifact, ambiguousNames); - const rightFacts = project(right, sameArtifact, ambiguousNames); + const leftFacts = project(left, identity); + const rightFacts = project(right, identity); let unknownIdentity = false; let ambiguousIdentity = false; let unknownKinds = false; @@ -205,7 +102,7 @@ export const compareFunctionReferences = ( const limitations: string[] = []; if (unknownIdentity) limitations.push( - sameArtifact + identity.sameArtifact ? "Reference endpoint coordinates could not be parsed, so their identity remains unknown." : "Unnamed reference endpoints and generated procedure names have no stable identity across different artifacts.", ); diff --git a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts index 2759f27580..79fa49685e 100644 --- a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts @@ -90,6 +90,8 @@ const evidence = ( readonly incoming?: readonly Reference[]; readonly procedureAddress?: string; readonly procedureName?: string; + readonly callers?: readonly ReturnType[]; + readonly callees?: readonly ReturnType[]; } = {}, ) => createEvidence( @@ -111,6 +113,8 @@ const evidence = ( }, incoming_references: [...(options.incoming ?? [])], outgoing_references: [...outgoing], + callers: [...(options.callers ?? dossier.callers)], + callees: [...(options.callees ?? dossier.callees)], }), confidence: "derived", authority: "shipped-artifact", @@ -159,12 +163,14 @@ const expectReferences = ( ) .toMatchObject({ status }); -const verifyReferences = async ( +const verifyDimension = async ( client: Client, comparisonEvidenceId: string, status: "pass" | "fail" | "unknown", + observedStatus: "unchanged" | "changed" | "unknown", + dimension: "references" | "calls" = "references", ) => { - const claimId = `references-${comparisonEvidenceId}`; + const claimId = `${dimension}-${comparisonEvidenceId}`; const response = await client.callTool({ name: "verify_reconstruction", arguments: { @@ -174,9 +180,9 @@ const verifyReferences = async ( { kind: "structural-function", claim_id: claimId, - title: "Reference facts agree", + title: `${dimension} facts agree`, comparison_evidence_id: comparisonEvidenceId, - dimension: "references", + dimension, }, ], }, @@ -186,8 +192,6 @@ const verifyReferences = async ( const verification = reconstructionVerificationResultSchema.parse( parseEvidence(response.structuredContent).normalized_result, ); - const observed = - status === "pass" ? "unchanged" : status === "fail" ? "changed" : "unknown"; expect.soft(verification).toMatchObject({ status, summary: { @@ -201,7 +205,7 @@ const verifyReferences = async ( expect.objectContaining({ claim_id: claimId, status, - observed_status: observed, + observed_status: observedStatus, }), ], }, @@ -230,7 +234,12 @@ it("preserves reference identity uncertainty and same-artifact coordinates", asy ); const namedToNull = await compare(client, named, unnamedElsewhere); expectReferences(namedToNull, "unknown"); - await verifyReferences(client, namedToNull.evidence.evidence_id, "unknown"); + await verifyDimension( + client, + namedToNull.evidence.evidence_id, + "unknown", + "unknown", + ); const nullToNamed = await compare(client, unnamedElsewhere, named); expectReferences(nullToNamed, "unknown"); @@ -346,6 +355,111 @@ it("keeps generated and ambiguous procedure names from proving cross-artifact id expect.soft(generatedFunctions.result.status).toBe("unknown"); }); +it("preserves call endpoint identity through public comparisons and selected reconstruction claims", async () => { + const { client, session } = await createHarness(); + const observedCalls = ( + id: string, + digest: string, + entry: string, + targets: readonly string[], + includeReferences = true, + ) => + evidence( + id, + digest, + includeReferences + ? targets.map((target) => + reference(entry, target, "call", { sourceName: "worker" }), + ) + : [], + { + procedureAddress: entry, + procedureName: "worker", + callees: targets.map((target) => procedure(target, "helper")), + }, + ); + const original = observedCalls("original-calls", "1", "0x1000", [ + "ram:0x2000", + ]); + const cases = [ + { + left: original, + right: original, + status: "unchanged", + verification: "unknown", + }, + { + left: original, + right: observedCalls("distinct-helpers", "1", "0x1100", ["ram:0x3000"]), + status: "changed", + verification: "fail", + }, + { + left: original, + right: observedCalls("same-helper-coordinate", "1", "0x1000", [ + "ram:0X002000", + ]), + status: "unchanged", + verification: "pass", + }, + { + left: original, + right: observedCalls("cross-build-helper", "2", "0x1100", ["ram:0x3000"]), + status: "unchanged", + verification: "pass", + }, + ...[true, false].map((includeReferences) => ({ + left: observedCalls( + `duplicate-helpers-left-${includeReferences}`, + "3", + "0x1000", + ["0x2000", "0x3000"], + includeReferences, + ), + right: observedCalls( + `duplicate-helpers-right-${includeReferences}`, + "4", + "0x1100", + ["0x4000", "0x5000"], + includeReferences, + ), + status: "unknown" as const, + verification: "unknown" as const, + })), + ] as const; + for (const test of cases) { + const compared = await compare(client, test.left, test.right); + expect( + compared.result.dimensions.find((value) => value.dimension === "calls"), + ).toMatchObject({ + status: test.status, + evidence_links: [test.left.evidence_id, test.right.evidence_id], + ...(test.status === "unknown" + ? { + left_count: 2, + right_count: 2, + left_digest: null, + right_digest: null, + limitations: [ + expect.stringContaining("does not uniquely identify"), + ], + } + : {}), + }); + await verifyDimension( + client, + compared.evidence.evidence_id, + test.verification, + test.status, + "calls", + ); + for (const source of [test.left, test.right]) + expect( + session.evidenceById(source.evidence_id)?.normalized_result, + ).toEqual(source.normalized_result); + } +}); + it("normalizes numeric coordinates but preserves address spaces and source sites", async () => { const { client } = await createHarness(); const at = (id: string, address: string) => @@ -461,7 +575,12 @@ it("keeps known reference changes decisive beside unrelated unknown kinds", asyn ); const compared = await compare(client, left, right); expectReferences(compared, "changed"); - await verifyReferences(client, compared.evidence.evidence_id, "fail"); + await verifyDimension( + client, + compared.evidence.evidence_id, + "fail", + "changed", + ); }); it("keeps ambiguous duplicate edge kinds unknown independent of input order", async () => {