From 3201f0538dc88e54bce00decf689bf9b5603bfd3 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Tue, 6 Oct 2026 03:02:22 -0700 Subject: [PATCH] fix(cli): fail when JSON input files cannot be parsed --- src/cliJsonInputStatus.test.ts | 94 +++++++++++ src/contracts/errorSchemas.ts | 11 +- tests/boundary/cli/jsonInputStatus.test.ts | 159 ++++++++++++++++++ .../process-global/cliJsonInputStatus.test.ts | 76 +++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/cliJsonInputStatus.test.ts create mode 100644 tests/boundary/cli/jsonInputStatus.test.ts create mode 100644 tests/process-global/cliJsonInputStatus.test.ts diff --git a/src/cliJsonInputStatus.test.ts b/src/cliJsonInputStatus.test.ts new file mode 100644 index 000000000..8f9238e00 --- /dev/null +++ b/src/cliJsonInputStatus.test.ts @@ -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); + }); +}); diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index 8f739d437..7da9c1916 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -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({ diff --git a/tests/boundary/cli/jsonInputStatus.test.ts b/tests/boundary/cli/jsonInputStatus.test.ts new file mode 100644 index 000000000..d772c58a2 --- /dev/null +++ b/tests/boundary/cli/jsonInputStatus.test.ts @@ -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); + }, + ); +}); diff --git a/tests/process-global/cliJsonInputStatus.test.ts b/tests/process-global/cliJsonInputStatus.test.ts new file mode 100644 index 000000000..582bb5946 --- /dev/null +++ b/tests/process-global/cliJsonInputStatus.test.ts @@ -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", + }); + }); +});