mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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
This commit is contained in:
1 parent
6911a609f1
commit
80d5c8dc80
6 files changed
+446
-153
No files matched your search
+12
-8
@@ -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:
|
||||
|
||||
@@ -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<typeof procedure>[],
|
||||
) =>
|
||||
functionDossierSchema.parse({
|
||||
...dossier("return helper();", "0x1000"),
|
||||
[direction]: entries,
|
||||
});
|
||||
const calls = (
|
||||
left: ReturnType<typeof dossier>,
|
||||
right: ReturnType<typeof dossier>,
|
||||
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) =>
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string> => {
|
||||
const ambiguous = new Set<string>();
|
||||
for (const snapshot of snapshots) {
|
||||
const entries = new Map<string, string>();
|
||||
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<string>()
|
||||
: 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 },
|
||||
);
|
||||
};
|
||||
@@ -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<Reference["kind"], { available: true }>,
|
||||
"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<string> => {
|
||||
const ambiguous = new Set<string>();
|
||||
for (const snapshot of snapshots) {
|
||||
const entries = new Map<string, string>();
|
||||
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<string>,
|
||||
): 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<string>,
|
||||
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<string>()
|
||||
: 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.",
|
||||
);
|
||||
|
||||
@@ -90,6 +90,8 @@ const evidence = (
|
||||
readonly incoming?: readonly Reference[];
|
||||
readonly procedureAddress?: string;
|
||||
readonly procedureName?: string;
|
||||
readonly callers?: readonly ReturnType<typeof procedure>[];
|
||||
readonly callees?: readonly ReturnType<typeof procedure>[];
|
||||
} = {},
|
||||
) =>
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user