fix(call-path): preserve relationship coverage from analysis (#2001)

Forward observed relationship coverage into function Evidence and use its typed completeness when searching call paths. Preserve contextual limitations, legacy fallback, provenance, and unknown outcomes.

Avoid argument-spread exhaustion on large valid limitation lists. Cover the public MCP producer/consumer chain, metadata tampering, terminal and unreachable coverage, and large-list capacity.

Related to #1964; separate get_call_graph and trace_call_path work is not included. AI-assisted with Hermes (Nous Research).
This commit is contained in:
Dmitry Khanukov authored and GitHub committed 2026-10-12 08:55:19 +08:00
1 parent 976c2c083e
commit ed6af7eb8c
4 files changed
+320 -5

No files matched your search

@@ -46,6 +46,9 @@ export const executeFunctionAnalysisEvidence = async (
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
...(observation.relationshipCoverage === undefined
? {}
: { relationshipCoverage: observation.relationshipCoverage }),
...(observation.analysisProfile === undefined
? {}
: { analysisProfile: observation.analysisProfile }),
+110
View File
@@ -9,6 +9,7 @@ import {
import { createEvidence, type Evidence } from "./evidence.js";
import { functionDossierSchema } from "./hopperValues.js";
import { jsonValueSchema } from "./jsonValue.js";
import type { RelationshipCoverage } from "./relationshipCoverage.js";
const observe = (
address: string,
@@ -17,6 +18,7 @@ const observe = (
options: {
readonly evidenceLimitations?: readonly string[];
readonly dossierLimitations?: readonly string[];
readonly relationshipCoverage?: RelationshipCoverage;
} = {},
): Evidence => {
const value = functionDossierSchema.parse({
@@ -69,6 +71,9 @@ const observe = (
...(options.evidenceLimitations === undefined
? {}
: { limitations: options.evidenceLimitations }),
...(options.relationshipCoverage === undefined
? {}
: { relationshipCoverage: options.relationshipCoverage }),
},
);
};
@@ -171,6 +176,21 @@ describe("call path reconstruction", () => {
});
describe("call path topology and capacity", () => {
it("retains every distinct generic limitation beyond the push argument limit", () => {
const limitations = Array.from(
{ length: 1_000_000 },
(_, index) => `context-${index.toString().padStart(7, "0")}`,
);
const result = run([
observe("0x1000", [], "rea-workflow", {
evidenceLimitations: [...limitations].reverse(),
}),
]);
expect(result.status).toBe("unknown");
expect(result.search_scope.exhaustive).toBe(false);
expect(result.limitations).toEqual(limitations);
}, 30_000);
it("retains every shortest path through merging branches and cycles", () => {
const result = run([
observe("0x1000", ["0x3000", "0x2000", "0x1000"]),
@@ -314,6 +334,96 @@ describe("call path topology and capacity", () => {
});
});
describe("explicit call relationship coverage", () => {
it.each([150_000, 1_000_000])(
"accumulates %i partial reasons without argument-stack exhaustion",
(length) => {
const result = run([
observe("0x1000", [], "rea-workflow", {
relationshipCoverage: {
status: "partial",
limitations: Array<string>(length).fill("omitted"),
},
evidenceLimitations: [],
dossierLimitations: [],
}),
]);
expect(result).toMatchObject({
status: "unknown",
search_scope: { exhaustive: false },
limitations: ["omitted"],
});
},
);
it("retains contextual limits on a found path without reducing complete coverage", () => {
const limitation = "Provider results reflect the current database state.";
const result = run([
observe("0x1000", ["0x4000"], "rea-workflow", {
relationshipCoverage: { status: "complete" },
evidenceLimitations: [limitation],
}),
]);
expect(result).toMatchObject({
status: "found",
shortest_hops: 1,
search_scope: { exhaustive: true },
});
expect(result.limitations).toContain(limitation);
});
it("retains a found path while its expanded relationship coverage remains partial", () => {
const limitation = "Other callees were omitted by the provider cap.";
const result = run([
observe("0x1000", ["0x4000"], "rea-workflow", {
relationshipCoverage: { status: "partial", limitations: [limitation] },
}),
]);
expect(result).toMatchObject({
status: "found",
shortest_hops: 1,
search_scope: { exhaustive: false },
});
expect(result.limitations).toContain(limitation);
});
it("retains an unknown goal coverage reason without expanding the terminal goal", () => {
const reason = "The goal's outgoing relationship coverage is unknown.";
const result = run([
observe("0x1000", ["0x4000"], "rea-workflow", {
relationshipCoverage: { status: "complete" },
}),
observe("0x4000", ["0x5000"], "rea-workflow", {
relationshipCoverage: { status: "unknown", reason },
}),
]);
expect(result).toMatchObject({
status: "found",
search_scope: { exhaustive: true },
explored: { nodes: 2 },
});
expect(result.limitations).toEqual([reason]);
});
it("ignores incomplete relationship coverage outside the reachable search closure", () => {
const reason = "An unrelated function's callees were not observed.";
const result = run([
observe("0x1000", [], "rea-workflow", {
relationshipCoverage: { status: "complete" },
}),
observe("0x2000", ["0x5000"], "rea-workflow", {
relationshipCoverage: { status: "unknown", reason },
}),
]);
expect(result).toMatchObject({
status: "not_found",
search_scope: { exhaustive: true },
explored: { nodes: 1 },
});
expect(result.limitations).toEqual([]);
});
});
describe("call path evidence admission", () => {
it("normalizes hex addresses and rejects duplicates and mixed providers", () => {
expect(
+17 -2
View File
@@ -236,12 +236,27 @@ const inspectSearch = ({
const depth = reached.get(node) ?? 0;
const snapshot = snapshots.get(node);
if (snapshot !== undefined) {
limitations.push(...snapshot.limitations);
const coverage = snapshot.evidence.relationship_coverage;
for (const limitation of snapshot.limitations)
limitations.push(limitation);
if (coverage?.status === "partial")
for (const limitation of coverage.limitations)
limitations.push(limitation);
else if (coverage?.status === "unknown")
limitations.push(coverage.reason);
// The requested goal is a terminal for this search. Its outgoing edges
// cannot add a path to the goal, so its dossier limits are contextual
// but do not make the searched start-to-goal closure incomplete.
if (node === goal) continue;
if (snapshot.limitations.length > 0) incompleteExpandedCoverage = true;
// Explicit, Evidence-bound relationship coverage owns completeness;
// generic provider/dossier limitations remain contextual. Legacy Evidence
// without that metadata retains its conservative limitation-based rule.
if (
coverage === null
? snapshot.limitations.length > 0
: coverage.status !== "complete"
)
incompleteExpandedCoverage = true;
} else if (node === goal) {
continue;
}
@@ -3,7 +3,14 @@ import { ok as resultOk } from "../../../src/domain/result.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "../../../src/application/AnalysisProvider.js";
import type { BinarySession } from "../../../src/application/binary/BinarySession.js";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
@@ -20,6 +27,7 @@ import {
} from "../../../src/domain/jsonValue.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
it("aggregates comparison Evidence and records an approved runtime gap", async () => {
const { session, server, client } = await connected();
@@ -149,6 +157,183 @@ const dossierWithCallees = (addresses: readonly string[]) => {
);
};
it.each([
{
label: "complete",
coverage: { status: "complete" } as RelationshipCoverage,
providerLimitations: ["The provider version identity is unavailable."],
dossierLimitations: ["The dossier has no native value-flow observation."],
status: "not_found",
exhaustive: true,
},
{
label: "partial",
coverage: {
status: "partial",
limitations: ["Callee output was capped."],
} as RelationshipCoverage,
providerLimitations: [],
dossierLimitations: [],
status: "unknown",
exhaustive: false,
},
{
label: "unknown",
coverage: {
status: "unknown",
reason: "Callee coverage was not established.",
} as RelationshipCoverage,
providerLimitations: [],
dossierLimitations: [],
status: "unknown",
exhaustive: false,
},
{
label: "legacy limited",
coverage: undefined,
providerLimitations: [
"Legacy relationship completeness was not established.",
],
dossierLimitations: [],
status: "unknown",
exhaustive: false,
},
{
label: "legacy unlimited",
coverage: undefined,
providerLimitations: [],
dossierLimitations: [],
status: "not_found",
exhaustive: true,
},
])(
"preserves $label coverage through public analyze_function then build_call_path",
async (scenario) => {
const base = FUNCTION_COMPARISON_EXAMPLE.left;
if (base.subject === null) throw new Error("missing fixture subject");
const provider = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
const rawResult = { fixture_reply: scenario.label };
const execution = createAnalysisExecution(
{
...jsonObjectSchema.parse(base.normalized_result),
callees: [],
limitations: scenario.dossierLimitations,
},
provider,
{
rawResult,
limitations: scenario.providerLimitations,
locations: base.locations,
...(scenario.coverage === undefined
? {}
: { relationshipCoverage: scenario.coverage }),
},
);
const { session, server, client } = await connected({
execute: async (operation, parameters) => {
if (operation === "health") return observed(null);
if (operation !== "analyze_function")
throw new Error(`Unexpected operation: ${operation}`);
expect(parameters.procedure).toBe("0x1000");
return resultOk(execution);
},
});
try {
const [path] = await createBinarySessionTargets();
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const analyzed = await client.callTool({
name: "analyze_function",
arguments: { procedure: "0x1000" },
});
expect(analyzed.isError, JSON.stringify(analyzed)).not.toBe(true);
const dossier = inlineEvidence(analyzed.structuredContent);
expect(dossier).toMatchObject({
operation: "analyze_function",
parameters: { procedure: "0x1000" },
provider,
subject: {
local_path: path,
digest: { sha256: session.activeTarget()?.sha256 },
format: session.activeTarget()?.format,
},
raw_result: rawResult,
limitations: scenario.providerLimitations,
locations: base.locations,
normalized_result: {
callees: [],
limitations: scenario.dossierLimitations,
},
});
const response = await client.callTool({
name: "build_call_path",
arguments: {
functions: [dossier],
start: { address: "0x1000" },
goal: { address: "0x4000" },
},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const evidence = inlineEvidence(response.structuredContent);
const coverage = scenario.coverage;
const reasons =
coverage?.status === "partial"
? coverage.limitations
: coverage?.status === "unknown"
? [coverage.reason]
: [];
expect(evidence.normalized_result).toMatchObject({
status: scenario.status,
shortest_hops: null,
paths: [],
search_scope: { exhaustive: scenario.exhaustive },
limitations: expect.arrayContaining([
...scenario.providerLimitations,
...scenario.dossierLimitations,
...reasons,
]),
});
expect(evidence.evidence_links).toEqual([dossier.evidence_id]);
expect(dossier.relationship_coverage).toEqual(coverage ?? null);
if (coverage?.status === "partial") {
const tampered = {
...dossier,
relationship_coverage: { status: "complete" },
};
expect(() => parseEvidence(tampered)).toThrow();
const rejected = await client.callTool({
name: "build_call_path",
arguments: {
functions: [tampered],
start: { address: "0x1000" },
goal: { address: "0x4000" },
},
});
expect(rejected.isError, JSON.stringify(rejected)).toBe(true);
}
if (scenario.exhaustive)
expect(session.listUnknowns({ domain: "call-path" })).toEqual([]);
else
expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([
{
question:
"Can the requested call path be established from complete analysis?",
supporting_evidence_ids: [evidence.evidence_id],
},
]);
} finally {
await close(session, server, client);
}
},
);
it("records a safe unresolved call-path question", async () => {
const { session, server, client } = await connected();
const dossier = dossierWithCallees(["0x2000"]);
@@ -326,10 +511,12 @@ it("cannot omit a session-owned active unknown from reconstruction input", async
}
});
const connected = async () => {
const connected = async (analysis?: AnalysisOperationPort) => {
const session = createTestBinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
execute:
analysis?.execute.bind(analysis) ??
(() => Promise.resolve(observed(null))),
close: () => Promise.resolve(resultOk(null)),
}));
const server = createServer({ kind: "session", session });