fix(cli): fail when JSON input files cannot be parsed

This commit is contained in:
Kaoru0822 2026-10-06 03:02:22 -07:00
parent af48fe8791
commit 3201f0538d
4 changed files with 338 additions and 2 deletions

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { isCliOperationFailure } from "./cliLogging.js";
import {
analysisCliErrorEnvelopeSchema,
analysisErrorProjectionSchema,
} from "./contracts/errorSchemas.js";
import { AnalysisInputError } from "./domain/analysisErrorCore.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
const projection = projectAnalysisError(new AnalysisInputError("test-input"));
const envelope = { error: "Application workflow failed", ...projection };
describe("CLI JSON input failure recognition", () => {
it.each([
{},
{ input_path: "input.json" },
{ input_reason: "invalid-json" },
{ input_path: "input.json", input_reason: "invalid-json" },
{ input_path: "missing.json", input_reason: "read-failed" },
{ input_path: "", input_reason: "read-failed" },
])("recognizes a complete envelope with metadata %j", (metadata) => {
const value = { ...envelope, ...metadata };
expect(analysisCliErrorEnvelopeSchema.parse(value)).toEqual(value);
expect(isCliOperationFailure(value)).toBe(true);
});
it.each([
{ input_path: null },
{ input_path: 1 },
{ input_path: ["input.json"] },
{ input_reason: null },
{ input_reason: 1 },
{ input_reason: "" },
{ input_reason: "unknown" },
{ input_reason: ["invalid-json"] },
{ unknown_field: "extra" },
{ input_path: "input.json", input_reason: "invalid-json", extra: true },
{ error: "" },
{ error: 1 },
{ retryable: "false" },
{ remediation: { action: "retry", extra: true } },
])("rejects malformed or unknown envelope fields %j", (changes) => {
const value = { ...envelope, ...changes };
expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe(false);
expect(isCliOperationFailure(value)).toBe(false);
});
it.each(["code", "category", "message", "retryable", "remediation"])(
"requires the complete canonical projection, including %s",
(field) => {
const value = Object.fromEntries(
Object.entries({ ...envelope, input_reason: "invalid-json" }).filter(
([key]) => key !== field,
),
);
expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe(
false,
);
expect(isCliOperationFailure(value)).toBe(false);
},
);
it.each([
{ error: "ordinary data" },
{ code: "invalid_request", message: "ordinary data" },
{
error: "ordinary data",
code: "invalid_request",
input_path: "input.json",
},
{ error: { code: "invalid_request" } },
{ result: envelope },
{ input_path: "input.json", input_reason: "invalid-json" },
null,
[],
])("keeps ordinary or incomplete error-like data successful: %j", (value) => {
expect(isCliOperationFailure(value)).toBe(false);
});
it("keeps CLI metadata out of the strict canonical error schema", () => {
expect(analysisErrorProjectionSchema.parse(projection)).toEqual(projection);
for (const metadata of [
{ error: "Application workflow failed" },
{ input_path: "input.json" },
{ input_reason: "invalid-json" },
{ extra: true },
])
expect(
analysisErrorProjectionSchema.safeParse({ ...projection, ...metadata })
.success,
).toBe(false);
});
});

View File

