diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index 582c970f1..378aa2324 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -1,8 +1,8 @@ import { realpath } from "node:fs/promises"; import { - artifactInventoryResultSchema, - type ArtifactInventoryResult, + artifactInventoryOutputSchema, + type ArtifactInventoryOutput, } from "../../domain/artifactGraph.js"; import { abortIfNeeded } from "../ArtifactHash.js"; import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js"; @@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn export const inventoryArtifact = async ( inputPath: string, options: ArtifactInventoryOptions, -): Promise => { +): Promise => { const snapshot = await scanArtifactInventory(inputPath, options); - return artifactInventoryResultSchema.parse(snapshot); + return artifactInventoryOutputSchema.parse(snapshot); }; /** Scan an artifact once and retain the complete immutable graph for projection. */ diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 8a171b566..f195eac27 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -4,7 +4,11 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js"; import { ZipArtifactReader } from "../ZipArtifactReader.js"; -import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; +import type { + ArtifactInventoryCoverage, + ArtifactOccurrence, + IntegrityContradiction, +} from "../../domain/artifactGraph.js"; import { isZipPackageFormat, ZIP_NON_ENTRY_TAIL_LIMITATION, @@ -36,20 +40,42 @@ export const createReader = ( } }; -export const inventoryLimitations = ( +/** Classify source coverage and preserve its user-facing explanation once. */ +export const inventoryAssessment = ( format: ArtifactOccurrence["artifact_format"], reader: ArtifactReader | undefined, -): string[] => { - if (reader !== undefined) - return isZipPackageFormat(reader.format) - ? [ZIP_NON_ENTRY_TAIL_LIMITATION] - : []; - // dmg and pkg reach here only after classifyRootFormat matched their bytes. - if (format === "dmg" || format === "pkg") - return [ + integrityContradictions: readonly IntegrityContradiction[], + caseCollisionLimitation: string | undefined, +): { + readonly coverage_status: ArtifactInventoryCoverage; + readonly limitations: string[]; +} => { + let coverageStatus: ArtifactInventoryCoverage = "complete"; + const limitations: string[] = []; + if (reader !== undefined) { + if (isZipPackageFormat(reader.format)) + limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION); + } else if (format === "dmg" || format === "pkg") { + coverageStatus = "partial"; + limitations.push( `${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`, - ]; - if (format === "mach-o-universal" && process.platform !== "darwin") - return ["Universal Mach-O slices require the native macOS lipo adapter."]; - return ["Artifact has no child container entries."]; + ); + } else if (format === "mach-o-universal" && process.platform !== "darwin") { + coverageStatus = "partial"; + limitations.push( + "Universal Mach-O slices require the native macOS lipo adapter.", + ); + } else { + // The graph's modeled scope is the root itself for recognized leaf formats. + limitations.push("Artifact has no child container entries."); + } + if (caseCollisionLimitation !== undefined) { + coverageStatus = "partial"; + limitations.push(caseCollisionLimitation); + } + if (integrityContradictions.length > 0) + limitations.push( + `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`, + ); + return { coverage_status: coverageStatus, limitations }; }; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index ef4df6d71..d5234bdf6 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -40,7 +40,7 @@ import { hashStableRootArtifact, hashStableRootArtifactHandle, } from "./hashStableRootArtifact.js"; -import { createReader, inventoryLimitations } from "./reader.js"; +import { createReader, inventoryAssessment } from "./reader.js"; import { scanReader, type PendingIntegrityContradiction, @@ -268,6 +268,12 @@ const buildInventorySnapshot = async ( orderedOccurrences, orderedEdges, }); + const assessment = inventoryAssessment( + rootFormat, + reader, + integrityContradictions, + input.caseCollisionLimitation, + ); return { manifest, nodes: orderedNodes, @@ -275,12 +281,8 @@ const buildInventorySnapshot = async ( edges: orderedEdges, provenance: reader?.provenance() ?? [], integrity_contradictions: integrityContradictions, - limitations: buildLimitations( - rootFormat, - reader, - integrityContradictions, - input.caseCollisionLimitation, - ), + coverage_status: assessment.coverage_status, + limitations: assessment.limitations, }; }; @@ -393,18 +395,3 @@ const buildManifest = ({ occurrence_count: orderedOccurrences.length, edge_count: orderedEdges.length, }); - -const buildLimitations = ( - rootFormat: ArtifactOccurrence["artifact_format"], - reader: ArtifactReader | undefined, - integrityContradictions: readonly IntegrityContradiction[], - caseCollisionLimitation: string | undefined, -): string[] => [ - ...inventoryLimitations(rootFormat, reader), - ...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]), - ...(integrityContradictions.length === 0 - ? [] - : [ - `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`, - ]), -]; diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 323a9077f..e47b28a22 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -13,7 +13,6 @@ import { } from "./artifactInventoryEvidence.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJson } from "./comparisonSemantics.js"; -import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const comparisonStatusSchema = z.enum([ @@ -105,9 +104,9 @@ export const compareArtifacts = ( const leftComplete = left.inventory.complete; const rightComplete = right.inventory.complete; const leftCovered = - leftComplete && !hasCoverageLimitation(left.inventory.limitations); + leftComplete && left.inventory.coverage_status === "complete"; const rightCovered = - rightComplete && !hasCoverageLimitation(right.inventory.limitations); + rightComplete && right.inventory.coverage_status === "complete"; const limitations = [ ...left.inventory.limitations.map((item) => `Left: ${item}`), ...right.inventory.limitations.map((item) => `Right: ${item}`), @@ -433,13 +432,6 @@ const summarize = ( }; }; -const hasCoverageLimitation = (limitations: readonly string[]): boolean => - limitations.some( - (limitation) => - limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION && - !/integrity contradiction\(s\) were recorded/u.test(limitation), - ); - // Differing manifest root digests prove a root content change even when node // details are unavailable. Fold that fact into an existing root row so one // logical path keeps one classification. diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 4860fd209..6b2709baa 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -188,6 +188,12 @@ export const artifactGraphManifestSchema = z.object({ edge_count: z.number().int().min(0), }); +/** Whether the inventory producer covered its modeled child-entry scope. */ +export const artifactInventoryCoverageSchema = z.enum(["complete", "partial"]); +export type ArtifactInventoryCoverage = z.infer< + typeof artifactInventoryCoverageSchema +>; + /** Complete inventory observation containing the stable artifact graph. */ export const artifactInventoryResultSchema = z.object({ manifest: artifactGraphManifestSchema, @@ -197,8 +203,19 @@ export const artifactInventoryResultSchema = z.object({ provenance: z.array(artifactCommandSchema), integrity_contradictions: z.array(integrityContradictionSchema).default([]), limitations: z.array(z.string()), + /** Optional only for persisted inventory Evidence written before this field. */ + coverage_status: artifactInventoryCoverageSchema.optional(), }); +/** Current inventory producers always classify the coverage they examined. */ +export const artifactInventoryOutputSchema = + artifactInventoryResultSchema.extend({ + coverage_status: artifactInventoryCoverageSchema, + }); +export type ArtifactInventoryOutput = z.infer< + typeof artifactInventoryOutputSchema +>; + /** One safely materialized artifact beneath caller-approved output root. */ const extractedArtifactSchema = z.object({ artifact_id: artifactIdSchema, diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index a5bb99d16..ae0e3aa6e 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + artifactInventoryOutputSchema, artifactInventoryResultSchema, type ArtifactInventoryResult, } from "./artifactGraph.js"; @@ -114,7 +115,7 @@ export const artifactInspectionOutputSchema = .array( artifactInspectionResultSchema.shape.substeps.element.safeExtend({ evidence: evidenceSchema.safeExtend({ - normalized_result: artifactInventoryResultSchema, + normalized_result: artifactInventoryOutputSchema, }), }), ) diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index a44902ac3..6d2528ec1 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -1,5 +1,6 @@ import { artifactInventoryResultSchema, + type ArtifactInventoryCoverage, type ArtifactInventoryResult, type ArtifactNode, type ArtifactOccurrence, @@ -17,6 +18,7 @@ import { } from "./artifactIdentity.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { artifactInspectionResultSchema } from "./artifactInspection.js"; +import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js"; /** Complete or partially assembled inventory used by comparison workflows. */ export interface InventorySet { @@ -27,6 +29,7 @@ export interface InventorySet { readonly integrityContradictions: readonly IntegrityContradiction[]; readonly limitations: readonly string[]; readonly complete: boolean; + readonly coverage_status: ArtifactInventoryCoverage; } export interface ParsedInventorySet { @@ -123,6 +126,10 @@ const assembleInventoryResults = ( const first = pages[0]; if (first === undefined) throw new TypeError("Artifact inventory requires Evidence pages"); + const coverageStatuses = new Set(pages.map(resolveInventoryCoverageStatus)); + if (coverageStatuses.size !== 1) + throw new TypeError("Artifact inventory pages disagree on coverage status"); + const coverageStatus = [...coverageStatuses][0]!; const manifestJson = canonicalJson(first.manifest, "Artifact inventory"); const nodes = new Map(); const occurrences = new Map(); @@ -152,11 +159,12 @@ const assembleInventoryResults = ( for (const edge of page.edges) mergeExact(edges, edge.edge_id, edge, "edge"); } - const inventory = orderedInventory(first, pages, { - nodes, - occurrences, - edges, - }); + const inventory = orderedInventory( + first, + pages, + { nodes, occurrences, edges }, + coverageStatus, + ); if (inventory.complete) validateCompleteInventory(inventory); return inventory; }; @@ -172,6 +180,7 @@ const orderedInventory = ( ArtifactInventoryResult["edges"][number] >; }, + coverageStatus: ArtifactInventoryCoverage, ): InventorySet => { const nodes = [...values.nodes.values()].sort((left, right) => compareUnicodeCodePoints(left.artifact_id, right.artifact_id), @@ -195,9 +204,28 @@ const orderedInventory = ( nodes.length === first.manifest.node_count && occurrences.length === first.manifest.occurrence_count && edges.length === first.manifest.edge_count, + coverage_status: coverageStatus, }; }; +const resolveInventoryCoverageStatus = ( + inventory: ArtifactInventoryResult, +): ArtifactInventoryCoverage => { + if (inventory.coverage_status !== undefined) return inventory.coverage_status; + // Older retained Evidence has no typed status. Keep only its known + // informational notices complete; unknown limitation text fails closed. + return inventory.limitations.every(isKnownNonCoverageNotice) + ? "complete" + : "partial"; +}; + +const isKnownNonCoverageNotice = (limitation: string): boolean => + limitation === "Artifact has no child container entries." || + limitation === ZIP_NON_ENTRY_TAIL_LIMITATION || + /^\d+ integrity contradiction\(s\) were recorded; mismatched content is observed-untrusted\.$/u.test( + limitation, + ); + const validateCompleteInventory = (inventory: InventorySet): void => { const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id)); const occurrenceIds = new Set( diff --git a/src/domain/artifactInventorySnapshot.ts b/src/domain/artifactInventorySnapshot.ts index a219727a1..2157c03ed 100644 --- a/src/domain/artifactInventorySnapshot.ts +++ b/src/domain/artifactInventorySnapshot.ts @@ -1,4 +1,5 @@ import type { + ArtifactInventoryCoverage, ArtifactInventoryResult, ArtifactNode, IntegrityContradiction, @@ -6,6 +7,7 @@ import type { /** Immutable inventory produced by one complete artifact scan. */ export interface ArtifactInventorySnapshot { + readonly coverage_status: ArtifactInventoryCoverage; readonly manifest: ArtifactInventoryResult["manifest"]; readonly nodes: readonly ArtifactNode[]; readonly occurrences: ArtifactInventoryResult["occurrences"]; diff --git a/tests/acceptance/artifactComparisonPlist.test.ts b/tests/acceptance/artifactComparisonPlist.test.ts new file mode 100644 index 000000000..3781eb6c7 --- /dev/null +++ b/tests/acceptance/artifactComparisonPlist.test.ts @@ -0,0 +1,84 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; + +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const cli = fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)); +const plist = + 'labelhello'; + +it("compares identical complete plist inventories across CLI and MCP and creates no unknowns", async () => { + const root = await createTestTempDirectory("rea-plist-compare-acceptance-"); + const leftPath = join(root, "left.plist"); + const rightPath = join(root, "right.plist"); + await Promise.all([writeFile(leftPath, plist), writeFile(rightPath, plist)]); + + const cliResult = await execute(process.execPath, [ + cli, + "inspect-artifact", + leftPath, + "--json", + ]); + const cliInspection = artifactInspectionResultSchema.parse( + parseEvidence(JSON.parse(cliResult.stdout)).normalized_result, + ); + const cliInventory = cliInspection.substeps[0]?.evidence; + if (cliInventory === undefined) + throw new Error("CLI inspection did not retain inventory Evidence"); + expect(cliInventory.normalized_result).toMatchObject({ + coverage_status: "complete", + limitations: ["Artifact has no child container entries."], + }); + + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path: rightPath }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_artifact", {}); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const mcpInspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.structuredContent).normalized_result, + ); + const mcpInventory = mcpInspection.substeps[0]?.evidence; + if (mcpInventory === undefined) + throw new Error("MCP inspection did not retain inventory Evidence"); + expect(mcpInventory.normalized_result).toMatchObject({ + coverage_status: "complete", + limitations: ["Artifact has no child container entries."], + }); + + const compared = await call("compare_artifacts", { + left: cliInventory, + right: mcpInventory, + }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + const comparison = parseEvidence(compared.structuredContent); + expect(comparison.normalized_result).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + changes: [], + }); + + const behavior = await call("find_changed_behavior", { + comparisons: [comparison], + }); + expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true); + expect(behavior.structuredContent).toMatchObject({ + normalized_result: { + behavior_status: "unknown", + findings: { items: [] }, + }, + }); + const unknowns = await call("list_unknowns", { + domain: "artifact-comparison", + }); + expect(unknowns.isError, JSON.stringify(unknowns)).not.toBe(true); + expect(unknowns.structuredContent).toMatchObject({ result: { items: [] } }); +}); diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index 4633a3fb2..38bfde264 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -12,6 +12,7 @@ import { artifactComparisonResultSchema, compareArtifacts, } from "../../../src/domain/artifactComparison.js"; +import { parseArtifactInventoryEvidence } from "../../../src/domain/artifactInventoryEvidence.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; @@ -61,6 +62,184 @@ const changedArtifactComparison = async () => { }; }; +describe("leaf artifact comparison", () => { + it("accepts persisted complete leaf inventory Evidence without coverage_status", async () => { + const root = await createTestTempDirectory("rea-artifact-plist-compare-"); + const leftPath = join(root, "left.plist"); + const rightPath = join(root, "right.plist"); + const xml = + 'labelhello'; + await Promise.all([writeFile(leftPath, xml), writeFile(rightPath, xml)]); + const [left, right] = await Promise.all([ + observe(leftPath), + observe(rightPath), + ]); + // Persisted inventory Evidence predates coverage_status. The known leaf + // notice remains complete when normalized at the inventory boundary. + const parsed = artifactInventoryResultSchema.parse(left.normalized_result); + const { coverage_status: _coverageStatus, ...legacyInventory } = parsed; + const legacy = createEvidence( + { + path: left.subject!.local_path, + sha256: left.subject!.digest.sha256, + format: left.subject!.format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse(legacyInventory), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + expect(compareArtifacts(legacy, right)).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + changes: [], + }); + }); + + it("keeps content and executable changes visible for complete plist roots", async () => { + const root = await createTestTempDirectory("rea-artifact-plist-deltas-"); + const leftPath = join(root, "left.plist"); + const rightPath = join(root, "right.plist"); + const plist = (label: string) => + `label${label}`; + await Promise.all([ + writeFile(leftPath, plist("hello"), { mode: 0o644 }), + writeFile(rightPath, plist("world"), { mode: 0o644 }), + ]); + const [left, changed] = await Promise.all([ + observe(leftPath), + observe(rightPath), + ]); + expect(compareArtifacts(left, changed)).toMatchObject({ + status: "changed", + summary: { changed: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ + logical_path: ".", + classification: "changed", + dimensions: expect.arrayContaining(["content"]), + }), + ], + }); + + if (process.platform !== "win32") { + await writeFile(rightPath, plist("hello"), { mode: 0o644 }); + await chmod(rightPath, 0o755); + const executable = await observe(rightPath); + expect(compareArtifacts(left, executable)).toMatchObject({ + status: "changed", + summary: { changed: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ + logical_path: ".", + classification: "changed", + dimensions: expect.arrayContaining(["executable"]), + }), + ], + }); + } + }); +}); + +describe("artifact inventory coverage", () => { + it("keeps explicit and legacy unknown coverage fail-closed", async () => { + const root = await createTestTempDirectory("rea-artifact-coverage-"); + const path = join(root, "complete.plist"); + await writeFile( + path, + '', + ); + const complete = await observe(path); + const graph = artifactInventoryResultSchema.parse( + complete.normalized_result, + ); + const partial = createEvidence( + { + path: complete.subject!.local_path, + sha256: complete.subject!.digest.sha256, + format: complete.subject!.format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ ...graph, coverage_status: "partial" }), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + expect(compareArtifacts(partial, partial)).toMatchObject({ + status: "unknown", + summary: { unchanged: 0, unknown: 1 }, + }); + + const { coverage_status: _coverageStatus, ...legacyGraph } = graph; + const unknownLegacy = createEvidence( + { + path: complete.subject!.local_path, + sha256: complete.subject!.digest.sha256, + format: complete.subject!.format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...legacyGraph, + limitations: ["Producer omitted unexamined child entries."], + }), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + expect(compareArtifacts(unknownLegacy, unknownLegacy)).toMatchObject({ + status: "unknown", + summary: { unchanged: 0, unknown: 1 }, + }); + }); + + it("rejects inventory fragments that disagree on producer coverage", async () => { + const parent = await createTestTempDirectory("rea-artifact-page-coverage-"); + const path = join(parent, "fixture.app"); + await mkdir(path); + await writeFile(join(path, "main.js"), "main();"); + const source = await observe(path); + const fragments = fragmentInventoryEvidence(source, 2); + const firstFragment = fragments[0]; + const fragment = fragments[1]; + if (firstFragment === undefined || fragment === undefined) + throw new Error("Missing test fragment"); + const fragmentGraph = artifactInventoryResultSchema.parse( + fragment.normalized_result, + ); + const mixed = createEvidence( + { + path: fragment.subject!.local_path, + sha256: fragment.subject!.digest.sha256, + format: fragment.subject!.format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: fragment.parameters, + result: jsonValueSchema.parse({ + ...fragmentGraph, + coverage_status: "partial", + }), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + expect(() => + parseArtifactInventoryEvidence([firstFragment, mixed]), + ).toThrow("Artifact inventory pages disagree on coverage status"); + }); +}); + describe("artifact comparison", () => { it("classifies deterministic path changes and cites both inventories", async () => { const firstObservation = await changedArtifactComparison(); diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index 4cb3133d5..de78af9d0 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -26,6 +26,7 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../../src/domain/artifactGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { analysisViewResultSchema, type AnalysisViewResult, @@ -266,7 +267,7 @@ const assertHistoricalInventory = async ( { operation: "inventory_artifact", parameters: {}, - result: legacyInventory, + result: jsonValueSchema.parse(legacyInventory), }, ); const legacyInspection = createArtifactInspection(legacySource); @@ -490,7 +491,10 @@ it("keeps complete ZIP output and reads retained inventory views after the sourc { operation: "inventory_artifact", parameters: {}, - result: { ...graph, occurrences: graph.occurrences.slice(0, 1) }, + result: jsonValueSchema.parse({ + ...graph, + occurrences: graph.occurrences.slice(0, 1), + }), }, ); const partialReply = await client.callTool({