fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Lucien 2026-10-13 23:30:31 +08:00 committed by GitHub
parent 3c39a3f536
commit 688defca0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 154 additions and 12 deletions

View File

@ -53,6 +53,7 @@ const source = (
digit: string,
observation: Partial<EvidenceObservation> = {},
hasSubject = true,
hasCoverage = true,
): Evidence =>
createEvidence(
hasSubject
@ -69,7 +70,9 @@ const source = (
result: dossier,
confidence: "derived",
authority: "shipped-artifact",
relationshipCoverage: { status: "complete" },
...(hasCoverage
? { relationshipCoverage: { status: "complete" as const } }
: {}),
...observation,
},
);
@ -197,6 +200,118 @@ describe("function reconstruction source parameters", () => {
});
});
describe("historical calls-only reconstruction changes", () => {
it.each(["missing", "unknown", "partial", "unresolved"] as const)(
"keeps historical calls-only changes unknown with %s call coverage",
(coverage) => {
const changedDossier = functionDossierSchema.parse({
...functionDossierSchema.parse(dossier),
callees: [
{
address: "0x2000",
name: "puts",
classification: null,
body: { available: false, reason: "External function." },
},
],
});
const complete = source("8", {
result: jsonValueSchema.parse(changedDossier),
});
const incomplete = source(
"9",
{
result: jsonValueSchema.parse({
...changedDossier,
...(coverage === "unresolved"
? {
unresolved_calls: [
{
address: "0x1004",
reason: "Computed target unresolved.",
},
],
}
: {}),
}),
...(coverage === "missing"
? {}
: {
relationshipCoverage:
coverage === "unknown"
? { status: "unknown", reason: "Call graph unavailable." }
: coverage === "partial"
? {
status: "partial",
limitations: ["Additional edges omitted."],
}
: { status: "complete" },
}),
},
true,
coverage !== "missing",
);
const historicalResult = compareFunctions(left, complete);
expect(historicalResult).toMatchObject({
status: "changed",
summary: { changed: 1 },
});
expect(historicalResult.changes).toMatchObject([
{ dimension: "calls", status: "changed" },
]);
const completeComparison = comparison(
{
left_evidence_id: left.evidence_id,
right_evidence_id: complete.evidence_id,
},
{},
[left, complete],
{},
historicalResult,
);
expect(
admittedVerification(completeComparison, [left, complete], "overall")()
.status,
).toBe("fail");
const evidence = comparison(
{
left_evidence_id: left.evidence_id,
right_evidence_id: incomplete.evidence_id,
},
{},
[left, incomplete],
{},
historicalResult,
);
const before = JSON.stringify(evidence);
for (const dimension of ["calls", "overall"] as const) {
expect(
admittedVerification(evidence, [left, incomplete], dimension)(),
).toMatchObject({
status: "unknown",
summary: { total: 1, passed: 0, failed: 0, unknown: 1 },
claims: {
items: [
expect.objectContaining({
status: "unknown",
observed_status: "changed",
limitations: expect.arrayContaining([
expect.stringContaining(`Source ${incomplete.evidence_id}:`),
]),
}),
],
},
});
}
expect(
admittedVerification(evidence, [left, incomplete], "pseudocode")()
.status,
).toBe("pass");
expect(JSON.stringify(evidence)).toBe(before);
},
);
});
describe("function reconstruction call coverage", () => {
it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => {
const incomplete = source("7", {

View File

@ -144,10 +144,15 @@ const evaluateClaim = (
return source;
});
const functionSourceFacts = readValidatedSourceFacts(claim, sources);
const observed = observedStatus(claim, comparison);
const observation = comparisonObservation(claim, comparison);
const observed = observation.status;
const limitations = [
...authorityLimitations(claim, sources, sides),
...sourceCallCoverageLimitations(claim, functionSourceFacts, observed),
...sourceCallCoverageLimitations(
claim,
functionSourceFacts,
observation.hasIndependentFunctionChange === true,
),
];
const relatedUnknowns = reconstructionClaimUnknowns(
heads,
@ -235,21 +240,40 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => {
throw new TypeError("Comparison Evidence identity or authority disagrees");
};
const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
const comparisonObservation = (
claim: Claim,
evidence: Evidence,
): {
readonly status: ObservedStatus;
readonly hasIndependentFunctionChange?: boolean;
} => {
if (claim.kind === "behavioral") {
const result = processCaptureComparisonSchema.parse(
evidence.normalized_result,
);
validateProcessStatus(result);
return claim.dimension === "overall"
? result.status
: result[claim.dimension];
return {
status:
claim.dimension === "overall" ? result.status : result[claim.dimension],
};
}
if (claim.kind === "structural-function") {
const result = functionComparisonResultSchema.parse(
evidence.normalized_result,
);
if (claim.dimension === "overall") return result.status;
if (claim.dimension === "overall")
return {
status: result.status,
// Historical overall changes may come solely from incomplete call sets.
hasIndependentFunctionChange:
result.function_match.status === "mismatched" ||
(result.function_match.status === "matched" &&
result.dimensions.some(
(dimension) =>
dimension.dimension !== "calls" &&
dimension.status === "changed",
)),
};
const dimension = result.dimensions.find(
(item) => item.dimension === claim.dimension,
);
@ -263,7 +287,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
throw new TypeError(
"Function dimension cites Evidence outside comparison closure",
);
return dimension.status;
return { status: dimension.status };
}
const result = artifactComparisonResultSchema.parse(
evidence.normalized_result,
@ -276,7 +300,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
throw new TypeError(
"Artifact change cites Evidence outside comparison closure",
);
return result.status;
return { status: result.status };
};
const validateProcessStatus = (
@ -340,12 +364,12 @@ const authorityLimitations = (
const sourceCallCoverageLimitations = (
claim: Claim,
functionSources: readonly FunctionSourceFacts[],
observed: ObservedStatus,
hasIndependentFunctionChange: boolean,
): string[] => {
if (
claim.kind !== "structural-function" ||
(claim.dimension !== "calls" &&
!(claim.dimension === "overall" && observed !== "changed"))
!(claim.dimension === "overall" && !hasIndependentFunctionChange))
)
return [];
return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) =>

View File

@ -49,6 +49,9 @@ const observeAgain = (
result,
confidence: source.confidence,
authority: source.authority,
...(source.relationship_coverage === null
? {}
: { relationshipCoverage: source.relationship_coverage }),
},
);