diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 7ee678d44..5016b357e 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -224,6 +224,9 @@ const createAnalysisViewEvidence = ( : "derived", authority: parent.authority, limitations: result.limitations, + ...(result.kind === "native" && parent.relationship_coverage !== null + ? { relationshipCoverage: parent.relationship_coverage } + : {}), locations: [ ...(result.artifact.path.length > 0 ? [{ kind: "artifact-path" as const, path: result.artifact.path }] diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index f9402ad47..a538ef96f 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -17,12 +17,50 @@ import { analysisViewObjectDenseCapture, analysisViewJavaScriptEvidence, analysisViewJavaScriptAnalysisWithSource, + analysisViewNativeEvidence, } from "../../fixtures/analysisView.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "preserves native relationship coverage independently of exhausted CLI pages", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-native-coverage-cli-"); + const parent = analysisViewNativeEvidence({ + status: "partial", + limited_directions: ["callees"], + limitations: ["Callees: fixture read limit"], + diagnostics: ["Fixture relationship diagnostic"], + }); + const parentBefore = JSON.stringify(parent); + const request = join(root, "request.json"); + await writeFile( + request, + JSON.stringify({ + source: { kind: "inline", evidence: parent }, + view: { kind: "native", facet: "callees", offset: 0, limit: 1 }, + }), + ); + const response = await cli.run({ + arguments: ["inspect-analysis-view", request, "--json"], + }); + expect(response.exitCode).toBe(0); + const evidence = parseEvidence(response.json); + expect(evidence.relationship_coverage).toEqual( + parent.relationship_coverage, + ); + expect(evidence.normalized_result).toMatchObject({ + item: [], + coverage: { total: 0, exhausted: true }, + limitations: expect.arrayContaining(["Fixture dossier caveat"]), + }); + expect(evidence.evidence_links).toEqual([parent.evidence_id]); + expect(JSON.stringify(parent)).toBe(parentBefore); + }, +); + cliTest( "reads typed module references through the public CLI with canonical Evidence", async ({ cli }) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 3fbe191bd..5837365c9 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -37,11 +37,13 @@ import { analysisViewCaptureFixture, analysisViewCaptureEvidence, analysisViewObjectDenseCapture, + analysisViewNativeEvidence, } from "../../fixtures/analysisView.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { BINARY_LAYOUT_TEST_PROVIDER } from "../../fixtures/binaryDiagnostics/layout.js"; import { wasmArtifactFixture } from "../../fixtures/wasm/artifact.js"; import { evmInterfaceFixture } from "../../fixtures/evm/interface.js"; +import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; const connect = async ( binaryLayout?: BinaryLayoutService, @@ -70,6 +72,72 @@ const connect = async ( return { client, session }; }; +it("preserves native relationship coverage through inline and retained MCP views", async () => { + const { client, session } = await connect(); + const { ajv, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_analysis_view", + ); + const coverages: (RelationshipCoverage | undefined)[] = [ + { + status: "partial", + limited_directions: ["callees"], + limitations: ["Callees: fixture read limit"], + diagnostics: ["Fixture relationship diagnostic"], + }, + { status: "partial", limitations: ["Historical read limit"] }, + { + status: "unknown", + limited_directions: ["callees"], + reason: "Callees not measured", + diagnostics: ["Unknown coverage diagnostic"], + }, + { + status: "complete", + diagnostics: ["Only direct references are observed"], + }, + { status: "complete" }, + undefined, + ]; + for (const coverage of coverages) { + const parent = analysisViewNativeEvidence(coverage); + const parentBefore = JSON.stringify(parent); + const recorded = session.recordEvidence(parent); + if (!recorded.ok) throw recorded.error; + const outputs = []; + for (const source of [ + { kind: "inline", evidence: parent }, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + ]) { + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "native", facet: "callees", offset: 0, limit: 1 }, + }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.relationship_coverage).toEqual( + parent.relationship_coverage, + ); + expect(evidence.normalized_result).toMatchObject({ + item: [], + coverage: { total: 0, exhausted: true }, + limitations: expect.arrayContaining(["Fixture dossier caveat"]), + }); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + outputs.push(evidence); + } + expect(outputs[0]).toEqual(outputs[1]); + expect(JSON.stringify(parent)).toBe(parentBefore); + } +}); + it("advertises typed module references and preserves portable/retained Evidence parity", async () => { const { client, session } = await connect(); const parent = analysisViewJavaScriptEvidence( diff --git a/tests/fixtures/analysisView.ts b/tests/fixtures/analysisView.ts index fb140c08e..09dba60e1 100644 --- a/tests/fixtures/analysisView.ts +++ b/tests/fixtures/analysisView.ts @@ -13,6 +13,27 @@ import { import { createJavaScriptSemanticGraph } from "../../src/domain/javascript/javascriptSemanticGraph.js"; import type { BinaryLayout } from "../../src/domain/native/binaryLayout.js"; import { binaryLayoutSchema } from "../../src/domain/native/binaryLayout.js"; +import { ghidraFunctionDossier } from "../../src/domain/ghidraValues.fixture.js"; +import { functionDossierSchema } from "../../src/domain/hopperValues.js"; +import type { RelationshipCoverage } from "../../src/domain/relationshipCoverage.js"; + +/** Canonical portable Evidence; no native provider is started by this fixture. */ +export const analysisViewNativeEvidence = ( + relationshipCoverage?: RelationshipCoverage, +): Evidence => + createEvidence( + { path: "/fixtures/native.exe", format: "pe", sha256: "a".repeat(64) }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "analyze_function", + parameters: { address: "0x401000" }, + result: { + ...functionDossierSchema.parse(ghidraFunctionDossier()), + limitations: ["Fixture dossier caveat"], + }, + ...(relationshipCoverage === undefined ? {} : { relationshipCoverage }), + }, + ); const hex = (value: number): string => value === 0 ? "0x0" : `0x${value.toString(16)}`;