fix(evidence): preserve format-owned JSON recovery diagnostics

This commit is contained in:
morluto
2026-10-24 03:06:43 +08:00
parent 9becdfe8f4
commit 271fecf53a
7 changed files with 116 additions and 12 deletions
+5
View File
@@ -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
+2
View File
@@ -193,6 +193,8 @@ const decodedJsonFileResult = <Value>(
decoded.error.issues[0]?.message ?? decoded.error.message,
),
path,
userMessage: decoded.error.userMessage,
contentIssues: decoded.error.issues,
}),
);
};
+17 -10
View File
@@ -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 },
]);
+7 -2
View File
@@ -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);
};
@@ -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",
@@ -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],
@@ -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(