From 688defca0f4a3de04eb199f7754f323c68896c3c Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:31 +0800 Subject: [PATCH] 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> --- ...uctionVerification.functionSources.test.ts | 117 +++++++++++++++++- src/domain/reconstructionVerification.ts | 46 +++++-- .../mcp/comparisonUnknownsMcp.test.ts | 3 + 3 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 5dc8db589..e23d37d74 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -53,6 +53,7 @@ const source = ( digit: string, observation: Partial = {}, 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", { diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index dd300a0a2..6f693a158 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -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 }) => diff --git a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts index bf950f83b..f872d2f15 100644 --- a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts +++ b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts @@ -49,6 +49,9 @@ const observeAgain = ( result, confidence: source.confidence, authority: source.authority, + ...(source.relationship_coverage === null + ? {} + : { relationshipCoverage: source.relationship_coverage }), }, );