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>
This commit is contained in:
wunianze666-netizenandgensokyo-dev authored and Ysopwnable committed 2026-11-14 14:06:23 +01:00
1 parent 7471b07a90
commit 213fcc8ce1
4 files changed
+153 -4

No files matched your search

+6
View File
@@ -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)
@@ -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";
};
@@ -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);
},
);
@@ -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 [