diff --git a/src/application/artifacts/ArtifactComparisonEvidence.test.ts b/src/application/artifacts/ArtifactComparisonEvidence.test.ts new file mode 100644 index 0000000000..b6f540e800 --- /dev/null +++ b/src/application/artifacts/ArtifactComparisonEvidence.test.ts @@ -0,0 +1,166 @@ +import { expect, it } from "vitest"; + +import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js"; +import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../domain/evidence.js"; +import { + EvidenceIntegrityError, + EvidenceReferenceError, +} from "../../domain/evidenceErrors.js"; +import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js"; + +const artifactSummary = () => { + const parent = ARTIFACT_COMPARISON_EXAMPLE.left; + const summary = inspectAnalysisView( + { + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }, + (evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined), + ); + if (!summary.ok) throw summary.error; + return { parent, summary: summary.value }; +}; + +const recreateSummary = ( + summary: Evidence, + normalizedResult: Evidence["normalized_result"], + parameters: Evidence["parameters"] = summary.parameters, +): Evidence => { + if (summary.subject === null) + throw new Error("artifact summary fixture must identify its subject"); + return createEvidence( + { + path: summary.subject.local_path, + sha256: summary.subject.digest.sha256, + format: summary.subject.format, + ...(summary.subject.architecture === null + ? {} + : { architecture: summary.subject.architecture }), + }, + summary.provider, + { + predicateType: summary.predicate_type, + operation: summary.operation, + parameters, + result: normalizedResult, + ...(summary.analysis_profile === null + ? {} + : { analysisProfile: summary.analysis_profile }), + rawResult: summary.raw_result, + confidence: summary.confidence, + authority: summary.authority, + environment: summary.environment, + limitations: summary.limitations, + ...(summary.relationship_coverage === null + ? {} + : { relationshipCoverage: summary.relationship_coverage }), + locations: summary.locations, + evidenceLinks: summary.evidence_links, + }, + ); +}; + +it("reports a missing retained parent instead of treating summary facts as inventory", () => { + const { summary } = artifactSummary(); + const resolved = resolveArtifactComparisonEvidence(summary, undefined); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toBeInstanceOf(EvidenceReferenceError); + expect(resolved.error).toMatchObject({ reason: "missing" }); + } +}); + +it("uses retained Evidence resolution to reject a mismatched parent identity", () => { + const { summary } = artifactSummary(); + const otherParent = ARTIFACT_COMPARISON_EXAMPLE.right; + const resolved = resolveArtifactComparisonEvidence( + summary, + () => otherParent, + ); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toBeInstanceOf(EvidenceReferenceError); + expect(resolved.error).toMatchObject({ reason: "identity_mismatch" }); + } +}); + +it("rejects a summary that declares the wrong parent operation", () => { + const { parent, summary } = artifactSummary(); + const summaryResult = artifactInventorySummaryViewResultSchema.parse( + summary.normalized_result, + ); + const normalizedResult = { + ...summaryResult, + parent_operation: "inspect_artifact", + }; + const forged = recreateSummary(summary, normalizedResult); + const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) => + evidenceId === parent.evidence_id ? parent : undefined, + ); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); +}); + +it("rejects summary parameters that do not identify the projected parent", () => { + const { parent, summary } = artifactSummary(); + const forged = recreateSummary(summary, summary.normalized_result, { + ...summary.parameters, + parent_evidence_id: `ev_${"f".repeat(64)}`, + }); + const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) => + evidenceId === parent.evidence_id ? parent : undefined, + ); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); +}); + +it("rejects summary facts that do not reproject from the complete parent", () => { + const { parent, summary } = artifactSummary(); + const summaryResult = artifactInventorySummaryViewResultSchema.parse( + summary.normalized_result, + ); + const normalizedResult = { + ...summaryResult, + summary: { + ...summaryResult.summary, + available_counts: { + ...summaryResult.summary.available_counts, + nodes: summaryResult.summary.available_counts.nodes + 1, + }, + }, + }; + const forged = recreateSummary(summary, normalizedResult); + const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) => + evidenceId === parent.evidence_id ? parent : undefined, + ); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); +}); + +it("accepts display-only subject changes excluded from Evidence identity", () => { + const { parent, summary } = artifactSummary(); + if (summary.subject === null) + throw new Error("artifact summary fixture must identify its subject"); + const displayOnly = parseEvidence({ + ...summary, + subject: { + ...summary.subject, + name: "renamed.app", + local_path: "/display-only/renamed.app", + }, + }); + const resolved = resolveArtifactComparisonEvidence( + displayOnly, + (evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined), + ); + expect(resolved).toEqual({ ok: true, value: parent }); +}); diff --git a/src/application/artifacts/ArtifactComparisonEvidence.ts b/src/application/artifacts/ArtifactComparisonEvidence.ts new file mode 100644 index 0000000000..3a2bb33e63 --- /dev/null +++ b/src/application/artifacts/ArtifactComparisonEvidence.ts @@ -0,0 +1,61 @@ +import type { EvidenceLookup } from "../EvidenceInputResolver.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js"; +import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; +import { + EvidenceIntegrityError, + EvidenceReferenceError, +} from "../../domain/evidenceErrors.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** Resolve only an artifact summary's exact retained owner before comparison. */ +export const resolveArtifactComparisonEvidence = ( + evidence: Evidence, + evidenceById: EvidenceLookup | undefined, +): Result => { + if (evidence.operation !== "inspect_analysis_view") return ok(evidence); + + const summary = artifactInventorySummaryViewResultSchema.safeParse( + evidence.normalized_result, + ); + if (!summary.success) + return err( + new AnalysisInputError("compare_artifacts", undefined, [ + { + path: ["evidence"], + reason: "invalid_value", + message: + "Artifact comparison accepts only an artifact inventory summary from inspect_analysis_view.", + }, + ]), + ); + + const parentId = summary.data.parent_evidence_id; + const parent = evidenceById?.(parentId); + if (parent === undefined) + return err( + new EvidenceReferenceError( + parentId, + "missing", + "retained complete artifact inventory Evidence", + null, + ), + ); + const reprojected = inspectAnalysisViewValidated( + { + source: { kind: "retained-evidence", evidence_id: parentId }, + view: { kind: "summary" }, + }, + (evidenceId) => (evidenceId === parentId ? parent : undefined), + ); + if (!reprojected.ok) return reprojected; + if (reprojected.value.evidence_id !== evidence.evidence_id) + return err( + new EvidenceIntegrityError( + "Artifact summary does not match its retained parent inventory.", + ), + ); + return ok(parent); +}; diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index 378aa2324f..582c970f1f 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -1,8 +1,8 @@ import { realpath } from "node:fs/promises"; import { - artifactInventoryOutputSchema, - type ArtifactInventoryOutput, + artifactInventoryResultSchema, + type ArtifactInventoryResult, } 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 artifactInventoryOutputSchema.parse(snapshot); + return artifactInventoryResultSchema.parse(snapshot); }; /** Scan an artifact once and retain the complete immutable graph for projection. */ diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index a6cdceeb28..93d12b4fe2 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -289,7 +289,6 @@ const buildInventorySnapshot = async ( provenance: reader?.provenance() ?? [], integrity_contradictions: integrityContradictions, coverage, - coverage_status: coverage.status === "complete" ? "complete" : "partial", limitations: buildLimitations( [...traversal.limitations, ...edgeResult.limitations], integrityContradictions, diff --git a/src/contracts/artifactComparisonExample.ts b/src/contracts/artifactComparisonExample.ts index b0f4ac7678..1cf1ab87d7 100644 --- a/src/contracts/artifactComparisonExample.ts +++ b/src/contracts/artifactComparisonExample.ts @@ -1,12 +1,22 @@ -import { digestCanonicalValue } from "../domain/canonicalDigest.js"; +import { + artifactGraphDigest, + artifactIdForContent, + artifactManifestId, + occurrenceIdForLocation, +} from "../domain/artifactIdentity.js"; +import type { + ArtifactEdge, + ArtifactNode, + ArtifactOccurrence, + IntegrityContradiction, +} from "../domain/artifactGraph.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; const inventory = (digit: string) => { const sha = digit.repeat(64); - const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact example")}`; - const occurrenceId = `occ_${digestCanonicalValue({ root: artifactId }, "Artifact example")}`; - const nodes = [ + const artifactId = artifactIdForContent(sha); + const nodes: ArtifactNode[] = [ { artifact_id: artifactId, format: "file", @@ -18,9 +28,13 @@ const inventory = (digit: string) => { limitations: [], }, ]; - const occurrences = [ + const occurrences: ArtifactOccurrence[] = [ { - occurrence_id: occurrenceId, + occurrence_id: occurrenceIdForLocation({ + rootArtifactId: artifactId, + logicalPath: ".", + entryKind: "file", + }), artifact_id: artifactId, parent_occurrence_id: null, logical_path: ".", @@ -36,36 +50,30 @@ const inventory = (digit: string) => { limitations: [], }, ]; - const graphSha256 = digestCanonicalValue( - { - nodes, - occurrences, - edges: [], - integrity_contradictions: [], - }, - "Artifact example", - ); + const edges: ArtifactEdge[] = []; + const integrityContradictions: IntegrityContradiction[] = []; + const graphSha256 = artifactGraphDigest({ + nodes, + occurrences, + edges, + contradictions: integrityContradictions, + }); return jsonValueSchema.parse({ manifest: { - manifest_id: `agm_${digestCanonicalValue( - { - root_artifact_id: artifactId, - graph_sha256: graphSha256, - }, - "Artifact example", - )}`, + manifest_id: artifactManifestId(artifactId, graphSha256), root_artifact_id: artifactId, root_sha256: sha, root_format: "file", graph_sha256: graphSha256, node_count: nodes.length, occurrence_count: occurrences.length, - edge_count: 0, + edge_count: edges.length, }, nodes, occurrences, - edges: [], + edges, provenance: [], + coverage: { scope: "root", status: "complete" }, limitations: [], }); }; diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 73fe37e643..5b510307a2 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -104,7 +104,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "compare_artifacts", - "Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right. Every delta cites both inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.", + "Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right, or pass its inspect_analysis_view summary while the complete parent Evidence remains retained in this session. Summary facts are not treated as a complete inventory; comparison resolves the exact retained parent. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.", artifactComparisonInputSchema, ), session( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 4c502e96f0..6709f7445d 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -45,7 +45,7 @@ import { listArchitecturesSchema, } from "../domain/native/nativeInspection.js"; import { artifactExtractionResultSchema } from "../domain/artifactGraph.js"; -import { artifactInspectionOutputSchema } from "../domain/artifactInspection.js"; +import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js"; import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js"; import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js"; @@ -404,7 +404,7 @@ export const nativeOutputSchemas: Readonly> = { export const artifactOutputSchemas: Readonly> = { inspect_artifact: evidenceResultOf( z.union([ - artifactInspectionOutputSchema, + artifactInspectionResultSchema, artifactInventorySummaryViewResultSchema, ]), ), diff --git a/src/domain/artifactGraph.test.ts b/src/domain/artifactGraph.test.ts index 5eee3aa93d..2840223e5c 100644 --- a/src/domain/artifactGraph.test.ts +++ b/src/domain/artifactGraph.test.ts @@ -61,6 +61,7 @@ describe("artifact graph path and provenance fields", () => { effects: ["read"], }, ], + coverage: { scope: "container-entries", status: "complete" }, integrity_contradictions: [], limitations: [], }); @@ -69,4 +70,35 @@ describe("artifact graph path and provenance fields", () => { expect(parsed.provenance[0]?.arguments).toHaveLength(1_025); expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]); }); + + it("requires current scoped coverage and rejects the obsolete status alias", () => { + const inventory = { + manifest: { + manifest_id: `agm_${"b".repeat(64)}`, + root_artifact_id: `art_${"a".repeat(64)}`, + root_sha256: "c".repeat(64), + root_format: "plist", + graph_sha256: "d".repeat(64), + node_count: 1, + occurrence_count: 1, + edge_count: 0, + }, + nodes: [], + occurrences: [], + edges: [], + provenance: [], + integrity_contradictions: [], + limitations: [], + }; + + expect(artifactInventoryResultSchema.safeParse(inventory).success).toBe( + false, + ); + expect( + artifactInventoryResultSchema.safeParse({ + ...inventory, + coverage_status: "complete", + }).success, + ).toBe(false); + }); }); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 0d12f3812f..ef67af6311 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -196,32 +196,18 @@ export type ArtifactInventoryCoverage = z.infer< typeof artifactInventoryCoverageSchema >; -/** Complete inventory observation containing the stable artifact graph. */ -export const artifactInventoryResultSchema = z.object({ +/** Current inventory observation containing the stable artifact graph. */ +export const artifactInventoryResultSchema = z.strictObject({ manifest: artifactGraphManifestSchema, nodes: z.array(artifactNodeSchema), occurrences: z.array(artifactOccurrenceSchema), edges: z.array(artifactEdgeSchema), provenance: z.array(artifactCommandSchema), integrity_contradictions: z.array(integrityContradictionSchema).default([]), - // Omitted by older retained Evidence; the inventory parser owns its compatibility policy. - coverage: artifactInventoryCoverageSchema.optional(), + coverage: artifactInventoryCoverageSchema, limitations: z.array(z.string()), - /** Optional only for persisted inventory Evidence written before this field. */ - coverage_status: z.enum(["complete", "partial"]).optional(), }); -/** Current producers always declare coverage; historical Evidence inputs may omit it. */ -export const artifactInventoryOutputSchema = - artifactInventoryResultSchema.safeExtend({ - coverage: artifactInventoryCoverageSchema, - coverage_status: - artifactInventoryResultSchema.shape.coverage_status.unwrap(), - }); -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 cb9c5ba48d..20bb4f540f 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { - artifactInventoryOutputSchema, artifactInventoryResultSchema, type ArtifactInventoryResult, } from "./artifactGraph.js"; @@ -75,7 +74,10 @@ export const artifactInspectionResultSchema = z.strictObject({ operation: z.literal("inventory_artifact"), status: z.literal("completed"), evidence_id: evidenceIdSchema, - evidence: evidenceSchema, + evidence: evidenceSchema.safeExtend({ + operation: z.literal("inventory_artifact"), + normalized_result: artifactInventoryResultSchema, + }), }), ) .min(1), @@ -86,7 +88,7 @@ export const artifactInspectionResultSchema = z.strictObject({ next_probes: z.array(nextProbeSchema), coverage: z.strictObject({ status: z.enum(["complete-within-substeps", "partial", "truncated"]), - substeps_completed: z.literal(1), + substeps_completed: z.number().int().min(1), observations_retained: z.number().int().min(0), observations_omitted: z.number().int().min(0), hypotheses_retained: z.number().int().min(0), @@ -104,24 +106,6 @@ export type ArtifactInspectionResult = z.infer< typeof artifactInspectionResultSchema >; -/** - * The current producer guarantees a concrete inventory payload. Specialize - * its output without narrowing the historical inspection/Evidence input - * parser or applying inventory defaults inside an authenticated imported record. - */ -export const artifactInspectionOutputSchema = - artifactInspectionResultSchema.safeExtend({ - substeps: z - .array( - artifactInspectionResultSchema.shape.substeps.element.safeExtend({ - evidence: evidenceSchema.safeExtend({ - normalized_result: artifactInventoryOutputSchema, - }), - }), - ) - .min(1), - }); - /** Project one successful inventory Evidence record into an inspection report. */ export const createArtifactInspection = ( inventoryInput: unknown, diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index afaabac49d..a4e071a9b5 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -18,10 +18,6 @@ import { } from "./artifactIdentity.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { artifactInspectionResultSchema } from "./artifactInspection.js"; -import { - isZipPackageFormat, - isZipInventoryScopeLimitation, -} from "./zipPackageFormat.js"; /** Complete or partially assembled inventory used by comparison workflows. */ export interface InventorySet { @@ -54,34 +50,42 @@ export interface ParsedInventorySet { export const parseArtifactInventoryEvidence = ( input: unknown, ): ParsedInventorySet => { - const pages = (Array.isArray(input) ? input : [input]).map((value) => { - const evidence = parseEvidence(value); - const inventoryEvidence = - evidence.operation === "inventory_artifact" - ? evidence - : evidence.operation === "inspect_artifact" - ? artifactInspectionResultSchema.parse(evidence.normalized_result) - .substeps[0]?.evidence - : undefined; + const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence); + const pages = evidence.flatMap((carrier) => { + let inventoryEvidence: readonly Evidence[] | undefined; + if (carrier.operation === "inventory_artifact") { + inventoryEvidence = [carrier]; + } else if (carrier.operation === "inspect_artifact") { + const inspection = artifactInspectionResultSchema.parse( + carrier.normalized_result, + ); + if (inspection.coverage.substeps_completed !== inspection.substeps.length) + throw new TypeError( + "Artifact inspection substep count does not match its coverage declaration", + ); + inventoryEvidence = inspection.substeps.map((substep) => + parseEvidence(substep.evidence), + ); + } if (inventoryEvidence === undefined) throw new TypeError( "Artifact comparison requires inspect_artifact Evidence", ); - const inventory = artifactInventoryResultSchema.parse( - inventoryEvidence.normalized_result, - ); - validateArtifactInventorySubject( - inventoryEvidence.subject, - inventory.manifest, - ); - validateInventoryPage(inventory); - return { evidence, inventory }; + return inventoryEvidence.map((pageEvidence) => { + const inventory = artifactInventoryResultSchema.parse( + pageEvidence.normalized_result, + ); + validateArtifactInventorySubject( + pageEvidence.subject, + inventory.manifest, + ); + validateInventoryPage(inventory); + return inventory; + }); }); return { - evidence: pages.map(({ evidence }) => evidence), - inventory: assembleInventoryResults( - pages.map(({ inventory }) => inventory), - ), + evidence, + inventory: assembleInventoryResults(pages), }; }; @@ -137,10 +141,7 @@ const assembleInventoryResults = ( throw new TypeError("Artifact inventory requires Evidence pages"); const manifestJson = canonicalJson(first.manifest, "Artifact inventory"); const coverageJson = canonicalJson( - { - coverage: first.coverage ?? null, - coverage_status: first.coverage_status ?? null, - }, + first.coverage, "Artifact inventory coverage", ); const nodes = new Map(); @@ -150,13 +151,8 @@ const assembleInventoryResults = ( if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson) throw new TypeError("Artifact inventory pages do not share one manifest"); if ( - canonicalJson( - { - coverage: page.coverage ?? null, - coverage_status: page.coverage_status ?? null, - }, - "Artifact inventory coverage", - ) !== coverageJson + canonicalJson(page.coverage, "Artifact inventory coverage") !== + coverageJson ) throw new TypeError( "Artifact inventory pages do not share one coverage declaration", @@ -221,7 +217,7 @@ const orderedInventory = ( edges, integrityContradictions: first.integrity_contradictions, limitations, - coverage: resolveInventoryCoverage({ ...first, limitations }), + coverage: first.coverage, complete: nodes.length === first.manifest.node_count && occurrences.length === first.manifest.occurrence_count && @@ -267,61 +263,6 @@ const orderOccurrences = ( }); }; -// Normalize both retained coverage formats at this owning boundary. -const resolveInventoryCoverage = ( - inventory: ArtifactInventoryResult, -): ArtifactInventoryCoverage => { - const legacy = legacyInventoryCoverage( - inventory.manifest.root_format, - inventory.limitations, - inventory.integrity_contradictions.length, - ); - if (inventory.coverage !== undefined) { - if ( - inventory.coverage_status !== undefined && - (inventory.coverage.status === "complete") !== - (inventory.coverage_status === "complete") - ) - throw new TypeError( - "Artifact inventory has conflicting coverage declarations", - ); - return inventory.coverage; - } - return inventory.coverage_status === undefined - ? legacy - : { - ...legacy, - status: - inventory.coverage_status === "complete" ? "complete" : "unknown", - }; -}; - -// Retained pre-coverage records cannot classify arbitrary prose. Recognize only -// the historical producer vocabulary here; unfamiliar diagnostics remain unknown. -const legacyInventoryCoverage = ( - format: ArtifactInventoryResult["manifest"]["root_format"], - limitations: readonly string[], - contradictionCount: number, -): ArtifactInventoryCoverage => ({ - scope: - isZipPackageFormat(format) || - ["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format) - ? "container-entries" - : "root", - status: limitations.some( - (limitation) => - limitation !== "Artifact has no child container entries." && - !isZipInventoryScopeLimitation(limitation) && - !( - contradictionCount > 0 && - limitation === - `${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.` - ), - ) - ? "unknown" - : "complete", -}); - 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 8626d7465b..dbfb6680f0 100644 --- a/src/domain/artifactInventorySnapshot.ts +++ b/src/domain/artifactInventorySnapshot.ts @@ -7,9 +7,6 @@ import type { /** Immutable inventory produced by one complete artifact scan. */ export interface ArtifactInventorySnapshot { - readonly coverage_status: NonNullable< - ArtifactInventoryResult["coverage_status"] - >; readonly manifest: ArtifactInventoryResult["manifest"]; readonly nodes: readonly ArtifactNode[]; readonly occurrences: ArtifactInventoryResult["occurrences"]; @@ -18,6 +15,6 @@ export interface ArtifactInventorySnapshot { ArtifactInventoryResult["provenance"][number] >; readonly integrity_contradictions: readonly IntegrityContradiction[]; - readonly coverage?: ArtifactInventoryCoverage; + readonly coverage: ArtifactInventoryCoverage; readonly limitations: readonly string[]; } diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index c85820ab44..6d9fd4663b 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -55,9 +55,3 @@ export const zipPackageFormatForPath = ( /** Inventory lists entries, not all bytes surrounding the central directory. */ export const ZIP_NON_ENTRY_LIMITATION = "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences."; - -/** Recognize the same inventory scope in current and previously retained Evidence. */ -export const isZipInventoryScopeLimitation = (limitation: string): boolean => - limitation === ZIP_NON_ENTRY_LIMITATION || - limitation === - "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index c4a4c028ab..5c4c7a3396 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -180,24 +180,29 @@ const evidenceValues = ( if (kind === "evidence") return [evidence.evidence_id]; if (kind === "capture") return isProcessCaptureEvidence(evidence) ? [evidence.evidence_id] : []; - const inventoryResult = - evidence.operation === "inventory_artifact" - ? evidence.normalized_result - : evidence.operation === "inspect_artifact" - ? (() => { - const inspection = artifactInspectionResultSchema.safeParse( - evidence.normalized_result, - ); - return inspection.success - ? inspection.data.substeps[0]?.evidence.normalized_result - : undefined; - })() - : undefined; - if (inventoryResult === undefined) return []; - const inventory = artifactInventoryResultSchema.safeParse(inventoryResult); - if (!inventory.success) return []; - if (kind === "manifest") return [inventory.data.manifest.manifest_id]; - return inventory.data.occurrences.map(({ occurrence_id: id }) => id); + let inventoryResults: readonly unknown[]; + if (evidence.operation === "inventory_artifact") + inventoryResults = [evidence.normalized_result]; + else if (evidence.operation === "inspect_artifact") { + const inspection = artifactInspectionResultSchema.safeParse( + evidence.normalized_result, + ); + if (!inspection.success) return []; + inventoryResults = inspection.data.substeps.map( + ({ evidence: inventoryEvidence }) => inventoryEvidence.normalized_result, + ); + } else return []; + const values: string[] = []; + for (const inventoryResult of inventoryResults) { + const inventory = artifactInventoryResultSchema.safeParse(inventoryResult); + if (!inventory.success) continue; + if (kind === "manifest") values.push(inventory.data.manifest.manifest_id); + else + values.push( + ...inventory.data.occurrences.map(({ occurrence_id: id }) => id), + ); + } + return values; }; const isProcessCaptureEvidence = (evidence: LedgerEvidence): boolean => { diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index f9839ac1d4..3e0661f7c4 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -5,6 +5,7 @@ import { toolContract } from "../contracts/toolContracts.js"; import { compareArtifacts } from "../domain/artifactComparison.js"; import { createEvidence, parseEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { resolveArtifactComparisonEvidence } from "../application/artifacts/ArtifactComparisonEvidence.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; @@ -20,6 +21,9 @@ export const registerArtifactComparisonTool = ( contract: ReturnType>, ): void => { const admission = withAdmittedAnalysis({ kind: "session", session }); + const evidenceById = (evidenceId: string) => + session.evidenceForAnalysis?.(evidenceId) ?? + session.evidenceById(evidenceId); server.registerTool( contract.name, toolRegistrationOptions(contract), @@ -34,8 +38,20 @@ export const registerArtifactComparisonTool = ( context, contract.name, () => { - const left = parseEvidence(input.left); - const right = parseEvidence(input.right); + const suppliedLeft = parseEvidence(input.left); + const suppliedRight = parseEvidence(input.right); + const resolvedLeft = resolveArtifactComparisonEvidence( + suppliedLeft, + evidenceById, + ); + if (!resolvedLeft.ok) throw resolvedLeft.error; + const resolvedRight = resolveArtifactComparisonEvidence( + suppliedRight, + evidenceById, + ); + if (!resolvedRight.ok) throw resolvedRight.error; + const left = resolvedLeft.value; + const right = resolvedRight.value; return { left, right, comparison: compareArtifacts(left, right) }; }, ); diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts index db86603114..42ab385205 100644 --- a/tests/acceptance/applications/artifactComparisonCoverage.test.ts +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -105,7 +105,6 @@ const assertCoverageControls = async ({ derive({ ...inventory, coverage: { scope: "root", status: "unknown" }, - coverage_status: "partial", limitations: [], }), ); @@ -125,26 +124,14 @@ const assertCoverageControls = async ({ limitations: expect.arrayContaining(["Artifact inventory scopes differ."]), }); - const { coverage, coverage_status: _coverageStatus, ...legacy } = inventory; - expect(coverage).toBeDefined(); - const retained = await compare(left, derive(legacy)); - expect(retained.normalized_result).toMatchObject({ - status: "unchanged", - summary: { unchanged: 1, unknown: 0 }, - }); - for (const limitation of [ - "Child inventory is unavailable.", - "Missing child observations; 1 integrity contradiction(s) were recorded.", - ]) { - const unfamiliarLegacy = await compare( - left, - derive({ ...legacy, limitations: [limitation] }), - ); - expect(unfamiliarLegacy.normalized_result).toMatchObject({ - status: "unknown", - summary: { unchanged: 0, unknown: 1 }, - }); - } + const { coverage: _coverage, ...withoutCoverage } = inventory; + expect(() => artifactInventoryResultSchema.parse(withoutCoverage)).toThrow(); + expect(() => + artifactInventoryResultSchema.parse({ + ...inventory, + coverage_status: "complete", + }), + ).toThrow(); }; cliTest( diff --git a/tests/acceptance/artifactComparisonPlist.test.ts b/tests/acceptance/artifactComparisonPlist.test.ts index 3781eb6c71..a6cb31bd25 100644 --- a/tests/acceptance/artifactComparisonPlist.test.ts +++ b/tests/acceptance/artifactComparisonPlist.test.ts @@ -34,7 +34,7 @@ it("compares identical complete plist inventories across CLI and MCP and creates if (cliInventory === undefined) throw new Error("CLI inspection did not retain inventory Evidence"); expect(cliInventory.normalized_result).toMatchObject({ - coverage_status: "complete", + coverage: { scope: "root", status: "complete" }, limitations: ["Artifact has no child container entries."], }); @@ -50,7 +50,7 @@ it("compares identical complete plist inventories across CLI and MCP and creates if (mcpInventory === undefined) throw new Error("MCP inspection did not retain inventory Evidence"); expect(mcpInventory.normalized_result).toMatchObject({ - coverage_status: "complete", + coverage: { scope: "root", status: "complete" }, limitations: ["Artifact has no child container entries."], }); diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index a29844641c..eb5efb950a 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -264,7 +264,7 @@ describe("case-distinct APK resource names", () => { await runProviderAnalysis(path, "inventory_artifact", {}), ); expect(inventory.normalized_result).toMatchObject({ - coverage_status: "complete", + coverage: { scope: "container-entries", status: "complete" }, }); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index 36e6bf9453..603201c468 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -9,12 +9,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; import { artifactComparisonResultSchema, compareArtifacts, } from "../../../src/domain/artifactComparison.js"; import { parseArtifactInventoryEvidence } from "../../../src/domain/artifactInventoryEvidence.js"; -import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; const PROVIDER = { @@ -42,6 +43,66 @@ const observe = async (path: string) => { ); }; +const inspectPages = ( + pages: readonly Evidence[], + substepsCompleted = pages.length, +) => { + const first = pages[0]; + if (first === undefined) throw new Error("Missing inspection page"); + const inventory = artifactInventoryResultSchema.parse( + first.normalized_result, + ); + return createEvidence( + { + path: "artifact-inspection-example", + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + PROVIDER, + { + operation: "inspect_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + inspection_id: `ai_${"f".repeat(64)}`, + subject: { + manifest_id: inventory.manifest.manifest_id, + root_artifact_id: inventory.manifest.root_artifact_id, + root_sha256: inventory.manifest.root_sha256, + root_format: inventory.manifest.root_format, + }, + substeps: pages.map((evidence, index) => ({ + substep_id: `ais_${(index + 1).toString(16).padStart(64, "0")}`, + operation: "inventory_artifact", + status: "completed", + evidence_id: evidence.evidence_id, + evidence, + })), + observations: [], + hypotheses: [], + contradictions: [], + unexplored_branches: [], + next_probes: [], + coverage: { + status: "complete-within-substeps", + substeps_completed: substepsCompleted, + observations_retained: 0, + observations_omitted: 0, + hypotheses_retained: 0, + hypotheses_omitted: 0, + unexplored_branches_retained: 0, + unexplored_branches_omitted: 0, + next_probes_retained: 0, + next_probes_omitted: 0, + }, + evidence_links: pages.map(({ evidence_id }) => evidence_id), + limitations: [], + }), + confidence: "observed", + authority: "shipped-artifact", + }, + ); +}; + const changedArtifactComparison = async () => { const parent = await createTestTempDirectory("rea-artifact-compare-"); const leftPath = join(parent, "left.app"); @@ -64,26 +125,16 @@ const changedArtifactComparison = async () => { }; describe("leaf artifact comparison", () => { - it("accepts persisted complete leaf inventory Evidence without coverage_status", async () => { + it("rejects inventory Evidence without required producer coverage", 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. + await writeFile(leftPath, xml); + const left = await observe(leftPath); const parsed = artifactInventoryResultSchema.parse(left.normalized_result); - const { - coverage: _coverage, - coverage_status: _coverageStatus, - ...legacyInventory - } = parsed; - const legacy = createEvidence( + const { coverage: _coverage, ...withoutCoverage } = parsed; + const malformed = createEvidence( { path: left.subject!.local_path, sha256: left.subject!.digest.sha256, @@ -93,16 +144,12 @@ describe("leaf artifact comparison", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(legacyInventory), + result: jsonValueSchema.parse(withoutCoverage), confidence: "observed", authority: "shipped-artifact", }, ); - expect(compareArtifacts(legacy, right)).toMatchObject({ - status: "unchanged", - summary: { unchanged: 1, unknown: 0 }, - changes: [], - }); + expect(() => parseArtifactInventoryEvidence(malformed)).toThrow(); }); it("keeps content and executable changes visible for complete plist roots", async () => { @@ -150,8 +197,95 @@ describe("leaf artifact comparison", () => { }); }); +describe("artifact inspection inventory substeps", () => { + it("assembles every paged inventory substep and retains its outer Evidence once", async () => { + const root = await createTestTempDirectory("rea-inspect-pages-"); + const path = join(root, "fixture.app"); + await mkdir(path); + await Promise.all([ + writeFile(join(path, "first.js"), "first();"), + writeFile(join(path, "second.js"), "second();"), + ]); + const source = await observe(path); + const pages = fragmentInventoryEvidence(source, 2); + const inspection = inspectPages(pages); + const parsed = parseArtifactInventoryEvidence(inspection); + + expect(parsed.evidence).toEqual([inspection]); + expect(parsed.inventory.complete).toBe(true); + expect(parsed.inventory.nodes).toHaveLength( + artifactInventoryResultSchema.parse(source.normalized_result).nodes + .length, + ); + }); + + it("rejects inspection substeps with different inventory manifests", async () => { + const root = await createTestTempDirectory("rea-inspect-mixed-pages-"); + const firstPath = join(root, "first.plist"); + const secondPath = join(root, "second.plist"); + await Promise.all([ + writeFile( + firstPath, + 'a1', + ), + writeFile( + secondPath, + 'b2', + ), + ]); + + const inspection = inspectPages([ + await observe(firstPath), + await observe(secondPath), + ]); + expect(() => parseArtifactInventoryEvidence(inspection)).toThrow( + "Artifact inventory pages do not share one manifest", + ); + }); + + it("rejects inspection coverage counts that disagree with retained substeps", async () => { + const root = await createTestTempDirectory("rea-inspect-count-mismatch-"); + const path = join(root, "fixture.app"); + await mkdir(path); + await Promise.all([ + writeFile(join(path, "first.js"), "first();"), + writeFile(join(path, "second.js"), "second();"), + ]); + const pages = fragmentInventoryEvidence(await observe(path), 2); + const inspection = inspectPages(pages, 1); + + expect(() => parseArtifactInventoryEvidence(inspection)).toThrow( + "Artifact inspection substep count does not match its coverage declaration", + ); + }); + + it("authenticates later inventory substeps instead of silently ignoring them", async () => { + const root = await createTestTempDirectory("rea-inspect-invalid-page-"); + const path = join(root, "fixture.app"); + await mkdir(path); + await writeFile(join(path, "main.js"), "main();"); + const source = await observe(path); + const [first, later] = fragmentInventoryEvidence(source, 2); + if (first === undefined || later === undefined) + throw new Error("Missing inventory page"); + const graph = artifactInventoryResultSchema.parse(later.normalized_result); + const tamperedLater: Evidence = { + ...later, + normalized_result: jsonValueSchema.parse({ + ...graph, + limitations: ["Changed after this Evidence identifier was computed."], + }), + }; + const inspection = inspectPages([first, tamperedLater]); + + expect(() => parseArtifactInventoryEvidence(inspection)).toThrow( + "Evidence semantic identifier does not match its record", + ); + }); +}); + describe("artifact inventory coverage", () => { - it("keeps explicit and legacy unknown coverage fail-closed", async () => { + it("uses current unknown coverage without deriving it from limitation prose", async () => { const root = await createTestTempDirectory("rea-artifact-coverage-"); const path = join(root, "complete.plist"); await writeFile( @@ -162,7 +296,7 @@ describe("artifact inventory coverage", () => { const graph = artifactInventoryResultSchema.parse( complete.normalized_result, ); - const contradictory = createEvidence( + const unknownCoverage = createEvidence( { path: complete.subject!.local_path, sha256: complete.subject!.digest.sha256, @@ -172,63 +306,19 @@ describe("artifact inventory coverage", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse({ ...graph, coverage_status: "partial" }), + result: jsonValueSchema.parse({ + ...graph, + coverage: { scope: "root", status: "unknown" }, + limitations: ["No extra child entries were observed."], + }), confidence: "derived", authority: "shipped-artifact", }, ); - expect(() => parseArtifactInventoryEvidence(contradictory)).toThrow( - "Artifact inventory has conflicting coverage declarations", - ); - - const { coverage: _coverage, ...legacyStatusGraph } = graph; - 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({ - ...legacyStatusGraph, - coverage_status: "partial", - }), - confidence: "observed", - authority: "shipped-artifact", - }, - ); - expect(parseArtifactInventoryEvidence(partial).inventory.complete).toBe( - true, - ); - expect(compareArtifacts(partial, partial)).toMatchObject({ - status: "unknown", - summary: { unchanged: 0, unknown: 1 }, - }); - - const { coverage_status: _coverageStatus, ...legacyGraph } = - legacyStatusGraph; - 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({ + expect( + parseArtifactInventoryEvidence(unknownCoverage).inventory.complete, + ).toBe(true); + expect(compareArtifacts(unknownCoverage, unknownCoverage)).toMatchObject({ status: "unknown", summary: { unchanged: 0, unknown: 1 }, }); @@ -248,7 +338,6 @@ describe("artifact inventory coverage", () => { const fragmentGraph = artifactInventoryResultSchema.parse( fragment.normalized_result, ); - const { coverage: _coverage, ...legacyFragmentGraph } = fragmentGraph; const mixed = createEvidence( { path: fragment.subject!.local_path, @@ -260,8 +349,8 @@ describe("artifact inventory coverage", () => { operation: "inventory_artifact", parameters: fragment.parameters, result: jsonValueSchema.parse({ - ...legacyFragmentGraph, - coverage_status: "partial", + ...fragmentGraph, + coverage: { ...fragmentGraph.coverage, status: "unknown" }, }), confidence: "derived", authority: "shipped-artifact", @@ -274,6 +363,42 @@ describe("artifact inventory coverage", () => { }); describe("artifact comparison", () => { + it("keeps the canonical public example valid and fully comparable", () => { + const left = parseArtifactInventoryEvidence( + ARTIFACT_COMPARISON_EXAMPLE.left, + ); + const right = parseArtifactInventoryEvidence( + ARTIFACT_COMPARISON_EXAMPLE.right, + ); + + expect(left.inventory).toMatchObject({ + complete: true, + coverage: { scope: "root", status: "complete" }, + }); + expect(right.inventory).toMatchObject({ + complete: true, + coverage: { scope: "root", status: "complete" }, + }); + expect( + artifactComparisonResultSchema.parse( + compareArtifacts( + ARTIFACT_COMPARISON_EXAMPLE.left, + ARTIFACT_COMPARISON_EXAMPLE.right, + ), + ), + ).toMatchObject({ + status: "changed", + summary: { changed: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ + logical_path: ".", + classification: "changed", + dimensions: expect.arrayContaining(["content"]), + }), + ], + }); + }); + it("classifies deterministic path changes and cites both inventories", async () => { const firstObservation = await changedArtifactComparison(); const secondObservation = await changedArtifactComparison(); @@ -480,7 +605,7 @@ describe("artifact source-map relationship coverage", () => { ({ occurrence_id }) => occurrence_id === map.occurrence_id, ); - expect(inventory.coverage?.status).toBe("unknown"); + expect(inventory.coverage.status).toBe("unknown"); expect(map.limitations).toContain( 'Source-map target is unknown because multiple occurrences share "main.js".', ); @@ -731,7 +856,7 @@ describe("artifact comparison fragment integrity", () => { }); describe("ZIP non-entry coverage", () => { - it("retains pre-directory APK block changes and compares historical scope descriptions", async () => { + it("retains pre-directory APK block changes under current ZIP coverage", async () => { const root = await createTestTempDirectory("rea-apk-block-compare-"); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("classes.dex", new TextReader("unchanged entry")); @@ -776,43 +901,5 @@ describe("ZIP non-entry coverage", () => { expect.objectContaining({ logical_path: ".", dimensions: ["content"] }), ], }); - const { - coverage: _coverage, - coverage_status: _coverageStatus, - ...legacyInventory - } = inventory; - const withLimitation = (limitation: string) => - createEvidence( - { - path: paths[0]!, - sha256: inventory.manifest.root_sha256, - format: "apk", - }, - PROVIDER, - { - operation: "inventory_artifact", - parameters: {}, - result: jsonValueSchema.parse({ - ...legacyInventory, - limitations: [limitation], - }), - confidence: "observed", - authority: "shipped-artifact", - }, - ); - const historical = withLimitation( - "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.", - ); - expect(compareArtifacts(historical, left)).toMatchObject({ - status: "unchanged", - summary: { unchanged: 2, unknown: 0 }, - changes: [], - }); - expect( - compareArtifacts( - withLimitation("ZIP entry bytes could not be read."), - left, - ).status, - ).toBe("unknown"); }); }); diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index f889aaed87..76fddc3740 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -225,7 +225,7 @@ describe("HarmonyOS inventory coverage", () => { ); const inventory = await inventoryApplicationPackage(path); expect(inventory.normalized_result).toMatchObject({ - coverage_status: "complete", + coverage: { scope: "container-entries", status: "complete" }, }); const projection = requireDeterministicProjection( diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index 96ba07283d..1a73ab8f43 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -306,30 +306,23 @@ it("records an explicitly continued mismatch, preserves verified siblings, and n }), ]), }); - const { coverage: _coverage, ...legacyInventory } = inventory; - const historical = createEvidence( + const { coverage: _coverage, ...inventoryWithoutCoverage } = inventory; + const missingCoverage = createEvidence( { path: archive, sha256: inventory.manifest.root_sha256, format: "asar" }, compact.provider, { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(legacyInventory), + result: jsonValueSchema.parse(inventoryWithoutCoverage), }, ); const retainedComparison = await client.callTool({ name: "compare_artifacts", - arguments: { left: evidence, right: historical }, - }); - expect( - retainedComparison.isError, - JSON.stringify(retainedComparison), - ).not.toBe(true); - expect( - toolEvidence(retainedComparison.structuredContent).normalized_result, - ).toMatchObject({ - status: "contradiction", - summary: { unchanged: 2, contradiction: 2, unknown: 0 }, + arguments: { left: evidence, right: missingCoverage }, }); + expect(retainedComparison.isError, JSON.stringify(retainedComparison)).toBe( + true, + ); } finally { await Promise.allSettled([client.close(), server.close(), session.close()]); } @@ -547,4 +540,65 @@ it("returns full artifact graphs inline and compares changed inventories", async } }); +it("compares summary Evidence through retained complete inspection parents", async () => { + const directory = await createTestTempDirectory("rea-artifact-summary-mcp-"); + const archive = join(directory, "one.zip"); + const changedArchive = join(directory, "two.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("a.txt", new TextReader("same")); + await writeFile(archive, await writer.close()); + const changedWriter = new ZipWriter(new Uint8ArrayWriter()); + await changedWriter.add("a.txt", new TextReader("same")); + await changedWriter.add("b.txt", new TextReader("new")); + await writeFile(changedArchive, await changedWriter.close()); + + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const server = createServer({ kind: "session", session }); + const client = new Client({ + name: "artifact-summary-mcp-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const summaries = []; + for (const path of [archive, changedArchive]) { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { detail: "summary" }, + }); + expect(inspected.isError).not.toBe(true); + summaries.push(toolEvidence(inspected.structuredContent)); + } + const [left, right] = summaries; + if (left === undefined || right === undefined) + throw new Error("missing artifact summary Evidence"); + expect(left.operation).toBe("inspect_analysis_view"); + expect(right.operation).toBe("inspect_analysis_view"); + const compared = await client.callTool({ + name: "compare_artifacts", + arguments: { left, right }, + }); + expect(compared.isError).not.toBe(true); + const result = toolEvidence(compared.structuredContent); + expect(result.normalized_result).toMatchObject({ status: "changed" }); + const leftParentId = z + .object({ parent_evidence_id: z.string() }) + .parse(left.normalized_result).parent_evidence_id; + const rightParentId = z + .object({ parent_evidence_id: z.string() }) + .parse(right.normalized_result).parent_evidence_id; + expect(result.evidence_links).toEqual([leftParentId, rightParentId]); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + const toolEvidence = (value: unknown) => parseEvidence(value); diff --git a/tests/boundary/mcp/promptCompletion.test.ts b/tests/boundary/mcp/promptCompletion.test.ts index 009ed7ad84..f795b116aa 100644 --- a/tests/boundary/mcp/promptCompletion.test.ts +++ b/tests/boundary/mcp/promptCompletion.test.ts @@ -15,7 +15,10 @@ import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComp import { PROCESS_CAPTURE_REFERENCE } from "../../../src/contracts/investigationExamples.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { createArtifactInspection } from "../../../src/domain/artifactInspection.js"; +import { + artifactInspectionResultSchema, + createArtifactInspection, +} from "../../../src/domain/artifactInspection.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { createPromptCompletionSource } from "../../../src/server/promptCompletion.js"; @@ -204,6 +207,65 @@ describe("guided prompt completion from investigation records", () => { await session.close(); expect(await completion.complete("evidence", "ev_")).toEqual([]); }); +}); + +describe("guided prompt completion from retained records", () => { + it("offers identifiers from every retained inspection inventory substep", async () => { + const session = createTestBinarySession(() => client([])); + const leftInspection = createArtifactInspection( + ARTIFACT_COMPARISON_EXAMPLE.left, + ); + const rightInspection = createArtifactInspection( + ARTIFACT_COMPARISON_EXAMPLE.right, + ); + const inspection = artifactInspectionResultSchema.parse({ + ...leftInspection, + substeps: [...leftInspection.substeps, ...rightInspection.substeps], + evidence_links: [ + ...leftInspection.evidence_links, + ...rightInspection.evidence_links, + ], + }); + const inspectionEvidence = createEvidence(undefined, fixtureProvider, { + operation: "inspect_artifact", + parameters: {}, + result: jsonValueSchema.parse(inspection), + confidence: "observed", + authority: "shipped-artifact", + evidenceLinks: inspection.evidence_links, + }); + expect(session.recordEvidence(inspectionEvidence).ok).toBe(true); + const completion = createPromptCompletionSource(session, session); + const expectedManifests = [ + ARTIFACT_COMPARISON_EXAMPLE.left, + ARTIFACT_COMPARISON_EXAMPLE.right, + ] + .map( + ({ normalized_result }) => + artifactInventoryResultSchema.parse(normalized_result).manifest + .manifest_id, + ) + .sort(); + const expectedOccurrences = [ + ARTIFACT_COMPARISON_EXAMPLE.left, + ARTIFACT_COMPARISON_EXAMPLE.right, + ] + .flatMap(({ normalized_result }) => + artifactInventoryResultSchema + .parse(normalized_result) + .occurrences.map(({ occurrence_id }) => occurrence_id), + ) + .sort(); + + expect(await completion.complete("manifest", "agm_")).toEqual( + expectedManifests, + ); + expect(await completion.complete("occurrence", "occ_")).toEqual( + expectedOccurrences, + ); + + await session.close(); + }); it("deduplicates, case-folds, and returns deterministic live evidence IDs", async () => { const session = createTestBinarySession(() => client([]));