fix(functions): preserve coverage diagnostics separately from blockers
This commit is contained in:
1 parent
92e17ccfce
commit
b9539e9c1c
7 files changed
+230
-53
No files matched your search
@@ -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."],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<FunctionDossier, "unresolved_calls">,
|
||||
): 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 ?? []),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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<Evidence["subject"]>;
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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.`,
|
||||
]),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user