mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(evidence): preserve format-owned JSON recovery diagnostics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -193,6 +193,8 @@ const decodedJsonFileResult = <Value>(
|
||||
decoded.error.issues[0]?.message ?? decoded.error.message,
|
||||
),
|
||||
path,
|
||||
userMessage: decoded.error.userMessage,
|
||||
contentIssues: decoded.error.issues,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user