From 271fecf53add685ac16a28ad54dcb0a43a14521c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:06:43 +0800 Subject: [PATCH] fix(evidence): preserve format-owned JSON recovery diagnostics --- docs/tool-design.md | 5 +++ src/application/JsonFiles.ts | 2 ++ src/application/JsonInputFile.ts | 27 ++++++++------ src/application/Utf8JsonInput.ts | 9 +++-- .../cli/evidenceBundleCommands.test.ts | 24 +++++++++++++ .../filesystem/evidenceBundleFiles.test.ts | 35 +++++++++++++++++++ .../boundary/mcp/evidenceBundleInline.test.ts | 26 ++++++++++++++ 7 files changed, 116 insertions(+), 12 deletions(-) diff --git a/docs/tool-design.md b/docs/tool-design.md index 26e5e40098..bbe06a9ad0 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -245,6 +245,11 @@ filtering and paging; an item missing from that page is not proved absent from the target. Structured completeness and absence fields must agree with the stated limitations. +File-error wrappers retain the format owner's specific diagnostic and recovery +text. Strict JSON file input, for example, keeps the byte-order-mark explanation +and UTF-8 recovery through CLI/MCP projection rather than replacing it with +generic invalid-file advice. Consumers do not classify parser message text. + Update canonical contracts, output schemas, examples, generated artifacts, and relevant docs together. Verify converted advertised schemas and success, malformed, unavailable, cancellation, and partial outcomes through affected diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index a96b06ee69..583eb0d47b 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -193,6 +193,8 @@ const decodedJsonFileResult = ( decoded.error.issues[0]?.message ?? decoded.error.message, ), path, + userMessage: decoded.error.userMessage, + contentIssues: decoded.error.issues, }), ); }; diff --git a/src/application/JsonInputFile.ts b/src/application/JsonInputFile.ts index 86dcbc1901..41fc919179 100644 --- a/src/application/JsonInputFile.ts +++ b/src/application/JsonInputFile.ts @@ -457,7 +457,7 @@ const jsonTokenizers = ( // The pinned tokenizer reports grammar violations with plain Error; // native runtime failures keep their distinct types and identities. if (cause instanceof Error && cause.constructor === Error) - throw invalidJson(operation, cause.message, cause); + throw invalidJson(operation, cause.message, { cause }); throw cause; } }; @@ -669,7 +669,9 @@ async function* decodedChunks( if (leading && text.length > 0) { leading = false; if (text.startsWith("\uFEFF")) - throw invalidJson(operation, JSON_BYTE_ORDER_MARK_MESSAGE); + throw invalidJson(operation, JSON_BYTE_ORDER_MARK_MESSAGE, { + userMessage: JSON_BYTE_ORDER_MARK_MESSAGE, + }); } return text; }; @@ -685,7 +687,9 @@ async function* decodedChunks( "code" in cause && cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA" ) - throw invalidJson(operation, "JSON input is not valid UTF-8", cause); + throw invalidJson(operation, "JSON input is not valid UTF-8", { + cause, + }); throw cause; } }; @@ -756,16 +760,19 @@ const readNativeJson = async ( ); return parsed.ok ? ok(parsed.value) - : err(invalidJson(operation, parsed.error, parsed.cause)); + : err( + invalidJson(operation, parsed.error, { + cause: parsed.cause, + userMessage: parsed.userMessage, + }), + ); }; const invalidJson = ( operation: string, message: string, - cause?: unknown, + options?: { readonly cause?: unknown; readonly userMessage?: string }, ): AnalysisInputError => - new AnalysisInputError( - operation, - cause === undefined ? undefined : { cause }, - [{ path: [], reason: "invalid_format", expected: "JSON", message }], - ); + new AnalysisInputError(operation, options, [ + { path: [], reason: "invalid_format", expected: "JSON", message }, + ]); diff --git a/src/application/Utf8JsonInput.ts b/src/application/Utf8JsonInput.ts index 0e8ac8bb6f..7b0e5c0ef7 100644 --- a/src/application/Utf8JsonInput.ts +++ b/src/application/Utf8JsonInput.ts @@ -20,7 +20,7 @@ export const parseUtf8Json = ( operation: string, path: string, boundary = "json-input", -): SafeJsonParseResult => { +): SafeJsonParseResult & { readonly userMessage?: string } => { const stringLimitFailure = ( cause?: unknown, decodedUnits?: number, @@ -85,6 +85,11 @@ export const parseUtf8Json = ( }; } if (text.startsWith("\uFEFF")) - return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined }; + return { + ok: false, + error: JSON_BYTE_ORDER_MARK_MESSAGE, + cause: undefined, + userMessage: JSON_BYTE_ORDER_MARK_MESSAGE, + }; return safeParseJson(text); }; diff --git a/tests/boundary/cli/evidenceBundleCommands.test.ts b/tests/boundary/cli/evidenceBundleCommands.test.ts index 59e36cdb7a..64e5ec89ba 100644 --- a/tests/boundary/cli/evidenceBundleCommands.test.ts +++ b/tests/boundary/cli/evidenceBundleCommands.test.ts @@ -14,6 +14,7 @@ import { } from "../../../src/domain/residualUnknown.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; const revisionBundle = () => { const records = ["open", "investigate"].map((operation) => @@ -55,6 +56,29 @@ const revisionBundle = () => { return createEvidenceBundle(records, [initial, revised]); }; +cliTest( + "explains a bundle BOM with the format owner's recovery", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundle-bom-cli-"); + const path = join(root, "bundle.json"); + await writeFile(path, `\uFEFF${JSON.stringify(revisionBundle())}`); + const result = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path }), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "evidence_integrity_mismatch", + message: JSON_BYTE_ORDER_MARK_MESSAGE, + remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + }); + }, +); + cliTest.for([ "export_evidence_bundle", "compare_bundles", diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 9f4574ede7..c43d64aad1 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -29,6 +29,7 @@ import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js" import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { writeTextParts } from "../../../src/application/JsonFiles.js"; +import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js"; import { holdFirstFileRead } from "../../support/holdFirstFileRead.js"; import { JsonInputPhysicalReadFailure } from "../../../src/application/JsonInputFile.js"; @@ -48,6 +49,40 @@ const bundle = (result = true) => ), ]); +it.each([0, 9 * 1024 * 1024])( + "preserves the format owner's BOM recovery with %i bytes of padding", + async (padding) => { + const root = await createTestTempDirectory("rea-bundle-bom-"); + const path = join(root, "bundle.json"); + await writeFile( + path, + `\uFEFF${" ".repeat(padding)}${JSON.stringify(bundle())}`, + ); + const result = await readEvidenceBundle(path); + if (result.ok) throw new Error("Expected strict JSON BOM rejection"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "evidence_integrity_mismatch", + message: JSON_BYTE_ORDER_MARK_MESSAGE, + remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + details: { + path, + reason: "invalid-json", + content_issues: [ + { reason: "invalid_format", message: JSON_BYTE_ORDER_MARK_MESSAGE }, + ], + }, + }); + // Ordinary malformed JSON keeps its distinct parser diagnostic and the + // existing general recovery; consumers do not infer BOMs from text. + await writeFile(path, `${" ".repeat(padding)}{`); + const malformed = await readEvidenceBundle(path); + if (malformed.ok) throw new Error("Expected malformed JSON rejection"); + expect(projectAnalysisError(malformed.error).message).toContain( + "Repair or recreate", + ); + }, +); + it.each([ ["immutable", readImmutableEvidenceBundle], ["mutable", readEvidenceBundle], diff --git a/tests/boundary/mcp/evidenceBundleInline.test.ts b/tests/boundary/mcp/evidenceBundleInline.test.ts index ae07a1be55..a27d2af1b6 100644 --- a/tests/boundary/mcp/evidenceBundleInline.test.ts +++ b/tests/boundary/mcp/evidenceBundleInline.test.ts @@ -16,6 +16,32 @@ import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { createServer } from "../../../src/server/createServer.js"; +import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; + +it("preserves BOM recovery through public bundle import", async () => { + const root = await createTestTempDirectory("rea-bundle-bom-mcp-"); + const path = join(root, "bundle.json"); + await writeFile(path, "\uFEFF{}"); + const session = createTestBinarySession(() => { + throw new Error("bundle import must not start a provider"); + }); + const { client, server } = await connectEvidenceMcp("bundle-bom", session); + try { + const response = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "evidence_integrity_mismatch", + message: JSON_BYTE_ORDER_MARK_MESSAGE, + remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + }, + }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); it("returns the complete session Evidence bundle in the tool result", async () => { const evidence = createEvidence(