diff --git a/docs/cli.md b/docs/cli.md index c320900c67..85cb8029b1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -442,6 +442,11 @@ redirected, which keeps large results smaller for agents and scripts; pipe through `jq .` for indented files. Output selection and formatting do not change operation status. +`--filter-output` selects fields from successful command data. Classified failures +retain their complete typed diagnostic projection even when the selector matches +a field such as `code` or `message`, or names a missing field. `--full-output` +controls whether the outer `{ok, error, meta}` envelope is included. + For `cutter-command`, `--json-output` requests command-specific JSON from Cutter when available. This is separate from `--format json`, which selects the CLI result representation; the global `--json` flag selects that same CLI diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 93cb3e5d34..5d55041d0f 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -219,9 +219,6 @@ export const createCliResultOutput = ( value: unknown, metadata: CliResultOutputMetadata, ): Promise { - const failure = options.fullOutput - ? cliOperationFailurePayload(value) - : undefined; completed = { metadata, originalFailure: cliOperationFailureIdentity(value), @@ -229,19 +226,30 @@ export const createCliResultOutput = ( const filterPaths = options.filterOutput ? Filter.parse(options.filterOutput) : undefined; + const operationFailure = + options.fullOutput || filterPaths !== undefined + ? cliOperationFailurePayload(value) + : undefined; + const failure = options.fullOutput ? operationFailure : undefined; const filtered: unknown = - filterPaths === undefined ? value : Filter.apply(value, filterPaths); + operationFailure ?? + (filterPaths === undefined ? value : Filter.apply(value, filterPaths)); if ( + operationFailure === undefined && filterPaths !== undefined && filterPaths.length > 0 && (format === "json" || format === "jsonl") && Array.isArray(filtered) ) normalizeFilteredJsonArrays(filtered); - // Leave successful YAML and bare YAML rendering to Incur. The custom - // boundary is needed only for a full failure envelope, whose error - // sentinel otherwise drops category, remediation, and details. - if (format !== "json" && format !== "jsonl" && failure === undefined) + // Leave successful non-JSON rendering to Incur. This owner handles typed + // failures when full output is requested or a data filter would erase the + // failure projection. + if ( + format !== "json" && + format !== "jsonl" && + operationFailure === undefined + ) return false; if (options.tokenCount) { // Incur/tokenx materialize JSON. Measure its UTF-16 expansion before @@ -322,7 +330,7 @@ export const createCliResultOutput = ( // Once writing starts, a failed destination must not receive a second // document from Incur's error formatter. const failureText = - failure !== undefined && format !== "json" && format !== "jsonl" + operationFailure !== undefined && format !== "json" && format !== "jsonl" ? Formatter.format(document, format) : undefined; handled = true; diff --git a/tests/boundary/cli/filteredFailureOutput.test.ts b/tests/boundary/cli/filteredFailureOutput.test.ts new file mode 100644 index 0000000000..36d4b1699c --- /dev/null +++ b/tests/boundary/cli/filteredFailureOutput.test.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto"; +import { Writable } from "node:stream"; + +import { Cli, z } from "incur"; +import { parse as parseYaml } from "yaml"; +import { expect, it } from "vitest"; + +import { + createCliResultOutput, + streamCliCommandResults, +} from "../../../src/cli/cliResultOutput.js"; +import { parseCliJsonInput } from "../../../src/cliJsonInput.js"; +import { logCliCommand } from "../../../src/cliLogging.js"; +import { silentLogger } from "../../../src/logger.js"; + +const runCli = async (arguments_: readonly string[]) => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput(arguments_, destination); + if (output === undefined) throw new Error("Missing CLI result output owner"); + const cli = Cli.create("filtered-failure-test", { sync: false }); + cli.command("trace-application-feature", { + args: z.object({ path: z.string() }), + run: ({ args }) => + logCliCommand(silentLogger, "trace-application-feature", async () => { + const input = await parseCliJsonInput( + args.path, + "trace-application-feature", + ); + return input.ok ? input.value : input.error; + }), + }); + streamCliCommandResults(cli, output); + let fallback = ""; + await cli.serve([...arguments_], { + stdout: (text) => { + if (!output.handled) fallback += text; + }, + }); + return { + text: Buffer.concat(chunks).toString("utf8"), + fallback, + output, + }; +}; + +const expectMissingInputFailureText = (text: string, path: string): void => { + for (const value of [ + "invalid_request", + "invalid_input", + "Correct the listed arguments and retry.", + path, + "read-failed", + "ENOENT", + ]) + expect(text).toContain(value); +}; + +it.each(["json", "toon", "yaml", "md"] as const)( + "preserves the typed missing-file failure across filtered %s results", + async (format) => { + const formatArgs = format === "json" ? ["--json"] : ["--format", format]; + const missingPath = `./missing-evidence-${randomUUID()}.json`; + for (const filter of ["missing", "code", "message"]) { + const previousExitCode = process.exitCode; + process.exitCode = 0; + try { + const result = await runCli([ + "trace-application-feature", + missingPath, + ...formatArgs, + "--filter-output", + filter, + ]); + expect(process.exitCode).toBe(1); + expect(result.output.failed).toBe(false); + expect(result.fallback).toBe(""); + expect(result.output.handled).toBe(true); + if (format === "json") { + expect(JSON.parse(result.text)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + retryable: true, + remediation: { + action: "Correct the listed arguments and retry.", + }, + details: { + input_path: missingPath, + input_reason: "read-failed", + issues: [ + expect.objectContaining({ + message: expect.stringContaining("ENOENT"), + }), + ], + }, + }); + } else expectMissingInputFailureText(result.text, missingPath); + } finally { + process.exitCode = previousExitCode; + } + } + + const previousExitCode = process.exitCode; + process.exitCode = 0; + try { + const result = await runCli([ + "trace-application-feature", + missingPath, + ...formatArgs, + "--filter-output", + "missing", + "--full-output", + ]); + expect(process.exitCode).toBe(1); + expect(result.fallback).toBe(""); + if (format === "json" || format === "yaml") { + const document = + format === "json" ? JSON.parse(result.text) : parseYaml(result.text); + expect(document).toMatchObject({ + ok: false, + error: { + code: "invalid_request", + category: "invalid_input", + retryable: true, + details: { + input_path: missingPath, + input_reason: "read-failed", + }, + }, + meta: { command: "trace-application-feature" }, + }); + } else { + if (format === "md") { + expect(result.text).toContain("## ok\n\nfalse"); + expect(result.text).toContain("## error.code"); + } else expect(result.text).toContain("ok: false"); + expectMissingInputFailureText(result.text, missingPath); + } + } finally { + process.exitCode = previousExitCode; + } + }, +); + +it("keeps successful scalar filtering available", async () => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const argv = ["open-control", "--json", "--filter-output", "open"]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) throw new Error("Missing CLI result output owner"); + const cli = Cli.create("filtered-failure-test", { sync: false }); + cli.command("open-control", { run: () => ({ open: false }) }); + streamCliCommandResults(cli, output); + const previousExitCode = process.exitCode; + process.exitCode = 0; + try { + await cli.serve(argv); + expect(Buffer.concat(chunks).toString("utf8")).toBe("false\n"); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + } +}); diff --git a/tests/boundary/cli/projectedErrorStatus.test.ts b/tests/boundary/cli/projectedErrorStatus.test.ts index b93a7aa6cb..ca3777635f 100644 --- a/tests/boundary/cli/projectedErrorStatus.test.ts +++ b/tests/boundary/cli/projectedErrorStatus.test.ts @@ -22,4 +22,29 @@ describe("projected CLI failures", () => { expect(result.json).toBeDefined(); }, ); + + cliTest( + "preserves failure identity when output selectors match or miss", + async ({ cli }) => { + for (const format of ["json", "toon", "yaml", "md"]) + for (const filter of ["missing", "code", "message"]) { + const result = await cli.run({ + arguments: [ + "import-managed-reconstruction", + "{}", + "--format", + format, + "--filter-output", + filter, + ], + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("invalid_request"); + expect(result.stdout).toContain("remediation"); + if (format === "json") + expect(result.json).toMatchObject({ code: "invalid_request" }); + } + }, + 30_000, + ); });