diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 0748894d14..82018733b7 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -35,6 +35,7 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; +import { analysisViewResultInputError } from "./analysisViewParent.js"; const page = { kind: z.literal("inventory"), @@ -172,7 +173,7 @@ const parseSource = ( cached.source.inventory.manifest, ); } catch (cause: unknown) { - return sourceFailure(cause); + return sourceFailure(parent.normalizedResult, cause); } return ok(cached.source); } @@ -208,13 +209,17 @@ const parseSourceUncached = ( inspection: parsed.inspection, }); } catch (cause: unknown) { - return sourceFailure(cause); + return sourceFailure(parent.normalizedResult, cause); } }; -const sourceFailure = (cause: unknown): Result => { - if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError)) - throw cause; +const sourceFailure = ( + input: unknown, + cause: unknown, +): Result => { + if (cause instanceof z.ZodError) + return err(analysisViewResultInputError(input, cause)); + if (!(cause instanceof TypeError)) throw cause; return err(inputError(cause.message)); }; diff --git a/src/domain/artifactInventoryEvidence.test.ts b/src/domain/artifactInventoryEvidence.test.ts index 4af33964c4..227c6dca81 100644 --- a/src/domain/artifactInventoryEvidence.test.ts +++ b/src/domain/artifactInventoryEvidence.test.ts @@ -1,5 +1,6 @@ import { freezeJsonSnapshot } from "./immutableJson.js"; import { expect, it } from "vitest"; +import { z } from "zod"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js"; import { createArtifactInspection } from "./artifactInspection.js"; @@ -86,3 +87,28 @@ it("retains the authenticated parent's nested facts without a second inventory c owned.substeps[0]!.evidence.normalized_result.nodes[0], ); }); + +it.each([0, 1])( + "locates a stale nested Evidence ID at substep %i and retains its cause", + (index) => { + const input = structuredClone(inspection); + input.substeps.push(structuredClone(input.substeps[0]!)); + input.coverage.substeps_completed = input.substeps.length; + expect( + parseArtifactInspectionInventoryResult(input, target).inventory.complete, + ).toBe(true); + input.substeps[index]!.evidence.evidence_id = `ev_${"f".repeat(64)}`; + expect(() => + parseArtifactInspectionInventoryResult(input, target), + ).toThrowError( + expect.objectContaining({ + issues: [ + expect.objectContaining({ + path: ["substeps", index, "evidence", "evidence_id"], + }), + ], + cause: expect.any(z.ZodError), + }), + ); + }, +); diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index c7fcc3ec13..b2e7899eee 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -23,6 +23,7 @@ import { occurrenceIdForLocation, } from "./artifactIdentity.js"; import { parseEvidence, type Evidence } from "./evidence.js"; +import { z } from "zod"; import { artifactInspectionResultSchema, type ArtifactInspectionResult, @@ -124,7 +125,21 @@ const parseInspectionInventoryPayload = ( ); const sourceEvidence = immutableJsonSnapshotMember(sealedStep, "evidence") ?? step.evidence; - const parsedEvidence = parseEvidence(sourceEvidence); + let parsedEvidence: Evidence; + try { + parsedEvidence = parseEvidence(sourceEvidence); + } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; + throw Object.assign( + new z.ZodError( + cause.issues.map((issue) => ({ + ...issue, + path: ["substeps", index, "evidence", ...issue.path], + })), + ), + { cause }, + ); + } const evidence = canBorrowParsedJsonSnapshot(sourceEvidence, parsedEvidence) ? sourceEvidence : parsedEvidence; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index a8863754fc..9cd12c8f78 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -29,7 +29,14 @@ import { environmentManifestCaptureFixture, environmentStateCaptureFixture, } from "../../fixtures/analysisView.js"; -import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { z } from "zod"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; @@ -294,6 +301,141 @@ cliTest( }, ); +cliTest( + "preserves artifact parent schema diagnostics through the application and CLI", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-inventory-diagnostics-cli-", + ); + const archive = join(root, "fixture.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("main.js", new TextReader("console.log(1);\n")); + await writeFile(archive, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", archive, "--json"], + }); + expect(inspected.exitCode).toBe(0); + const inspection = parseEvidence(inspected.json); + const inventory = artifactInspectionResultSchema.parse( + inspection.normalized_result, + ).substeps[0]!.evidence; + const path = join(root, "view.json"); + const reseal = (carrier: Evidence, result: JsonValue) => { + const subject = carrier.subject!; + return createEvidence( + { + path: subject.local_path, + sha256: subject.digest.sha256, + format: subject.format, + }, + carrier.provider, + { operation: carrier.operation, parameters: {}, result }, + ); + }; + for (const [carrier, group, field] of [ + [inventory, "manifest", "node_count"], + [inspection, "coverage", "substeps_completed"], + ] as const) { + for (const variant of ["valid", "type", "missing"] as const) { + const payload = structuredClone(carrier.normalized_result) as Record< + string, + Record + >; + if (variant === "type") payload[group]![field] = "bad"; + if (variant === "missing") delete payload[group]![field]; + const evidence = reseal(carrier, payload); + const request = { + source: { kind: "inline" as const, evidence }, + view: { kind: "summary" as const }, + }; + const application = inspectAnalysisView(request); + await writeFile(path, JSON.stringify(request)); + const reply = await cli.run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(reply.stderr).toBe(""); + if (variant === "valid") { + expect(application.ok).toBe(true); + expect(reply.exitCode).toBe(0); + expect(parseEvidence(reply.json).normalized_result).toMatchObject({ + kind: "summary", + }); + continue; + } + expect(application.ok).toBe(false); + if (application.ok) throw new Error("Expected an invalid parent"); + expect(application.error.cause).toBeInstanceOf(z.ZodError); + expect(reply.exitCode).toBe(1); + expect(reply.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + retryable: false, + recovery: { + kind: "inspect_input_contract", + operation: "inspect_analysis_view", + }, + details: { + issues: [ + { + path: ["source", "normalized_result", group, field], + reason: + variant === "type" ? "invalid_type" : "missing_argument", + expected: "number", + }, + ], + }, + }); + } + } + const nested = artifactInspectionResultSchema.parse( + inspection.normalized_result, + ); + nested.substeps.push(structuredClone(nested.substeps[0]!)); + nested.coverage.substeps_completed = nested.substeps.length; + nested.substeps[1]!.evidence.evidence_id = `ev_${"f".repeat(64)}`; + const request = { + source: { kind: "inline" as const, evidence: reseal(inspection, nested) }, + view: { kind: "summary" as const }, + }; + const application = inspectAnalysisView(request); + expect(application.ok).toBe(false); + if (application.ok) throw new Error("Expected invalid nested Evidence"); + expect(application.error.cause).toMatchObject({ + cause: expect.any(z.ZodError), + issues: [ + expect.objectContaining({ + path: ["substeps", 1, "evidence", "evidence_id"], + }), + ], + }); + await writeFile(path, JSON.stringify(request)); + const refused = await cli.run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toBe(""); + expect(refused.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: [ + "source", + "normalized_result", + "substeps", + 1, + "evidence", + "evidence_id", + ], + reason: "invalid_value", + message: "Evidence semantic identifier does not match its record", + }, + ], + }, + }); + }, +); + cliTest( "admits portable captures before parsing and preserves capture caveats", async ({ cli }) => {