diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 845e8c8719..16543c9b7a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -355,7 +355,9 @@ page size or export retained Evidence if the view is too large. Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also support summary and inventory views. A summary reports the manifest's expected counts separately from available record counts, observed entry kinds, inventory -Evidence IDs and inspection coverage. An inventory view selects `nodes`, +Evidence IDs, the inventory producer's traversal `source_coverage` and +inspection coverage. Inspection coverage is `partial` when that traversal +coverage is unknown. An inventory view selects `nodes`, `occurrences`, `edges`, or `integrity_contradictions`, with `offset` and `limit` defaulting to 0 and 64. For occurrences, optional `logical_paths` matches exact recorded strings: omission selects all available records; `[]` selects none. diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 730d200116..0748894d14 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -245,17 +245,14 @@ export const projectArtifactInventoryView = ( inventory.occurrences.find((row) => row.logical_path === ".") ?.format_metadata ?? null; const rootMetadata = source.rootMetadata; - const includeCoverage = + // Producer traversal coverage is the owner's fact for every format; missing + // coverage would read as unknown, so every view carries it inline. + const sourceCoverage = { source_coverage: inventory.coverage }; + const includeHashCounts = view.kind === "inventory" || inventory.manifest.root_format === "godot-pck" || inventory.manifest.root_format === "pyinstaller"; - const sourceCoverage = includeCoverage - ? { source_coverage: inventory.coverage } - : {}; - const counts = - includeCoverage || view.kind === "summary" - ? inventoryCounts(source) - : undefined; + const counts = includeHashCounts ? inventoryCounts(source) : undefined; const limitations = uniqueSorted([ ...parent.limitations, ...inventory.limitations, @@ -272,13 +269,12 @@ export const projectArtifactInventoryView = ( limitations, unknowns: [ "Inventory does not establish application runtime behavior or complete format-specific analysis.", - ...(includeCoverage && inventory.coverage.status === "unknown" + ...(inventory.coverage.status === "unknown" ? [ "Producer traversal coverage is unknown; missing matches do not prove source absence.", ] : []), - ...(includeCoverage && - counts !== undefined && + ...(counts !== undefined && (counts.hashes.unavailable > 0 || counts.hashes["not-hashed"] > 0) ? [ "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence.", @@ -325,7 +321,7 @@ export const projectArtifactInventoryView = ( ? {} : { metadata: counts.kinds.metadata }), }, - ...(includeCoverage + ...(includeHashCounts ? { available_hash_status_counts: counts.hashes } : {}), source_complete: inventory.complete, diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index 95620c4330..d95fe9f169 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -427,10 +427,13 @@ const branch = ( }; }; +// A completed substep establishes only the coverage its producer examined, so +// unknown inventory traversal keeps the inspection partial, never complete. const inspectionCoverage = ( - _inventory: ArtifactInventoryResult, + inventory: ArtifactInventoryResult, omissions: Readonly>, ): ArtifactInspectionResult["coverage"]["status"] => { if (Object.values(omissions).some((count) => count > 0)) return "truncated"; + if (inventory.coverage.status !== "complete") return "partial"; return "complete-within-substeps"; }; diff --git a/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts b/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts new file mode 100644 index 0000000000..f2ab203a66 --- /dev/null +++ b/tests/boundary/mcp/artifactInspectionCoverageMcp.test.ts @@ -0,0 +1,130 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { createArtifactBinarySession } from "../../../src/composition/binary.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const TRAVERSAL_UNKNOWN = + "Producer traversal coverage is unknown; missing matches do not prove source absence."; + +const writeBundle = async (withFifo: boolean): Promise => { + const root = await createTestTempDirectory("rea-inspection-coverage-"); + const bundle = join(root, "Fixture.app"); + await mkdir(bundle); + await writeFile( + join(bundle, "Info.plist"), + "CFBundleExecutablemain.js", + ); + await writeFile(join(bundle, "main.js"), "export const value = 1;\n"); + if (withFifo) execFileSync("mkfifo", [join(bundle, "omitted.fifo")]); + return bundle; +}; + +const connect = async () => { + const session = await createArtifactBinarySession(process.env); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "inspection-coverage", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_analysis_view", + ); + expect(definition?.outputSchema).toBeDefined(); + const outputSchema: Record = definition!.outputSchema!; + const viewOutput = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(outputSchema); + return { client, server, viewOutput }; +}; + +describe.skipIf(process.platform === "win32")( + "inspect_artifact traversal coverage", + () => { + it.each([ + { + withFifo: true, + status: "partial", + sourceCoverage: { scope: "container-entries", status: "unknown" }, + }, + { + withFifo: false, + status: "complete-within-substeps", + sourceCoverage: { scope: "container-entries", status: "complete" }, + }, + ] as const)( + "derives inspection and summary coverage from inventory traversal (FIFO: $withFifo)", + async ({ withFifo, status, sourceCoverage }) => { + const path = await writeBundle(withFifo); + const { client, server, viewOutput } = await connect(); + try { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const parent = parseEvidence(inspected.structuredContent); + const inspection = artifactInspectionResultSchema.parse( + parent.normalized_result, + ); + const inventory = inspection.substeps[0]!.evidence.normalized_result; + expect(inventory.coverage).toEqual(sourceCoverage); + expect(inspection.coverage.status).toBe(status); + + const summaries = [ + await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "summary" }, + }, + }), + await client.callTool({ + name: "inspect_artifact", + arguments: { detail: "summary" }, + }), + ]; + for (const reply of summaries) { + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + viewOutput(reply.structuredContent), + JSON.stringify(viewOutput.errors), + ).toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + if (view.kind !== "summary" || !("source_complete" in view.summary)) + throw new Error("Expected an artifact inventory summary"); + expect(view.summary.source_complete).toBe(true); + expect(view.summary.source_coverage).toEqual(sourceCoverage); + expect(view.summary.inspection?.coverage.status).toBe(status); + expect(view.unknowns.includes(TRAVERSAL_UNKNOWN)).toBe(withFifo); + } + } finally { + await client.close(); + await server.close(); + } + }, + 30_000, + ); + }, +);