@ -60,14 +60,21 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
generic("execution_failure"),
]);
/** Legacy CLI envelope: a human label plus the canonical projected error fields. */
/** CLI label and input diagnostics surrounding the strict canonical error projection. */
export const analysisCliErrorEnvelopeSchema = z
.object({
error: z.string().min(1),
input_path: z.string().optional(),
input_reason: z.enum(["invalid-json", "read-failed"]).optional(),
})
.passthrough()
.superRefine((value, context) => {
const { error: _label, ...projection } = value;
const {
error: _label,
input_path: _inputPath,
input_reason: _inputReason,
...projection
} = value;
const parsed = analysisErrorProjectionSchema.safeParse(projection);
if (!parsed.success) {
context.addIssue({

View File

@ -0,0 +1,159 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { parseCliJsonInput } from "../../../src/cliJsonInput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const JSON_COMMANDS = [
["trace-application-feature", "trace-application-feature"],
["trace-javascript-semantics", "trace-javascript-semantics"],
["compare-application-versions", "compare-application-versions"],
["compare-source-to-bundle", "compare-source-to-bundle"],
["compare-javascript-export-shapes", "compare-javascript-export-shapes"],
[
"build-reconstruction-obligation-ledger",
"build-reconstruction-obligation-ledger",
],
["evaluate-reconstruction-coverage", "evaluate-reconstruction-coverage"],
["import-managed-reconstruction", "import-managed-reconstruction"],
["verify-managed-native-boundaries", "verify-managed-native-boundaries"],
["project-managed-application-graph", "project-managed-application-graph"],
["capture-browser-scenario", "capture_browser_scenario"],
["capture-electron-scenario", "capture_electron_scenario"],
["reconcile-javascript-runtime", "reconcile_javascript_runtime"],
] as const;
describe("compiled CLI JSON input failure status", () => {
for (const [command, operation] of JSON_COMMANDS)
for (const kind of ["inline", "file", "missing"] as const)
cliTest(
`${command} rejects malformed or unreadable ${kind} input before dispatch`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-cli-json-status-");
const input = kind === "inline" ? "{" : join(root, "input.json");
if (kind === "file") await writeFile(input, "{");
const parsed = await parseCliJsonInput(input, operation);
if (parsed.ok)
throw new Error("Expected JSON input to fail before dispatch");
const result = await cli.run({
arguments: [command, input, "--json"],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
expect(result.stdout).toBe(
`${JSON.stringify(parsed.error, null, 2)}\n`,
);
expect(result.json).toMatchObject({
code: "invalid_request",
category: "invalid_input",
...(kind === "missing" ? {} : { details: { operation } }),
...(kind === "inline"
? {}
: {
input_path: input,
input_reason:
kind === "file" ? "invalid-json" : "read-failed",
}),
});
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(1);
},
);
for (const [format, flags] of [
["JSON", ["--json"]],
["JSONL", ["--format", "jsonl"]],
["YAML", ["--format", "yaml"]],
["default", []],
] as const)
cliTest(
`keeps the failure status with ${format} output`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-cli-json-format-");
const input = join(root, "missing.json");
const result = await cli.run({
arguments: ["compare-javascript-export-shapes", input, ...flags],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
for (const diagnostic of [
"invalid_request",
"invalid_input",
input,
"read-failed",
])
expect(result.stdout).toContain(diagnostic);
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(1);
},
);
cliTest(
"rejects an empty file path without losing its diagnostic",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-cli-json-empty-");
const result = await cli.run({
arguments: ["compare-javascript-export-shapes", "", "--json"],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
expect(result.json).toMatchObject({
code: "invalid_request",
input_path: "",
input_reason: "read-failed",
});
expect(result.exitCode).toBe(1);
},
);
});
describe("compiled JSON parser and logger success seam", () => {
for (const kind of ["inline", "file"] as const)
cliTest(
`preserves valid ${kind} data and a zero exit status without running a workflow`,
async ({ processes }) => {
const root = await createTestTempDirectory("rea-cli-json-success-");
const value = {
error: "source data",
code: "invalid_request",
values: [null, false, 0],
};
const json = JSON.stringify(value);
const input = kind === "inline" ? json : join(root, "input.json");
if (kind === "file") await writeFile(input, json);
const result = await processes.run(
process.execPath,
[
"--input-type=module",
"-e",
`import { parseCliJsonInput } from "./dist/cliJsonInput.js";
import { logCliCommand } from "./dist/cliLogging.js";
import { silentLogger } from "./dist/logger.js";
const parsed = await parseCliJsonInput(process.argv[1], "test-input");
const output = await logCliCommand(silentLogger, "test-input", async () =>
parsed.ok ? parsed.value : parsed.error);
console.log(JSON.stringify(output));`,
input,
],
{ env: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root } },
);
expect(result.stdout).toBe(`${json}\n`);
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
},
);
});

View File

@ -0,0 +1,76 @@
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { parseCliJsonInput } from "../../src/cliJsonInput.js";
import { logCliCommand } from "../../src/cliLogging.js";
const originalExitCode = process.exitCode;
afterEach(() => {
process.exitCode = originalExitCode;
});
describe("CLI JSON input logging status", () => {
it.each([
{},
{ input_path: "input.json", input_reason: "invalid-json" },
{ input_path: "missing.json", input_reason: "read-failed" },
{ input_path: "", input_reason: "read-failed" },
])(
"preserves failure diagnostics and logs an error for %j",
async (metadata) => {
const parsed = await parseCliJsonInput("{", "test-input");
if (parsed.ok) throw new Error("Expected malformed inline JSON to fail");
const value = Object.assign({}, parsed.error, metadata);
const serialized = JSON.stringify(value);
const lines: string[] = [];
const logger = pino(
{ level: "info" },
{ write: (line) => lines.push(line) },
);
process.exitCode = undefined;
const output = await logCliCommand(
logger,
"test-input",
async () => value,
);
expect(output).toBe(value);
expect(JSON.stringify(output)).toBe(serialized);
expect(process.exitCode).toBe(1);
expect(lines).toHaveLength(1);
expect(JSON.parse(lines.join(""))).toMatchObject({
level: 50,
command: "test-input",
status: "error",
msg: "CLI command failed",
});
},
);
it("leaves ordinary parsed JSON successful and unchanged", async () => {
const parsed = await parseCliJsonInput(
'{"error":"source data","code":"invalid_request"}',
"test-input",
);
if (!parsed.ok) throw new Error("Expected valid JSON to parse");
const lines: string[] = [];
const logger = pino(
{ level: "info" },
{ write: (line) => lines.push(line) },
);
process.exitCode = undefined;
expect(
await logCliCommand(logger, "test-input", async () => parsed.value),
).toBe(parsed.value);
expect(process.exitCode).toBeUndefined();
expect(lines).toHaveLength(1);
expect(JSON.parse(lines.join(""))).toMatchObject({
level: 30,
status: "ok",
msg: "CLI command completed",
});
});
});