Own endpoint identity and per-edge kind knowledge in one parsed reference representation. Preserve exact same-artifact coordinates and cross-artifact name uncertainty, including generated and nonunique procedure labels. Use exact multiplicity-preserving sparse compatibility flow instead of positional/greedy pairing or a quadratic adjacency matrix. Share the native coordinate parser with call paths and remove the superseded projections. Validate public MCP comparison/reconstruction outcomes, independent tiny permutation oracles, dense matching and advertised JSON Schemas. Fixes #4210 Fixes #5131 Co-authored-by: Henglie <ebhenglie@gmail.com>
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import { canonicalJson } from "./comparisonSemantics.js";
|
|
import { compareFunctionReferences } from "./functionReferenceComparison.js";
|
|
import { diffLines } from "diff";
|
|
|
|
import type { FunctionSnapshot } from "./functionDossierEvidence.js";
|
|
import {
|
|
commentProjection,
|
|
identityProjection,
|
|
isAutoName,
|
|
normalizeCfg,
|
|
sorted,
|
|
stringAndNameProjection,
|
|
swiftLiteralSitesConflict,
|
|
} from "./functionComparisonNormalization.js";
|
|
import type {
|
|
DimensionName,
|
|
FunctionDimension,
|
|
} from "./functionComparisonSchemas.js";
|
|
import {
|
|
dimensionResult,
|
|
unresolvedDimension,
|
|
type DimensionResultInput,
|
|
} from "./functionComparisonResults.js";
|
|
|
|
type ComparisonContext = Pick<
|
|
DimensionResultInput,
|
|
"links" | "providersDiffer"
|
|
>;
|
|
|
|
export const compareDimensions = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
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),
|
|
].map((result) => {
|
|
const limitations = [
|
|
...facetLimitations(left, result.dimension).map(
|
|
(reason) => `Left: ${reason}`,
|
|
),
|
|
...facetLimitations(right, result.dimension).map(
|
|
(reason) => `Right: ${reason}`,
|
|
),
|
|
];
|
|
return limitations.length === 0
|
|
? result
|
|
: unresolvedDimension({
|
|
dimension: result.dimension,
|
|
links: context.links,
|
|
leftCount: null,
|
|
rightCount: null,
|
|
limitations: [...result.limitations, ...limitations],
|
|
});
|
|
});
|
|
};
|
|
|
|
/** An empty undeclared facet is not evidence of observed absence. */
|
|
const facetLimitations = (
|
|
snapshot: FunctionSnapshot,
|
|
dimension: DimensionName,
|
|
): string[] => {
|
|
const { dossier } = snapshot;
|
|
const fields = {
|
|
identity: [],
|
|
pseudocode: [["pseudocode", dossier.pseudocode.length]],
|
|
assembly: [["assembly", dossier.assembly.length]],
|
|
comments: [["comments", dossier.comments.length]],
|
|
calls: [
|
|
["callers", dossier.callers.length],
|
|
["callees", dossier.callees.length],
|
|
],
|
|
references: [
|
|
[
|
|
"references",
|
|
dossier.incoming_references.length + dossier.outgoing_references.length,
|
|
],
|
|
],
|
|
strings_names: [
|
|
[
|
|
"strings_names",
|
|
dossier.referenced_strings.length + dossier.referenced_names.length,
|
|
],
|
|
],
|
|
cfg: [["cfg", dossier.basic_blocks.length]],
|
|
} satisfies Record<
|
|
DimensionName,
|
|
[keyof NonNullable<typeof dossier.facet_availability>, number][]
|
|
>;
|
|
return fields[dimension].flatMap(([field, count]) => {
|
|
const availability = dossier.facet_availability?.[field];
|
|
if (availability?.available === false)
|
|
return [`${field}: ${availability.reason}`];
|
|
return availability === undefined && count === 0
|
|
? [`${field}: Provider did not declare coverage for this empty facet.`]
|
|
: [];
|
|
});
|
|
};
|
|
|
|
const compareText = (
|
|
dimension: "pseudocode" | "assembly",
|
|
left: string,
|
|
right: string,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
const limitations = [
|
|
"Exact provider text is compared without address or generated-name normalization; layout-only differences do not establish a behavior change.",
|
|
...(dimension === "assembly"
|
|
? [
|
|
"Assembly is opaque provider text; relocation normalization is unavailable.",
|
|
]
|
|
: []),
|
|
];
|
|
if (context.providersDiffer)
|
|
return unresolvedDimension({
|
|
dimension,
|
|
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({
|
|
dimension,
|
|
links: context.links,
|
|
leftCount: [...left].length,
|
|
rightCount: [...right].length,
|
|
limitations: [
|
|
...limitations,
|
|
"Bounded line diff exceeded its time or edit-distance limit.",
|
|
],
|
|
});
|
|
const delta = changes.reduce(
|
|
(summary, change) => ({
|
|
added_lines:
|
|
summary.added_lines + (change.added ? (change.count ?? 0) : 0),
|
|
removed_lines:
|
|
summary.removed_lines + (change.removed ? (change.count ?? 0) : 0),
|
|
hunks: summary.hunks + (change.added || change.removed ? 1 : 0),
|
|
}),
|
|
{ added_lines: 0, removed_lines: 0, hunks: 0 },
|
|
);
|
|
return dimensionResult({
|
|
dimension,
|
|
status: delta.hunks === 0 ? "unchanged" : "changed",
|
|
left,
|
|
right,
|
|
...context,
|
|
leftCount: [...left].length,
|
|
rightCount: [...right].length,
|
|
textDelta: delta,
|
|
limitations,
|
|
});
|
|
};
|
|
|
|
const compareComments = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
const leftValues = commentProjection(left);
|
|
const rightValues = commentProjection(right);
|
|
if (leftValues === null || rightValues === null)
|
|
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,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
const complete =
|
|
swiftLiteralCoverage(left) === swiftLiteralCoverage(right) &&
|
|
swiftLiteralCoverage(left) !== "partial";
|
|
// Facts both dossiers record decide a change before Swift literal coverage can.
|
|
const leftValues = stringAndNameProjection(left, complete);
|
|
const rightValues = stringAndNameProjection(right, complete);
|
|
if (leftValues === null || rightValues === null)
|
|
return unresolvedDimension({
|
|
dimension: "strings_names",
|
|
links: context.links,
|
|
leftCount: null,
|
|
rightCount: null,
|
|
limitations: [
|
|
"Reference source locations could not be normalized to function offsets.",
|
|
],
|
|
});
|
|
const compared = compareValues(
|
|
"strings_names",
|
|
leftValues,
|
|
rightValues,
|
|
context,
|
|
);
|
|
if (complete || compared.status === "changed") return compared;
|
|
// A retained literal differs only when the opposite dossier establishes its absence.
|
|
if (swiftLiteralSitesConflict(left, right)) {
|
|
const leftAll = stringAndNameProjection(left);
|
|
const rightAll = stringAndNameProjection(right);
|
|
if (leftAll !== null && rightAll !== null)
|
|
return compareValues("strings_names", leftAll, rightAll, context);
|
|
}
|
|
return unresolvedDimension({
|
|
dimension: "strings_names",
|
|
links: context.links,
|
|
leftCount: null,
|
|
rightCount: null,
|
|
limitations: [
|
|
"Swift String literal coverage differs or is partial; referenced strings and names match, and complete coverage does not establish a differing literal.",
|
|
],
|
|
});
|
|
};
|
|
|
|
const swiftLiteralCoverage = (
|
|
snapshot: FunctionSnapshot,
|
|
): "absent" | "complete" | "partial" =>
|
|
(snapshot.dossier.swift_string_literal_omissions ?? []).length > 0
|
|
? "partial"
|
|
: snapshot.dossier.swift_string_literals === undefined
|
|
? "absent"
|
|
: "complete";
|
|
|
|
const compareReferences = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
const compared = compareFunctionReferences(left, right);
|
|
if (compared.status === "unknown")
|
|
return unresolvedDimension({
|
|
dimension: "references",
|
|
links: context.links,
|
|
leftCount: compared.leftCount,
|
|
rightCount: compared.rightCount,
|
|
limitations: compared.limitations,
|
|
});
|
|
return dimensionResult({
|
|
dimension: "references",
|
|
...compared,
|
|
...context,
|
|
textDelta: null,
|
|
});
|
|
};
|
|
|
|
const compareIdentity = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
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),
|
|
context,
|
|
);
|
|
};
|
|
|
|
const compareCalls = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
const leftValues = callProjection(left);
|
|
const rightValues = callProjection(right);
|
|
const coverageLimitations = [
|
|
...left.callCoverageBlockers.map((reason) => `Left: ${reason}`),
|
|
...right.callCoverageBlockers.map((reason) => `Right: ${reason}`),
|
|
];
|
|
const coverageDiagnostics = [
|
|
...left.callCoverageDiagnostics.map((reason) => `Left: ${reason}`),
|
|
...right.callCoverageDiagnostics.map((reason) => `Right: ${reason}`),
|
|
];
|
|
if (coverageLimitations.length > 0)
|
|
return unresolvedDimension({
|
|
dimension: "calls",
|
|
links: context.links,
|
|
leftCount: leftValues.length,
|
|
rightCount: rightValues.length,
|
|
limitations: [...coverageLimitations, ...coverageDiagnostics],
|
|
});
|
|
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
|
|
return unresolvedDimension({
|
|
dimension: "calls",
|
|
links: context.links,
|
|
leftCount: leftValues.length,
|
|
rightCount: rightValues.length,
|
|
limitations: [
|
|
"Address-derived callee or caller names cannot be matched safely.",
|
|
...coverageDiagnostics,
|
|
],
|
|
});
|
|
return compareValues(
|
|
"calls",
|
|
sorted(leftValues),
|
|
sorted(rightValues),
|
|
context,
|
|
coverageDiagnostics,
|
|
);
|
|
};
|
|
|
|
const callProjection = (snapshot: FunctionSnapshot) => [
|
|
...snapshot.dossier.callers.map(({ name }) => ({
|
|
direction: "in" as const,
|
|
name,
|
|
})),
|
|
...snapshot.dossier.callees.map(({ name }) => ({
|
|
direction: "out" as const,
|
|
name,
|
|
})),
|
|
];
|
|
|
|
const compareCfg = (
|
|
left: FunctionSnapshot,
|
|
right: FunctionSnapshot,
|
|
context: ComparisonContext,
|
|
): FunctionDimension => {
|
|
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({
|
|
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: DimensionName,
|
|
left: unknown,
|
|
right: unknown,
|
|
context: ComparisonContext,
|
|
limitations: readonly string[] = [],
|
|
): FunctionDimension => {
|
|
const leftJson = canonicalJson(left, "Function comparison");
|
|
const rightJson = canonicalJson(right, "Function comparison");
|
|
return dimensionResult({
|
|
dimension,
|
|
status: leftJson === rightJson ? "unchanged" : "changed",
|
|
left,
|
|
right,
|
|
...context,
|
|
leftCount: Array.isArray(left) ? left.length : null,
|
|
rightCount: Array.isArray(right) ? right.length : null,
|
|
textDelta: null,
|
|
limitations,
|
|
});
|
|
};
|