From 213fcc8ce110c4c8a7d15550bcb80bfff4720e7a Mon Sep 17 00:00:00 2001 From: wunianze666-netizen Date: Tue, 27 Oct 2026 21:16:21 +0800 Subject: [PATCH] fix(snapshot): retain schema diagnostics in file failures Refs morluto/rea#7442 Fixes #7442 Supersedes #7875 Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/getting-started/cli.md | 6 ++ .../binary/AnalysisSnapshotFiles.ts | 9 ++- .../analysisSnapshotDiagnostics.test.ts | 70 ++++++++++++++++++ .../filesystem/analysisSnapshotInput.test.ts | 72 ++++++++++++++++++- 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 tests/acceptance/analysisSnapshotDiagnostics.test.ts diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index dedec3ea8b..b26cb4d1b8 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -690,6 +690,12 @@ an exact result when the target bytes, operation, parameters, provider and settings match. Mutations and cursor-dependent calls are excluded from the cache. Snapshot files are local and use owner-only permissions. +Malformed snapshot headers or entries remain integrity failures. The diagnostic +keeps the first schema issue's location and message, plus the number of further +issues, rather than replacing it with a constant validation reason. File-selection +and JSON-syntax failures remain distinct; internal exception objects and stacks +are not serialized into the public error. + A binary CLI replay reports the current invocation's selected path while leaving the saved Evidence and snapshot query unchanged. See [caller-selected paths](../reference/mcp-contracts.md#caller-selected-paths) diff --git a/src/application/binary/AnalysisSnapshotFiles.ts b/src/application/binary/AnalysisSnapshotFiles.ts index af1842af35..c3144be396 100644 --- a/src/application/binary/AnalysisSnapshotFiles.ts +++ b/src/application/binary/AnalysisSnapshotFiles.ts @@ -1,4 +1,5 @@ import { setImmediate } from "node:timers/promises"; +import { z } from "zod"; import { prepareSnapshotEntry, @@ -12,6 +13,7 @@ import { EvidenceIntegrityError, } from "../../domain/evidenceErrors.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { describeValidationFailure } from "../../domain/validationFailure.js"; import type { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { readJsonObjectMembersFile, writeTextFile } from "../JsonFiles.js"; @@ -140,7 +142,10 @@ export const writeAnalysisSnapshot = async ( return writeTextFile(encoded, path, overwrite, resources); }; -const snapshotValidationMessage = (cause: unknown): string => - cause instanceof TypeError +const snapshotValidationMessage = (cause: unknown): string => { + if (cause instanceof z.ZodError) + return `Analysis snapshot validation failed ${describeValidationFailure(cause)}`; + return cause instanceof TypeError ? cause.message : "Analysis snapshot validation failed"; +}; diff --git a/tests/acceptance/analysisSnapshotDiagnostics.test.ts b/tests/acceptance/analysisSnapshotDiagnostics.test.ts new file mode 100644 index 0000000000..1c24529239 --- /dev/null +++ b/tests/acceptance/analysisSnapshotDiagnostics.test.ts @@ -0,0 +1,70 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "preserves selected snapshot schema reasons through compiled CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-snapshot-diagnostic-boundary-", + ); + const target = join(root, "target.zip"); + const snapshot = join(root, "selected.json"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("entry.txt", new TextReader("inert"), { level: 0 }); + const targetBytes = await writer.close(); + await writeFile(target, targetBytes); + const call = await cli.sharedMcpCall!(); + for (const [document, field] of [ + [{ entries: [], workflow_entries: [] }, "target"], + [{ entries: [{}], workflow_entries: [] }, "query_id"], + [{ entries: [], workflow_entries: [{}] }, "query_id"], + ] as const) { + const encoded = JSON.stringify(document); + await writeFile(snapshot, encoded); + const output = await cli.sharedCliRun!({ + arguments: [ + "analyze", + target, + "--snapshot", + snapshot, + "--format", + "json", + ], + }); + expect(output.exitCode, output.stderr).toBe(1); + const { error: description, ...projection } = z + .record(z.string(), z.unknown()) + .parse(output.json); + expect(description).toBe("Analysis failed"); + const cliError = analysisErrorProjectionSchema.parse(projection); + const response = await call("open_binary", { + path: target, + snapshot_path: snapshot, + }); + const mcpError = parseMcpToolError(response).error; + for (const error of [cliError, mcpError]) { + expect(error).toMatchObject({ + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + retryable: false, + details: { reason: expect.stringContaining(`at ${field}:`) }, + }); + expect(error).not.toHaveProperty("cause"); + expect(error).not.toHaveProperty("stack"); + } + expect(mcpError.details).toEqual(cliError.details); + expect(await readFile(snapshot, "utf8")).toBe(encoded); + } + expect( + Buffer.compare(await readFile(target), Buffer.from(targetBytes)), + ).toBe(0); + }, +); diff --git a/tests/boundary/filesystem/analysisSnapshotInput.test.ts b/tests/boundary/filesystem/analysisSnapshotInput.test.ts index 85bd7b7d14..0b5ca523f5 100644 --- a/tests/boundary/filesystem/analysisSnapshotInput.test.ts +++ b/tests/boundary/filesystem/analysisSnapshotInput.test.ts @@ -1,9 +1,12 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; +import { + readAnalysisSnapshot, + writeAnalysisSnapshot, +} from "../../../src/application/binary/AnalysisSnapshotFiles.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { snapshotBinding, @@ -15,11 +18,53 @@ import { } from "../../../src/domain/analysisSnapshot.fixture.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js"; describe.each([ { mode: "native", paddingBytes: 0 }, { mode: "streamed", paddingBytes: 9 * 1024 * 1024 }, ])("analysis snapshot input ($mode)", ({ paddingBytes }) => { + it.each([ + { + label: "header", + document: { entries: [], workflow_entries: [] }, + field: "target", + }, + { + label: "provider entry", + document: { entries: [{}], workflow_entries: [] }, + field: "query_id", + }, + { + label: "workflow entry", + document: { entries: [], workflow_entries: [{}] }, + field: "query_id", + }, + ])( + "retains the first $label schema diagnostic through the file wrapper", + async ({ document, field }) => { + const root = await createTestTempDirectory( + "rea-snapshot-schema-diagnostic-", + ); + const path = join(root, "snapshot.json"); + const encoded = `${" ".repeat(paddingBytes)}${JSON.stringify(document)}`; + await writeFile(path, encoded); + const loaded = await readAnalysisSnapshot(path); + if (loaded.ok) throw new Error("Expected invalid snapshot refusal"); + expect(loaded.error.cause).toMatchObject({ name: "ZodError" }); + const projected = projectAnalysisError(loaded.error); + expect(projected).toMatchObject({ + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + retryable: false, + details: { reason: expect.stringContaining(`at ${field}:`) }, + }); + expect(projected).not.toHaveProperty("cause"); + expect(projected).not.toHaveProperty("stack"); + expect(await readFile(path, "utf8")).toBe(encoded); + }, + ); + it.each(["[]", "null", "5", '"x"', "true"])( "classifies a readable %s root as invalid content, not an I/O failure", async (document) => { @@ -72,6 +117,29 @@ describe.each([ }); }); +it("retains serialization schema diagnostics without creating a snapshot file", async () => { + const root = await createTestTempDirectory("rea-snapshot-write-diagnostic-"); + const path = join(root, "snapshot.json"); + const snapshot = { + target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), + binding: snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE), + entries: [], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([]), + }; + Reflect.deleteProperty(snapshot, "binding"); + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot(snapshot, path, false, resources), + ); + if (written.ok) throw new Error("Expected invalid snapshot refusal"); + expect(written.error.cause).toMatchObject({ name: "ZodError" }); + expect(projectAnalysisError(written.error)).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { reason: expect.stringContaining("at binding:") }, + }); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); +}); + it("keeps file-selection failures distinct from invalid snapshot contents", async () => { const root = await createTestTempDirectory("rea-snapshot-selection-"); for (const [path, reason] of [