refactor(domain)!: remove obsolete comparison representations
Compare complete function dossiers directly, remove unreachable truncation and collection wrappers, and preserve deterministic Unicode ordering. Require real external reference targets instead of display placeholders. BREAKING CHANGE: function comparison results no longer advertise the unreachable truncated state or counter.
This commit is contained in:
1 parent
475a0f84da
commit
5510b27d7f
11 files changed
+224
-468
No files matched your search
@@ -160,8 +160,8 @@ const parseSnapshots = (
|
||||
const snapshots = new Map<string, FunctionSnapshot>();
|
||||
for (const group of groups) {
|
||||
const snapshot = parseFunctionEvidence(group);
|
||||
const address = normalizeAddress(snapshot.procedure.address);
|
||||
for (const callee of snapshot.collections.callees.items)
|
||||
const address = normalizeAddress(snapshot.dossier.procedure.address);
|
||||
for (const callee of snapshot.dossier.callees)
|
||||
normalizeAddress(callee.address);
|
||||
if (snapshots.has(address))
|
||||
throw new TypeError(`Duplicate function Evidence for ${address}`);
|
||||
@@ -184,9 +184,9 @@ const assertCompatible = (
|
||||
)
|
||||
throw new TypeError("Call-path Evidence mixes artifact subjects");
|
||||
if (
|
||||
snapshot.provider.id !== first.provider.id ||
|
||||
snapshot.provider.name !== first.provider.name ||
|
||||
snapshot.provider.version !== first.provider.version
|
||||
snapshot.evidence.provider.id !== first.evidence.provider.id ||
|
||||
snapshot.evidence.provider.name !== first.evidence.provider.name ||
|
||||
snapshot.evidence.provider.version !== first.evidence.provider.version
|
||||
)
|
||||
throw new TypeError("Call-path Evidence mixes providers");
|
||||
}
|
||||
@@ -198,7 +198,7 @@ const createGraph = (
|
||||
const graph = new CallGraph();
|
||||
for (const [address, snapshot] of snapshots) {
|
||||
graph.mergeNode(address);
|
||||
for (const callee of snapshot.collections.callees.items) {
|
||||
for (const callee of snapshot.dossier.callees) {
|
||||
const calleeAddress = normalizeAddress(callee.address);
|
||||
graph.mergeNode(calleeAddress);
|
||||
graph.mergeDirectedEdge(address, calleeAddress);
|
||||
@@ -346,7 +346,7 @@ const citePath = (
|
||||
(index > 0 ? snapshots.get(addresses[index - 1] ?? "") : undefined);
|
||||
return {
|
||||
address,
|
||||
name: snapshot?.procedure.name ?? null,
|
||||
name: snapshot?.dossier.procedure.name ?? null,
|
||||
evidence_links: snapshotLinks(supporting),
|
||||
};
|
||||
});
|
||||
@@ -367,7 +367,7 @@ const citePath = (
|
||||
const snapshotLinks = (snapshot: FunctionSnapshot | undefined): string[] => {
|
||||
if (snapshot === undefined)
|
||||
throw new TypeError("Every call-path claim requires supporting Evidence");
|
||||
return snapshot.evidence.map(({ evidence_id }) => evidence_id);
|
||||
return [snapshot.evidence.evidence_id];
|
||||
};
|
||||
|
||||
const uniqueEvidence = (snapshots: Iterable<FunctionSnapshot>): string[] =>
|
||||
|
||||
@@ -293,7 +293,7 @@ const functionFindings = (evidence: Evidence): Finding[] => {
|
||||
scope: "static_candidate",
|
||||
status: dimension.status,
|
||||
classification:
|
||||
dimension.status === "unknown" || dimension.status === "truncated"
|
||||
dimension.status === "unknown"
|
||||
? "unresolved_branch"
|
||||
: dimension.conclusion_kind,
|
||||
limitations: dimension.limitations,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { createEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionDossierSchema } from "./hopperValues.js";
|
||||
import { canonicalDigest, canonicalJson } from "./comparisonSemantics.js";
|
||||
import { compareCodePoints } from "./canonicalOrdering.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const dossier = (
|
||||
@@ -219,17 +220,17 @@ describe("function collection collation ties", () => {
|
||||
expect(calls?.left_digest).not.toBe(calls?.right_digest);
|
||||
});
|
||||
|
||||
it("preserves existing digests when canonical records do not collate equally", () => {
|
||||
it("uses canonical code-point ordering for collection digests", () => {
|
||||
const callees = ["zeta", "Alpha", "alpha", "_helper", "beta"].map(
|
||||
(name, index) => ({
|
||||
address: `0x${(0x2000 + index).toString(16)}`,
|
||||
name,
|
||||
}),
|
||||
);
|
||||
const legacyProjection = callees
|
||||
const canonicalProjection = callees
|
||||
.map(({ name }) => ({ direction: "out", name }))
|
||||
.sort((left, right) =>
|
||||
canonicalJson(left).localeCompare(canonicalJson(right)),
|
||||
compareCodePoints(canonicalJson(left), canonicalJson(right)),
|
||||
);
|
||||
const result = compareFunctions(
|
||||
observe("b", calling(callees)),
|
||||
@@ -240,8 +241,8 @@ describe("function collection collation ties", () => {
|
||||
);
|
||||
expect(calls).toMatchObject({
|
||||
status: "unchanged",
|
||||
left_digest: canonicalDigest(legacyProjection),
|
||||
right_digest: canonicalDigest(legacyProjection),
|
||||
left_digest: canonicalDigest(canonicalProjection),
|
||||
right_digest: canonicalDigest(canonicalProjection),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -395,6 +396,28 @@ describe("function comparison CFG address normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("function comparison unresolved text", () => {
|
||||
it("counts Unicode text consistently for unresolved provider comparisons", () => {
|
||||
const left = observe("b", dossier("return '😀';", "0x1000"), "provider-a");
|
||||
const right = observe("c", dossier("return '😀';", "0x2000"), "provider-b");
|
||||
const comparison = compareFunctions(left, right);
|
||||
expect(
|
||||
comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"),
|
||||
).toMatchObject({ status: "unknown", left_count: 11, right_count: 11 });
|
||||
expect(comparison.summary).toEqual({
|
||||
unchanged: 6,
|
||||
changed: 0,
|
||||
unknown: 2,
|
||||
});
|
||||
expect(
|
||||
functionComparisonResultSchema.safeParse({
|
||||
...comparison,
|
||||
status: "truncated",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("function comparison", () => {
|
||||
it("preserves text changes alongside complete inline dossier facets", () => {
|
||||
const result = compareFunctions(
|
||||
|
||||
@@ -20,11 +20,11 @@ export const compareFunctions = (
|
||||
): FunctionComparisonResult => {
|
||||
const left = parseFunctionEvidence(leftInput);
|
||||
const right = parseFunctionEvidence(rightInput);
|
||||
const links = [left.evidence[0].evidence_id, right.evidence[0].evidence_id];
|
||||
const links = [left.evidence.evidence_id, right.evidence.evidence_id];
|
||||
const providersDiffer =
|
||||
canonicalJson(left.provider, "Function comparison") !==
|
||||
canonicalJson(right.provider, "Function comparison");
|
||||
const dimensions = compareDimensions(left, right, links, providersDiffer);
|
||||
canonicalJson(left.evidence.provider, "Function comparison") !==
|
||||
canonicalJson(right.evidence.provider, "Function comparison");
|
||||
const dimensions = compareDimensions(left, right, { links, providersDiffer });
|
||||
const match = functionMatch(left, right);
|
||||
const changes = dimensions.filter(({ status }) => status !== "unchanged");
|
||||
return functionComparisonResultSchema.parse({
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { canonicalJson } from "./comparisonSemantics.js";
|
||||
import { diffLines } from "diff";
|
||||
|
||||
import type {
|
||||
FunctionCollection,
|
||||
FunctionSnapshot,
|
||||
} from "./functionDossierEvidence.js";
|
||||
import type { FunctionSnapshot } from "./functionDossierEvidence.js";
|
||||
import {
|
||||
commentProjection,
|
||||
combineCoverage,
|
||||
identityProjection,
|
||||
isAutoName,
|
||||
normalizeCfg,
|
||||
project,
|
||||
referenceKindProjection,
|
||||
referenceProjection,
|
||||
sorted,
|
||||
@@ -23,113 +18,79 @@ import type {
|
||||
} from "./functionComparisonSchemas.js";
|
||||
import {
|
||||
dimensionResult,
|
||||
unresolvedDimension as buildUnresolvedDimension,
|
||||
unresolvedDimension,
|
||||
type DimensionResultInput,
|
||||
} from "./functionComparisonResults.js";
|
||||
|
||||
const unresolvedDimension = (
|
||||
...[dimension, status, links, leftCount, rightCount, limitations]: readonly [
|
||||
DimensionName,
|
||||
"truncated" | "unknown",
|
||||
readonly string[],
|
||||
number | null,
|
||||
number | null,
|
||||
readonly string[],
|
||||
]
|
||||
): FunctionDimension =>
|
||||
buildUnresolvedDimension({
|
||||
dimension,
|
||||
status,
|
||||
links,
|
||||
leftCount,
|
||||
rightCount,
|
||||
limitations,
|
||||
});
|
||||
type ComparisonContext = Pick<
|
||||
DimensionResultInput,
|
||||
"links" | "providersDiffer"
|
||||
>;
|
||||
|
||||
export const compareDimensions = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
): FunctionDimension[] => [
|
||||
compareIdentity(left, right, links, providersDiffer),
|
||||
compareText(
|
||||
"pseudocode",
|
||||
left.pseudocode.text,
|
||||
right.pseudocode.text,
|
||||
left.pseudocode.complete,
|
||||
right.pseudocode.complete,
|
||||
links,
|
||||
providersDiffer,
|
||||
),
|
||||
compareText(
|
||||
"assembly",
|
||||
left.collections.assembly.items.join("\n"),
|
||||
right.collections.assembly.items.join("\n"),
|
||||
left.collections.assembly.complete,
|
||||
right.collections.assembly.complete,
|
||||
links,
|
||||
providersDiffer,
|
||||
left.collections.assembly.truncated || right.collections.assembly.truncated,
|
||||
"Assembly is opaque provider text; relocation normalization is unavailable.",
|
||||
),
|
||||
compareComments(left, right, links, providersDiffer),
|
||||
compareCalls(left, right, links, providersDiffer),
|
||||
compareReferences(left, right, links, providersDiffer),
|
||||
compareStringsAndNames(left, right, links, providersDiffer),
|
||||
compareCfg(left, right, links, providersDiffer),
|
||||
];
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension[] => {
|
||||
return [
|
||||
compareIdentity(left, right, context),
|
||||
compareText(
|
||||
"pseudocode",
|
||||
left.dossier.pseudocode,
|
||||
right.dossier.pseudocode,
|
||||
context,
|
||||
),
|
||||
compareText(
|
||||
"assembly",
|
||||
left.dossier.assembly.join("\n"),
|
||||
right.dossier.assembly.join("\n"),
|
||||
context,
|
||||
),
|
||||
compareComments(left, right, context),
|
||||
compareCalls(left, right, context),
|
||||
compareReferences(left, right, context),
|
||||
compareStringsAndNames(left, right, context),
|
||||
compareCfg(left, right, context),
|
||||
];
|
||||
};
|
||||
|
||||
const compareText = (
|
||||
...[
|
||||
dimension,
|
||||
left,
|
||||
right,
|
||||
leftComplete,
|
||||
rightComplete,
|
||||
links,
|
||||
providersDiffer,
|
||||
explicitTruncation,
|
||||
limitation,
|
||||
]: readonly [
|
||||
"pseudocode" | "assembly",
|
||||
string,
|
||||
string,
|
||||
boolean,
|
||||
boolean,
|
||||
readonly string[],
|
||||
boolean,
|
||||
boolean?,
|
||||
string?,
|
||||
]
|
||||
dimension: "pseudocode" | "assembly",
|
||||
left: string,
|
||||
right: string,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const truncated = explicitTruncation ?? (!leftComplete || !rightComplete);
|
||||
if (!leftComplete || !rightComplete || providersDiffer)
|
||||
return unresolvedDimension(
|
||||
const limitations =
|
||||
dimension === "assembly"
|
||||
? [
|
||||
"Assembly is opaque provider text; relocation normalization is unavailable.",
|
||||
]
|
||||
: [];
|
||||
if (context.providersDiffer)
|
||||
return unresolvedDimension({
|
||||
dimension,
|
||||
truncated ? "truncated" : "unknown",
|
||||
links,
|
||||
left.length,
|
||||
right.length,
|
||||
[
|
||||
...(limitation === undefined ? [] : [limitation]),
|
||||
...(providersDiffer
|
||||
? ["Exact text comparison requires one provider identity."]
|
||||
: []),
|
||||
links: context.links,
|
||||
leftCount: [...left].length,
|
||||
rightCount: [...right].length,
|
||||
limitations: [
|
||||
...limitations,
|
||||
"Exact text comparison requires one provider identity.",
|
||||
],
|
||||
);
|
||||
});
|
||||
const changes = diffLines(left, right, {
|
||||
timeout: 100,
|
||||
maxEditLength: 10_000,
|
||||
});
|
||||
if (changes === undefined)
|
||||
return unresolvedDimension(
|
||||
return unresolvedDimension({
|
||||
dimension,
|
||||
"unknown",
|
||||
links,
|
||||
left.length,
|
||||
right.length,
|
||||
["Bounded line diff exceeded its time or edit-distance limit."],
|
||||
);
|
||||
links: context.links,
|
||||
leftCount: [...left].length,
|
||||
rightCount: [...right].length,
|
||||
limitations: [
|
||||
"Bounded line diff exceeded its time or edit-distance limit.",
|
||||
],
|
||||
});
|
||||
const delta = changes.reduce(
|
||||
(summary, change) => ({
|
||||
added_lines:
|
||||
@@ -145,138 +106,59 @@ const compareText = (
|
||||
status: delta.hunks === 0 ? "unchanged" : "changed",
|
||||
left,
|
||||
right,
|
||||
links,
|
||||
providersDiffer,
|
||||
...context,
|
||||
leftCount: [...left].length,
|
||||
rightCount: [...right].length,
|
||||
textDelta: delta,
|
||||
limitations: limitation === undefined ? [] : [limitation],
|
||||
limitations,
|
||||
});
|
||||
};
|
||||
|
||||
const compareCollections = (
|
||||
...[
|
||||
dimension,
|
||||
left,
|
||||
right,
|
||||
leftCoverage,
|
||||
rightCoverage,
|
||||
links,
|
||||
providersDiffer,
|
||||
]: readonly [
|
||||
DimensionName,
|
||||
readonly unknown[],
|
||||
readonly unknown[],
|
||||
Pick<FunctionCollection, "complete" | "truncated">,
|
||||
Pick<FunctionCollection, "complete" | "truncated">,
|
||||
readonly string[],
|
||||
boolean,
|
||||
]
|
||||
): FunctionDimension => {
|
||||
if (!leftCoverage.complete || !rightCoverage.complete)
|
||||
return unresolvedDimension(
|
||||
dimension,
|
||||
leftCoverage.truncated || rightCoverage.truncated
|
||||
? "truncated"
|
||||
: "unknown",
|
||||
links,
|
||||
left.length,
|
||||
right.length,
|
||||
[],
|
||||
);
|
||||
return compareValues(
|
||||
dimension,
|
||||
sorted(left),
|
||||
sorted(right),
|
||||
links,
|
||||
true,
|
||||
true,
|
||||
providersDiffer,
|
||||
);
|
||||
};
|
||||
|
||||
const compareComments = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const leftValues = commentProjection(left);
|
||||
const rightValues = commentProjection(right);
|
||||
if (leftValues === null || rightValues === null)
|
||||
return unresolvedDimension(
|
||||
"comments",
|
||||
"unknown",
|
||||
links,
|
||||
left.collections.comments.items.length,
|
||||
right.collections.comments.items.length,
|
||||
["Comment locations could not be normalized relative to the function."],
|
||||
);
|
||||
return compareCollections(
|
||||
"comments",
|
||||
leftValues,
|
||||
rightValues,
|
||||
left.collections.comments,
|
||||
right.collections.comments,
|
||||
links,
|
||||
providersDiffer,
|
||||
);
|
||||
return unresolvedDimension({
|
||||
dimension: "comments",
|
||||
links: context.links,
|
||||
leftCount: left.dossier.comments.length,
|
||||
rightCount: right.dossier.comments.length,
|
||||
limitations: [
|
||||
"Comment locations could not be normalized relative to the function.",
|
||||
],
|
||||
});
|
||||
return compareValues("comments", leftValues, rightValues, context);
|
||||
};
|
||||
|
||||
const compareStringsAndNames = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const leftValues = stringAndNameProjection(left);
|
||||
const rightValues = stringAndNameProjection(right);
|
||||
if (leftValues === null || rightValues === null)
|
||||
return unresolvedDimension("strings_names", "unknown", links, null, null, [
|
||||
"Reference source locations could not be normalized to function offsets.",
|
||||
]);
|
||||
return compareCollections(
|
||||
"strings_names",
|
||||
leftValues,
|
||||
rightValues,
|
||||
combineCoverage(
|
||||
left.collections.referenced_strings,
|
||||
left.collections.referenced_names,
|
||||
),
|
||||
combineCoverage(
|
||||
right.collections.referenced_strings,
|
||||
right.collections.referenced_names,
|
||||
),
|
||||
links,
|
||||
providersDiffer,
|
||||
);
|
||||
return unresolvedDimension({
|
||||
dimension: "strings_names",
|
||||
links: context.links,
|
||||
leftCount: null,
|
||||
rightCount: null,
|
||||
limitations: [
|
||||
"Reference source locations could not be normalized to function offsets.",
|
||||
],
|
||||
});
|
||||
return compareValues("strings_names", leftValues, rightValues, context);
|
||||
};
|
||||
|
||||
const compareReferences = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const leftCoverage = combineCoverage(
|
||||
left.collections.incoming_references,
|
||||
left.collections.outgoing_references,
|
||||
);
|
||||
const rightCoverage = combineCoverage(
|
||||
right.collections.incoming_references,
|
||||
right.collections.outgoing_references,
|
||||
);
|
||||
if (!leftCoverage.complete || !rightCoverage.complete)
|
||||
return unresolvedDimension(
|
||||
"references",
|
||||
leftCoverage.truncated || rightCoverage.truncated
|
||||
? "truncated"
|
||||
: "unknown",
|
||||
links,
|
||||
leftCoverage.items.length,
|
||||
rightCoverage.items.length,
|
||||
[],
|
||||
);
|
||||
const leftProjection = referenceProjection(left);
|
||||
const rightProjection = referenceProjection(right);
|
||||
if (leftProjection.length === 0 && rightProjection.length === 0)
|
||||
@@ -284,10 +166,7 @@ const compareReferences = (
|
||||
"references",
|
||||
leftProjection,
|
||||
rightProjection,
|
||||
links,
|
||||
true,
|
||||
true,
|
||||
providersDiffer,
|
||||
context,
|
||||
);
|
||||
if (
|
||||
canonicalJson(leftProjection, "Function comparison") !==
|
||||
@@ -298,8 +177,7 @@ const compareReferences = (
|
||||
status: "changed",
|
||||
left: leftProjection,
|
||||
right: rightProjection,
|
||||
links,
|
||||
providersDiffer,
|
||||
...context,
|
||||
leftCount: leftProjection.length,
|
||||
rightCount: rightProjection.length,
|
||||
textDelta: null,
|
||||
@@ -310,82 +188,75 @@ const compareReferences = (
|
||||
const leftKinds = referenceKindProjection(left);
|
||||
const rightKinds = referenceKindProjection(right);
|
||||
if (leftKinds !== null && rightKinds !== null)
|
||||
return compareValues(
|
||||
"references",
|
||||
leftKinds,
|
||||
rightKinds,
|
||||
links,
|
||||
true,
|
||||
true,
|
||||
providersDiffer,
|
||||
);
|
||||
return unresolvedDimension(
|
||||
"references",
|
||||
"unknown",
|
||||
links,
|
||||
leftProjection.length,
|
||||
rightProjection.length,
|
||||
[
|
||||
return compareValues("references", leftKinds, rightKinds, context);
|
||||
return unresolvedDimension({
|
||||
dimension: "references",
|
||||
links: context.links,
|
||||
leftCount: leftProjection.length,
|
||||
rightCount: rightProjection.length,
|
||||
limitations: [
|
||||
"At least one provider did not expose reference kinds, so equal endpoints do not prove equal edge semantics.",
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const compareIdentity = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
if (isAutoName(left.procedure.name) || isAutoName(right.procedure.name))
|
||||
return unresolvedDimension("identity", "unknown", links, null, null, [
|
||||
"Address-derived function names are not stable cross-version identity.",
|
||||
]);
|
||||
if (
|
||||
isAutoName(left.dossier.procedure.name) ||
|
||||
isAutoName(right.dossier.procedure.name)
|
||||
)
|
||||
return unresolvedDimension({
|
||||
dimension: "identity",
|
||||
links: context.links,
|
||||
leftCount: null,
|
||||
rightCount: null,
|
||||
limitations: [
|
||||
"Address-derived function names are not stable cross-version identity.",
|
||||
],
|
||||
});
|
||||
return compareValues(
|
||||
"identity",
|
||||
identityProjection(left),
|
||||
identityProjection(right),
|
||||
links,
|
||||
true,
|
||||
true,
|
||||
providersDiffer,
|
||||
context,
|
||||
);
|
||||
};
|
||||
|
||||
const compareCalls = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const leftValues = callProjection(left);
|
||||
const rightValues = callProjection(right);
|
||||
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
|
||||
return unresolvedDimension(
|
||||
"calls",
|
||||
"unknown",
|
||||
links,
|
||||
leftValues.length,
|
||||
rightValues.length,
|
||||
["Address-derived callee or caller names cannot be matched safely."],
|
||||
);
|
||||
return compareCollections(
|
||||
return unresolvedDimension({
|
||||
dimension: "calls",
|
||||
links: context.links,
|
||||
leftCount: leftValues.length,
|
||||
rightCount: rightValues.length,
|
||||
limitations: [
|
||||
"Address-derived callee or caller names cannot be matched safely.",
|
||||
],
|
||||
});
|
||||
return compareValues(
|
||||
"calls",
|
||||
leftValues,
|
||||
rightValues,
|
||||
combineCoverage(left.collections.callers, left.collections.callees),
|
||||
combineCoverage(right.collections.callers, right.collections.callees),
|
||||
links,
|
||||
providersDiffer,
|
||||
sorted(leftValues),
|
||||
sorted(rightValues),
|
||||
context,
|
||||
);
|
||||
};
|
||||
|
||||
const callProjection = (snapshot: FunctionSnapshot) => [
|
||||
...project(snapshot.collections.callers, ({ name }) => ({
|
||||
...snapshot.dossier.callers.map(({ name }) => ({
|
||||
direction: "in" as const,
|
||||
name,
|
||||
})),
|
||||
...project(snapshot.collections.callees, ({ name }) => ({
|
||||
...snapshot.dossier.callees.map(({ name }) => ({
|
||||
direction: "out" as const,
|
||||
name,
|
||||
})),
|
||||
@@ -394,63 +265,31 @@ const callProjection = (snapshot: FunctionSnapshot) => [
|
||||
const compareCfg = (
|
||||
left: FunctionSnapshot,
|
||||
right: FunctionSnapshot,
|
||||
links: readonly string[],
|
||||
providersDiffer: boolean,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const leftBlocks = left.collections.basic_blocks;
|
||||
const rightBlocks = right.collections.basic_blocks;
|
||||
if (!leftBlocks.complete || !rightBlocks.complete)
|
||||
return unresolvedDimension(
|
||||
"cfg",
|
||||
leftBlocks.truncated || rightBlocks.truncated ? "truncated" : "unknown",
|
||||
links,
|
||||
leftBlocks.items.length,
|
||||
rightBlocks.items.length,
|
||||
[],
|
||||
);
|
||||
const leftGraph = normalizeCfg(leftBlocks.items);
|
||||
const rightGraph = normalizeCfg(rightBlocks.items);
|
||||
const leftBlocks = left.dossier.basic_blocks;
|
||||
const rightBlocks = right.dossier.basic_blocks;
|
||||
const leftGraph = normalizeCfg(leftBlocks);
|
||||
const rightGraph = normalizeCfg(rightBlocks);
|
||||
if (leftGraph === null || rightGraph === null)
|
||||
return unresolvedDimension(
|
||||
"cfg",
|
||||
"unknown",
|
||||
links,
|
||||
leftBlocks.items.length,
|
||||
rightBlocks.items.length,
|
||||
["CFG addresses could not be normalized to local block indices."],
|
||||
);
|
||||
return compareValues(
|
||||
"cfg",
|
||||
leftGraph,
|
||||
rightGraph,
|
||||
links,
|
||||
true,
|
||||
true,
|
||||
providersDiffer,
|
||||
);
|
||||
return unresolvedDimension({
|
||||
dimension: "cfg",
|
||||
links: context.links,
|
||||
leftCount: leftBlocks.length,
|
||||
rightCount: rightBlocks.length,
|
||||
limitations: [
|
||||
"CFG addresses could not be normalized to local block indices.",
|
||||
],
|
||||
});
|
||||
return compareValues("cfg", leftGraph, rightGraph, context);
|
||||
};
|
||||
|
||||
const compareValues = (
|
||||
...[
|
||||
dimension,
|
||||
left,
|
||||
right,
|
||||
links,
|
||||
leftComplete,
|
||||
rightComplete,
|
||||
providersDiffer,
|
||||
]: readonly [
|
||||
DimensionName,
|
||||
unknown,
|
||||
unknown,
|
||||
readonly string[],
|
||||
boolean,
|
||||
boolean,
|
||||
boolean,
|
||||
]
|
||||
dimension: DimensionName,
|
||||
left: unknown,
|
||||
right: unknown,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
if (!leftComplete || !rightComplete)
|
||||
return unresolvedDimension(dimension, "unknown", links, null, null, []);
|
||||
const leftJson = canonicalJson(left, "Function comparison");
|
||||
const rightJson = canonicalJson(right, "Function comparison");
|
||||
return dimensionResult({
|
||||
@@ -458,8 +297,7 @@ const compareValues = (
|
||||
status: leftJson === rightJson ? "unchanged" : "changed",
|
||||
left,
|
||||
right,
|
||||
links,
|
||||
providersDiffer,
|
||||
...context,
|
||||
leftCount: Array.isArray(left) ? left.length : null,
|
||||
rightCount: Array.isArray(right) ? right.length : null,
|
||||
textDelta: null,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { canonicalJson } from "./comparisonSemantics.js";
|
||||
import { compareCodePoints } from "./canonicalOrdering.js";
|
||||
|
||||
import type {
|
||||
FunctionCollection,
|
||||
FunctionSnapshot,
|
||||
} from "./functionDossierEvidence.js";
|
||||
import type { FunctionSnapshot } from "./functionDossierEvidence.js";
|
||||
import type { FunctionComparisonResult } from "./functionComparisonSchemas.js";
|
||||
|
||||
export const identityProjection = (snapshot: FunctionSnapshot) => ({
|
||||
name: snapshot.procedure.name,
|
||||
signature: snapshot.procedure.signature,
|
||||
locals: snapshot.procedure.locals
|
||||
name: snapshot.dossier.procedure.name,
|
||||
signature: snapshot.dossier.procedure.signature,
|
||||
locals: snapshot.dossier.procedure.locals
|
||||
.map(({ description }) => description)
|
||||
.sort(),
|
||||
});
|
||||
@@ -20,26 +17,28 @@ export const functionMatch = (
|
||||
right: FunctionSnapshot,
|
||||
): FunctionComparisonResult["function_match"] => {
|
||||
if (
|
||||
!isAutoName(left.procedure.name) &&
|
||||
left.procedure.name === right.procedure.name
|
||||
!isAutoName(left.dossier.procedure.name) &&
|
||||
left.dossier.procedure.name === right.dossier.procedure.name
|
||||
)
|
||||
return {
|
||||
status: "matched",
|
||||
method: "symbol",
|
||||
left_name: left.procedure.name,
|
||||
right_name: right.procedure.name,
|
||||
left_name: left.dossier.procedure.name,
|
||||
right_name: right.dossier.procedure.name,
|
||||
};
|
||||
return {
|
||||
status:
|
||||
left.procedure.name === right.procedure.name ? "ambiguous" : "mismatched",
|
||||
left.dossier.procedure.name === right.dossier.procedure.name
|
||||
? "ambiguous"
|
||||
: "mismatched",
|
||||
method: "explicit",
|
||||
left_name: left.procedure.name,
|
||||
right_name: right.procedure.name,
|
||||
left_name: left.dossier.procedure.name,
|
||||
right_name: right.dossier.procedure.name,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeCfg = (
|
||||
blocks: FunctionSnapshot["collections"]["basic_blocks"]["items"],
|
||||
blocks: FunctionSnapshot["dossier"]["basic_blocks"],
|
||||
): readonly unknown[] | null => {
|
||||
const parsed: {
|
||||
block: (typeof blocks)[number];
|
||||
@@ -78,12 +77,12 @@ export const normalizeCfg = (
|
||||
|
||||
export const referenceProjection = (snapshot: FunctionSnapshot) =>
|
||||
sorted([
|
||||
...snapshot.collections.incoming_references.items.map((item) => ({
|
||||
...snapshot.dossier.incoming_references.map((item) => ({
|
||||
direction: "in",
|
||||
source: item.source_procedure?.name ?? null,
|
||||
target: item.target_procedure?.name ?? null,
|
||||
})),
|
||||
...snapshot.collections.outgoing_references.items.map((item) => ({
|
||||
...snapshot.dossier.outgoing_references.map((item) => ({
|
||||
direction: "out",
|
||||
source: item.source_procedure?.name ?? null,
|
||||
target: item.target_procedure?.name ?? null,
|
||||
@@ -94,11 +93,11 @@ export const referenceKindProjection = (
|
||||
snapshot: FunctionSnapshot,
|
||||
): readonly unknown[] | null => {
|
||||
const edges = [
|
||||
...snapshot.collections.incoming_references.items.map((edge) => ({
|
||||
...snapshot.dossier.incoming_references.map((edge) => ({
|
||||
direction: "in",
|
||||
edge,
|
||||
})),
|
||||
...snapshot.collections.outgoing_references.items.map((edge) => ({
|
||||
...snapshot.dossier.outgoing_references.map((edge) => ({
|
||||
direction: "out",
|
||||
edge,
|
||||
})),
|
||||
@@ -136,13 +135,11 @@ export const referenceKindProjection = (
|
||||
export const commentProjection = (
|
||||
snapshot: FunctionSnapshot,
|
||||
): readonly unknown[] | null => {
|
||||
const values = snapshot.collections.comments.items.map(
|
||||
({ address, kind, text }) => ({
|
||||
offset: relativeAddress(address, snapshot.procedure.address),
|
||||
kind,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
const values = snapshot.dossier.comments.map(({ address, kind, text }) => ({
|
||||
offset: relativeAddress(address, snapshot.dossier.procedure.address),
|
||||
kind,
|
||||
text,
|
||||
}));
|
||||
return values.some(({ offset }) => offset === null) ? null : sorted(values);
|
||||
};
|
||||
|
||||
@@ -150,22 +147,22 @@ export const stringAndNameProjection = (
|
||||
snapshot: FunctionSnapshot,
|
||||
): readonly unknown[] | null => {
|
||||
const values = [
|
||||
...snapshot.collections.referenced_strings.items.map(
|
||||
...snapshot.dossier.referenced_strings.map(
|
||||
({ source_address: sourceAddress, value }) => ({
|
||||
kind: "string",
|
||||
source_offset: relativeAddress(
|
||||
sourceAddress,
|
||||
snapshot.procedure.address,
|
||||
snapshot.dossier.procedure.address,
|
||||
),
|
||||
value,
|
||||
}),
|
||||
),
|
||||
...snapshot.collections.referenced_names.items.map(
|
||||
...snapshot.dossier.referenced_names.map(
|
||||
({ source_address: sourceAddress, value }) => ({
|
||||
kind: "name",
|
||||
source_offset: relativeAddress(
|
||||
sourceAddress,
|
||||
snapshot.procedure.address,
|
||||
snapshot.dossier.procedure.address,
|
||||
),
|
||||
value,
|
||||
}),
|
||||
@@ -182,32 +179,9 @@ export const sorted = (values: readonly unknown[]): readonly unknown[] =>
|
||||
value,
|
||||
json: canonicalJson(value, "Function normalization"),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
// Preserve existing collation while ordering distinct values within a tie.
|
||||
return (
|
||||
left.json.localeCompare(right.json) ||
|
||||
compareCodePoints(left.json, right.json)
|
||||
);
|
||||
})
|
||||
.sort((left, right) => compareCodePoints(left.json, right.json))
|
||||
.map(({ value }) => value);
|
||||
|
||||
export const combineCoverage = <Item>(
|
||||
left: FunctionCollection<Item>,
|
||||
right: FunctionCollection<Item>,
|
||||
): FunctionCollection<Item> => {
|
||||
return {
|
||||
items: [...left.items, ...right.items],
|
||||
total: left.total + right.total,
|
||||
complete: true,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const project = <Input, Output>(
|
||||
collection: FunctionCollection<Input>,
|
||||
mapper: (item: Input) => Output,
|
||||
): Output[] => collection.items.map(mapper);
|
||||
|
||||
export const isAutoName = (name: string): boolean =>
|
||||
/^(?:sub_[0-9a-f]+|fcn\.[0-9a-f]+)$/iu.test(name);
|
||||
|
||||
|
||||
@@ -48,14 +48,13 @@ export const dimensionResult = (
|
||||
|
||||
export const unresolvedDimension = (input: {
|
||||
readonly dimension: DimensionName;
|
||||
readonly status: "truncated" | "unknown";
|
||||
readonly links: readonly string[];
|
||||
readonly leftCount: number | null;
|
||||
readonly rightCount: number | null;
|
||||
readonly limitations: readonly string[];
|
||||
}): FunctionDimension => ({
|
||||
dimension: input.dimension,
|
||||
status: input.status,
|
||||
status: "unknown",
|
||||
left_digest: null,
|
||||
right_digest: null,
|
||||
left_count: input.leftCount,
|
||||
@@ -70,8 +69,6 @@ export const overallStatus = (
|
||||
dimensions: readonly FunctionDimension[],
|
||||
match: FunctionComparisonResult["function_match"]["status"],
|
||||
): FunctionComparisonResult["status"] => {
|
||||
if (dimensions.some(({ status }) => status === "truncated"))
|
||||
return "truncated";
|
||||
if (
|
||||
match === "ambiguous" ||
|
||||
dimensions.some(({ status }) => status === "unknown")
|
||||
@@ -86,7 +83,6 @@ export const overallStatus = (
|
||||
export const summarize = (dimensions: readonly FunctionDimension[]) => ({
|
||||
unchanged: dimensions.filter(({ status }) => status === "unchanged").length,
|
||||
changed: dimensions.filter(({ status }) => status === "changed").length,
|
||||
truncated: dimensions.filter(({ status }) => status === "truncated").length,
|
||||
unknown: dimensions.filter(({ status }) => status === "unknown").length,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,7 @@ import { evidenceSchema } from "./evidence.js";
|
||||
import { digestSchema } from "./../domain/digests.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
|
||||
const comparisonStatusSchema = z.enum([
|
||||
"unchanged",
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
]);
|
||||
const comparisonStatusSchema = z.enum(["unchanged", "changed", "unknown"]);
|
||||
const dimensionNameSchema = z.enum([
|
||||
"identity",
|
||||
"pseudocode",
|
||||
@@ -60,7 +55,7 @@ const functionDimensionSchema = z.union([
|
||||
}),
|
||||
z.object({
|
||||
...functionDimensionContextShape,
|
||||
status: z.enum(["truncated", "unknown"]),
|
||||
status: z.literal("unknown"),
|
||||
left_digest: z.null(),
|
||||
right_digest: z.null(),
|
||||
text_delta: z.null(),
|
||||
@@ -92,7 +87,6 @@ export const functionComparisonResultSchema = z.object({
|
||||
summary: z.object({
|
||||
unchanged: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
truncated: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
}),
|
||||
dimensions: z.array(functionDimensionSchema).length(8),
|
||||
|
||||
@@ -1,48 +1,11 @@
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js";
|
||||
|
||||
const DOSSIER_COLLECTION_FIELDS = [
|
||||
"assembly",
|
||||
"comments",
|
||||
"callers",
|
||||
"callees",
|
||||
"incoming_references",
|
||||
"outgoing_references",
|
||||
"referenced_strings",
|
||||
"referenced_names",
|
||||
"basic_blocks",
|
||||
] as const;
|
||||
|
||||
type DossierCollectionField = (typeof DOSSIER_COLLECTION_FIELDS)[number];
|
||||
type DossierItem<Field extends DossierCollectionField> =
|
||||
FunctionDossier[Field][number];
|
||||
|
||||
export type FunctionCollection<Item = unknown> = {
|
||||
readonly items: readonly Item[];
|
||||
readonly total: number;
|
||||
readonly complete: true;
|
||||
readonly truncated: false;
|
||||
};
|
||||
|
||||
type FunctionCollections = {
|
||||
readonly [Field in DossierCollectionField]: FunctionCollection<
|
||||
DossierItem<Field>
|
||||
>;
|
||||
};
|
||||
|
||||
/** Complete function observation parsed from one analyze_function Evidence record. */
|
||||
export interface FunctionSnapshot {
|
||||
readonly evidence: readonly [Evidence];
|
||||
readonly procedure: FunctionDossier["procedure"];
|
||||
readonly provider: Evidence["provider"];
|
||||
readonly evidence: Evidence;
|
||||
readonly subject: NonNullable<Evidence["subject"]>;
|
||||
readonly pseudocode: {
|
||||
readonly text: string;
|
||||
readonly total: number;
|
||||
readonly complete: true;
|
||||
readonly truncated: false;
|
||||
};
|
||||
readonly collections: FunctionCollections;
|
||||
readonly dossier: FunctionDossier;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
@@ -57,38 +20,11 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => {
|
||||
throw new TypeError("Function comparison requires artifact-bound Evidence");
|
||||
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
||||
return {
|
||||
evidence: [evidence],
|
||||
procedure: dossier.procedure,
|
||||
provider: evidence.provider,
|
||||
evidence,
|
||||
subject: evidence.subject,
|
||||
pseudocode: {
|
||||
text: dossier.pseudocode,
|
||||
total: [...dossier.pseudocode].length,
|
||||
complete: true,
|
||||
truncated: false,
|
||||
},
|
||||
collections: {
|
||||
assembly: completeCollection(dossier.assembly),
|
||||
comments: completeCollection(dossier.comments),
|
||||
callers: completeCollection(dossier.callers),
|
||||
callees: completeCollection(dossier.callees),
|
||||
incoming_references: completeCollection(dossier.incoming_references),
|
||||
outgoing_references: completeCollection(dossier.outgoing_references),
|
||||
referenced_strings: completeCollection(dossier.referenced_strings),
|
||||
referenced_names: completeCollection(dossier.referenced_names),
|
||||
basic_blocks: completeCollection(dossier.basic_blocks),
|
||||
},
|
||||
dossier,
|
||||
limitations: [
|
||||
...new Set([...evidence.limitations, ...dossier.limitations]),
|
||||
].sort((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
};
|
||||
|
||||
const completeCollection = <Item>(
|
||||
items: readonly Item[],
|
||||
): FunctionCollection<Item> => ({
|
||||
items,
|
||||
total: items.length,
|
||||
complete: true,
|
||||
truncated: false,
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("historical source graph", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains actual external targets and accepts legacy markers", () => {
|
||||
it("retains actual external targets and rejects placeholder targets", () => {
|
||||
const input = { ...graphInput(), inventory_state: "partial" as const };
|
||||
input.entries[2] = symlinkEntry({
|
||||
target: "/private/shared/target.js",
|
||||
@@ -205,7 +205,7 @@ describe("historical source graph", () => {
|
||||
: entry,
|
||||
),
|
||||
}),
|
||||
).not.toThrow();
|
||||
).toThrow(/target path does not match/u);
|
||||
});
|
||||
|
||||
it("derives language and manifest indexes and enforces code-point order", () => {
|
||||
@@ -337,7 +337,7 @@ describe("historical source graph validation", () => {
|
||||
: entry,
|
||||
),
|
||||
}),
|
||||
).not.toThrow();
|
||||
).toThrow(/target path does not match/u);
|
||||
expect(() =>
|
||||
createHistoricalSourceGraph({
|
||||
...graphInput(),
|
||||
|
||||
@@ -80,7 +80,6 @@ const symlinkTargetSchema = z
|
||||
.min(1)
|
||||
.refine(
|
||||
(target) =>
|
||||
target === "<outside-root>" ||
|
||||
isPortableAbsoluteSymlinkTarget(target) ||
|
||||
(!target.startsWith("/") &&
|
||||
!target.includes("\\") &&
|
||||
@@ -317,12 +316,8 @@ const checkSymlinks = (
|
||||
if (entry.kind !== "symlink") continue;
|
||||
const absolute = isPortableAbsoluteSymlinkTarget(entry.target);
|
||||
if (
|
||||
(entry.target_state === "external" &&
|
||||
entry.target !== "<outside-root>" &&
|
||||
!absolute) ||
|
||||
(entry.target_state === "internal" &&
|
||||
(entry.target === "<outside-root>" || absolute)) ||
|
||||
(entry.target_state === "missing" && entry.target === "<outside-root>")
|
||||
(entry.target_state === "external" && !absolute) ||
|
||||
(entry.target_state === "internal" && absolute)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
||||
Reference in new issue
Block a user