From b9539e9c1c0cf428f0979e2d510ea39963fd99fc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:06:19 +0800 Subject: [PATCH] fix(functions): preserve coverage diagnostics separately from blockers --- src/domain/functionCallCoverage.test.ts | 24 ++++ src/domain/functionCallCoverage.ts | 21 ++-- src/domain/functionComparison.test.ts | 110 +++++++++++++++--- src/domain/functionComparisonDimensions.ts | 15 ++- src/domain/functionDossierEvidence.ts | 10 +- ...uctionVerification.functionSources.test.ts | 50 ++++++++ src/domain/reconstructionVerification.ts | 53 +++++---- 7 files changed, 230 insertions(+), 53 deletions(-) diff --git a/src/domain/functionCallCoverage.test.ts b/src/domain/functionCallCoverage.test.ts index 543f6ca92b..f3502996ef 100644 --- a/src/domain/functionCallCoverage.test.ts +++ b/src/domain/functionCallCoverage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + assessFunctionCallCoverage, combineFunctionRelationshipCoverage, isCallerOnlyIncompleteCoverage, } from "./functionCallCoverage.js"; @@ -88,3 +89,26 @@ describe("isCallerOnlyIncompleteCoverage", () => { ).toBe(false); }); }); + +describe("assessFunctionCallCoverage", () => { + it("keeps incomplete coverage and unresolved calls as equality blockers", () => { + expect( + assessFunctionCallCoverage( + { + status: "partial", + limitations: ["Read limit reached."], + diagnostics: ["Indirect calls are unresolved."], + }, + { + unresolved_calls: [{ address: "0x1004", reason: "Computed target." }], + }, + ), + ).toEqual({ + blockers: [ + "Read limit reached.", + "Unresolved call at 0x1004: Computed target.", + ], + diagnostics: ["Indirect calls are unresolved."], + }); + }); +}); diff --git a/src/domain/functionCallCoverage.ts b/src/domain/functionCallCoverage.ts index 06fc06fe5b..d09249d14c 100644 --- a/src/domain/functionCallCoverage.ts +++ b/src/domain/functionCallCoverage.ts @@ -78,16 +78,23 @@ export const isCallerOnlyIncompleteCoverage = ( ); }; -/** Explain why two resolved call sets cannot establish call-set equality. */ -export const functionCallCoverageLimitations = ( +export interface FunctionCallCoverageAssessment { + /** Reasons the observed call sets cannot establish equality. */ + readonly blockers: readonly string[]; + /** Provider caveats that remain relevant even with complete coverage. */ + readonly diagnostics: readonly string[]; +} + +/** Keep observed-set blockers separate from descriptive provider caveats. */ +export const assessFunctionCallCoverage = ( coverage: RelationshipCoverage | null, dossier: Pick, -): string[] => { - return uniqueSorted([ +): FunctionCallCoverageAssessment => ({ + blockers: uniqueSorted([ ...relationshipCoverageLimitations(coverage), - ...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])), ...(dossier.unresolved_calls ?? []).map( ({ address, reason }) => `Unresolved call at ${address}: ${reason}`, ), - ]); -}; + ]), + diagnostics: uniqueSorted(coverage?.diagnostics ?? []), +}); diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index e49b0a8925..28f0aae967 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -291,25 +291,53 @@ describe("function comparison normalized identity", () => { ).toMatchObject({ status: "unknown" }); }, ); +}); + +describe("function comparison address-derived call names", () => { it.each([ - { name: "sub_deallocate", status: "unchanged" }, - { name: "sub_deadbeef", status: "unknown" }, - { name: "FUN_00401000", status: "unknown" }, - { name: "FUN_initialize", status: "unchanged" }, - ])("preserves the calls dimension for callee $name", ({ name, status }) => { - const calling = (base: "0x1000" | "0x2000") => - functionDossierSchema.parse({ - ...dossier("return helper();", base), - callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)], - }); - const result = compareFunctions( - observe("6", calling("0x1000")), - observe("7", calling("0x2000")), - ); - expect( - result.dimensions.find(({ dimension }) => dimension === "calls"), - ).toMatchObject({ status }); - }); + { name: "sub_deallocate", status: "unchanged", diagnostic: undefined }, + { + name: "sub_deadbeef", + status: "unknown", + diagnostic: "Provider retained an unresolved call target.", + }, + { name: "FUN_00401000", status: "unknown", diagnostic: undefined }, + { name: "FUN_initialize", status: "unchanged", diagnostic: undefined }, + ])( + "preserves the calls dimension for callee $name", + ({ name, status, diagnostic }) => { + const calling = (base: "0x1000" | "0x2000") => + functionDossierSchema.parse({ + ...dossier("return helper();", base), + callees: [ + procedure(base === "0x1000" ? "0x1010" : "0x2010", name), + ], + }); + const result = compareFunctions( + observe("6", calling("0x1000")), + observe( + "7", + calling("0x2000"), + "rea-workflow", + diagnostic === undefined + ? { status: "complete" } + : { status: "complete", diagnostics: [diagnostic] }, + ), + ); + const calls = result.dimensions.find( + ({ dimension }) => dimension === "calls", + ); + expect(calls).toMatchObject({ status }); + if (diagnostic !== undefined) + expect(calls).toMatchObject({ + status: "unknown", + limitations: expect.arrayContaining([ + "Address-derived callee or caller names cannot be matched safely.", + `Right: ${diagnostic}`, + ]), + }); + }, + ); }); describe("function call relationship coverage", () => { @@ -681,6 +709,52 @@ describe("function comparison unresolved text", () => { }); describe("function comparison", () => { + it("retains complete call coverage diagnostics without blocking observed-set equality", () => { + const diagnostic = "Indirect calls remain unresolved."; + const left = observe("a", dossier("return 0;", "0x1000"), "rea-workflow", { + status: "complete", + diagnostics: [diagnostic], + }); + const right = observe("b", dossier("return 0;", "0x2000"), "rea-workflow", { + status: "complete", + diagnostics: [diagnostic], + }); + const comparison = compareFunctions(left, right); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "calls"), + ).toMatchObject({ + status: "unchanged", + limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`], + }); + }); + + it("retains diagnostics alongside incomplete call coverage blockers", () => { + const diagnostic = "Indirect calls remain unresolved."; + const left = observe("c", dossier("return 0;", "0x1000"), "rea-workflow", { + status: "partial", + limitations: ["Provider read limit reached."], + diagnostics: [diagnostic], + }); + const right = observe("d", dossier("return 0;", "0x2000"), "rea-workflow", { + status: "partial", + limitations: ["Provider read limit reached."], + diagnostics: [diagnostic], + }); + expect( + compareFunctions(left, right).dimensions.find( + ({ dimension }) => dimension === "calls", + ), + ).toMatchObject({ + status: "unknown", + limitations: expect.arrayContaining([ + "Left: Provider read limit reached.", + `Left: ${diagnostic}`, + "Right: Provider read limit reached.", + `Right: ${diagnostic}`, + ]), + }); + }); + it("preserves text changes alongside complete inline dossier facets", () => { const result = compareFunctions( FUNCTION_COMPARISON_EXAMPLE.left, diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index 4a36e1c275..f935e228bd 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -333,8 +333,12 @@ const compareCalls = ( const leftValues = callProjection(left); const rightValues = callProjection(right); const coverageLimitations = [ - ...left.callCoverageLimitations.map((reason) => `Left: ${reason}`), - ...right.callCoverageLimitations.map((reason) => `Right: ${reason}`), + ...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({ @@ -342,7 +346,7 @@ const compareCalls = ( links: context.links, leftCount: leftValues.length, rightCount: rightValues.length, - limitations: coverageLimitations, + limitations: [...coverageLimitations, ...coverageDiagnostics], }); if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name))) return unresolvedDimension({ @@ -352,6 +356,7 @@ const compareCalls = ( rightCount: rightValues.length, limitations: [ "Address-derived callee or caller names cannot be matched safely.", + ...coverageDiagnostics, ], }); return compareValues( @@ -359,6 +364,7 @@ const compareCalls = ( sorted(leftValues), sorted(rightValues), context, + coverageDiagnostics, ); }; @@ -400,6 +406,7 @@ const compareValues = ( left: unknown, right: unknown, context: ComparisonContext, + limitations: readonly string[] = [], ): FunctionDimension => { const leftJson = canonicalJson(left, "Function comparison"); const rightJson = canonicalJson(right, "Function comparison"); @@ -412,6 +419,6 @@ const compareValues = ( leftCount: Array.isArray(left) ? left.length : null, rightCount: Array.isArray(right) ? right.length : null, textDelta: null, - limitations: [], + limitations, }); }; diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index 35f5c4b8b0..9c2f906cce 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -1,7 +1,7 @@ import { parseEvidence, type Evidence } from "./evidence.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js"; -import { functionCallCoverageLimitations } from "./functionCallCoverage.js"; +import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; /** Complete function observation parsed from one analyze_function Evidence record. */ export interface FunctionSnapshot { @@ -9,7 +9,8 @@ export interface FunctionSnapshot { readonly subject: NonNullable; readonly dossier: FunctionDossier; readonly limitations: readonly string[]; - readonly callCoverageLimitations: readonly string[]; + readonly callCoverageBlockers: readonly string[]; + readonly callCoverageDiagnostics: readonly string[]; } /** Parse one complete analyze_function Evidence record for comparison workflows. */ @@ -22,7 +23,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => { if (evidence.subject === null) throw new TypeError("Function comparison requires artifact-bound Evidence"); const dossier = functionDossierSchema.parse(evidence.normalized_result); - const callCoverageLimitations = functionCallCoverageLimitations( + const callCoverage = assessFunctionCallCoverage( evidence.relationship_coverage, dossier, ); @@ -33,6 +34,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => { limitations: [ ...new Set([...evidence.limitations, ...dossier.limitations]), ].sort((left, right) => compareUnicodeCodePoints(left, right)), - callCoverageLimitations, + callCoverageBlockers: callCoverage.blockers, + callCoverageDiagnostics: callCoverage.diagnostics, }; }; diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 7ca6961bef..14220987de 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -323,11 +323,60 @@ describe("historical calls-only reconstruction changes", () => { }); describe("function reconstruction call coverage", () => { + it("preserves complete call diagnostics while passing equal call claims", () => { + const diagnostic = "Indirect calls remain unresolved."; + const leftWithDiagnostic = source("a", { + relationshipCoverage: { status: "complete", diagnostics: [diagnostic] }, + }); + const rightWithDiagnostic = source("b", { + relationshipCoverage: { status: "complete", diagnostics: [diagnostic] }, + }); + const observed = compareFunctions(leftWithDiagnostic, rightWithDiagnostic); + expect( + observed.dimensions.find(({ dimension }) => dimension === "calls"), + ).toMatchObject({ + status: "unchanged", + limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`], + }); + const evidence = comparison( + { + left_evidence_id: leftWithDiagnostic.evidence_id, + right_evidence_id: rightWithDiagnostic.evidence_id, + }, + {}, + [leftWithDiagnostic, rightWithDiagnostic], + {}, + observed, + ); + expect( + admittedVerification( + evidence, + [leftWithDiagnostic, rightWithDiagnostic], + "calls", + )(), + ).toMatchObject({ + status: "pass", + claims: { + items: [ + expect.objectContaining({ + status: "pass", + observed_status: "unchanged", + limitations: expect.arrayContaining([ + `Source ${leftWithDiagnostic.evidence_id}: ${diagnostic}`, + `Source ${rightWithDiagnostic.evidence_id}: ${diagnostic}`, + ]), + }), + ], + }, + }); + }); + it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => { const incomplete = source("7", { relationshipCoverage: { status: "unknown", reason: "Call graph unavailable.", + diagnostics: ["Indirect calls remain unresolved."], }, }); const incompleteSides = { @@ -352,6 +401,7 @@ describe("function reconstruction call coverage", () => { observed_status: "unchanged", limitations: expect.arrayContaining([ `Source ${incomplete.evidence_id}: Call graph unavailable.`, + `Source ${incomplete.evidence_id}: Indirect calls remain unresolved.`, ]), }), ], diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index ee7b73b9bb..1467f1a66b 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -19,7 +19,7 @@ import { isProcessEvidenceProvider, } from "./process/processEvidenceProvider.js"; import { functionDossierSchema } from "./hopperValues.js"; -import { functionCallCoverageLimitations } from "./functionCallCoverage.js"; +import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; import { deriveProcessComparisonStatus, PROCESS_COMPARISON_DIMENSIONS, @@ -64,7 +64,8 @@ const providers = { interface FunctionSourceFacts { readonly evidence_id: string; - readonly callCoverageLimitations: readonly string[]; + readonly callCoverageBlockers: readonly string[]; + readonly callCoverageDiagnostics: readonly string[]; } const acceptsPredicate = ( @@ -149,13 +150,14 @@ const evaluateClaim = ( const functionSourceFacts = readValidatedSourceFacts(claim, sources); const observation = comparisonObservation(claim, comparison); const observed = observation.status; - const limitations = [ + const callCoverage = sourceCallCoverage( + claim, + functionSourceFacts, + observation.hasIndependentFunctionChange === true, + ); + const blockers = [ ...authorityLimitations(claim, sources, sides), - ...sourceCallCoverageLimitations( - claim, - functionSourceFacts, - observation.hasIndependentFunctionChange === true, - ), + ...callCoverage.blockers, ]; const relatedUnknowns = reconstructionClaimUnknowns( heads, @@ -164,7 +166,7 @@ const evaluateClaim = ( claim.dimension, ); const relevantUnknowns = relatedUnknowns; - const status = classify(observed, limitations, relevantUnknowns); + const status = classify(observed, blockers, relevantUnknowns); const displayedUnknowns = relevantUnknowns; return reconstructionClaimResultSchema.parse({ claim_id: claim.claim_id, @@ -180,7 +182,8 @@ const evaluateClaim = ( limitations: uniqueSorted([ ...comparison.limitations, ...observation.limitations, - ...limitations, + ...blockers, + ...callCoverage.diagnostics, ...displayedUnknowns.map( ({ question }) => `Residual unknown: ${question}`, ), @@ -214,12 +217,14 @@ const readValidatedSourceFacts = ( parseProcessCapture(source.normalized_result); else if (claim.kind === "structural-function") { const dossier = functionDossierSchema.parse(source.normalized_result); + const callCoverage = assessFunctionCallCoverage( + source.relationship_coverage, + dossier, + ); functionSources.push({ evidence_id: source.evidence_id, - callCoverageLimitations: functionCallCoverageLimitations( - source.relationship_coverage, - dossier, - ), + callCoverageBlockers: callCoverage.blockers, + callCoverageDiagnostics: callCoverage.diagnostics, }); } else artifactInventoryResultSchema.parse(source.normalized_result); } @@ -366,20 +371,28 @@ const authorityLimitations = ( return limitations; }; -const sourceCallCoverageLimitations = ( +const sourceCallCoverage = ( claim: Claim, functionSources: readonly FunctionSourceFacts[], hasIndependentFunctionChange: boolean, -): string[] => { +): { blockers: string[]; diagnostics: string[] } => { if ( claim.kind !== "structural-function" || (claim.dimension !== "calls" && !(claim.dimension === "overall" && !hasIndependentFunctionChange)) ) - return []; - return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) => - callCoverageLimitations.map((reason) => `Source ${evidence_id}: ${reason}`), - ); + return { blockers: [], diagnostics: [] }; + return { + blockers: functionSources.flatMap(({ evidence_id, callCoverageBlockers }) => + callCoverageBlockers.map((reason) => `Source ${evidence_id}: ${reason}`), + ), + diagnostics: functionSources.flatMap( + ({ evidence_id, callCoverageDiagnostics }) => + callCoverageDiagnostics.map( + (reason) => `Source ${evidence_id}: ${reason}`, + ), + ), + }; }; const classify = (