diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 0d64bd5524..4e310bf080 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -41,6 +41,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const { createCli } = await import("../dist/cli.js"); const { renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, sanitizeCliOutput, validateCliOutputArguments, } = await import("../dist/cliOutput.js"); @@ -49,9 +50,16 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.stdout.write(renderCliOutputArgumentError(outputArguments)); process.exitCode = 1; } else { + let wroteOutput = false; await createCli().serve(args, { - stdout: (output) => process.stdout.write(sanitizeCliOutput(output)), + stdout: (output) => { + const sanitized = sanitizeCliOutput(output); + if (sanitized.length > 0) wroteOutput = true; + process.stdout.write(sanitized); + }, }); + if (!wroteOutput) + process.stdout.write(renderEmptyFilteredCliOutput(args) ?? ""); } } diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 0d029337cc..96f65a7e7b 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -18,6 +18,12 @@ export type CliOutputArgumentValidation = type JsonRecord = Record; +interface ParsedCliOutputArguments { + readonly filterOutput: boolean; + readonly format: string; + readonly tokenWindow: boolean; +} + const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); @@ -28,16 +34,16 @@ const validationError = (value: JsonRecord): JsonRecord | undefined => { return undefined; }; -/** Reject text-window controls that would corrupt a structured document. */ -export const validateCliOutputArguments = ( +const parseCliOutputArguments = ( arguments_: readonly string[], -): CliOutputArgumentValidation => { +): ParsedCliOutputArguments => { let format: string = "toon"; + let filterOutput = false; let tokenWindow = false; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; - // Incur scans builtins across `--` and accepts only the spaced --format form. - // Mirror its effective format so malformed requests still emit complete JSON. + // Incur scans builtins across `--` and accepts only spaced value forms. + // Mirror those effective values at the executable output boundary. if (argument === "--json") { format = "json"; continue; @@ -50,6 +56,13 @@ export const validateCliOutputArguments = ( } continue; } + if (argument === "--filter-output") { + if (arguments_[index + 1] !== undefined) { + filterOutput = true; + index += 1; + } + continue; + } if (argument === "--token-limit" || argument === "--token-offset") { if (arguments_[index + 1] !== undefined) { tokenWindow = true; @@ -63,6 +76,14 @@ export const validateCliOutputArguments = ( ) tokenWindow = true; } + return { filterOutput, format, tokenWindow }; +}; + +/** Reject text-window controls that would corrupt a structured document. */ +export const validateCliOutputArguments = ( + arguments_: readonly string[], +): CliOutputArgumentValidation => { + const { format, tokenWindow } = parseCliOutputArguments(arguments_); if ( tokenWindow && (format === "json" || format === "jsonl" || format === "yaml") @@ -76,6 +97,19 @@ export const validateCliOutputArguments = ( return { ok: true }; }; +/** Preserve a complete document when Incur filters the entire result away. */ +export const renderEmptyFilteredCliOutput = ( + arguments_: readonly string[], +): string | undefined => { + const { filterOutput, format } = parseCliOutputArguments(arguments_); + if ( + filterOutput && + (format === "json" || format === "jsonl" || format === "yaml") + ) + return "{}\n"; + return undefined; +}; + /** Render one complete structured error before command execution. */ export const renderCliOutputArgumentError = ( error: Exclude, diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 4ca3d913d0..22fa1b0a65 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -8,6 +8,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import { renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, sanitizeCliOutput, validateCliOutputArguments, } from "../../../src/cliOutput.js"; @@ -140,9 +141,85 @@ describe("CLI output argument and sanitization boundary", () => { meta: { command: "analyze" }, }); }); + + it("renders an explicit empty projection for structured filtered output", () => { + for (const format of ["json", "jsonl", "yaml"]) + expect( + renderEmptyFilteredCliOutput([ + "providers", + "--format", + format, + "--filter-output", + "missing", + ]), + ).toBe("{}\n"); + expect( + renderEmptyFilteredCliOutput(["providers", "--filter-output", "missing"]), + ).toBeUndefined(); + expect( + renderEmptyFilteredCliOutput(["providers", "--format", "json"]), + ).toBeUndefined(); + }); }); describe("compiled CLI output boundary", () => { + workspaceCliTest( + "emits valid JSON when an output filter misses the top-level result", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "summary", + ], + }); + expect(result).toMatchObject({ + exitCode: 0, + stdout: "{}\n", + stderr: "", + json: {}, + }); + const nested = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "normalized_result.not_a_field", + ], + }); + expect(nested).toMatchObject({ + exitCode: 0, + stderr: "", + json: { normalized_result: {} }, + }); + const selected = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "normalized_result.summary", + ], + }); + expect(selected).toMatchObject({ + exitCode: 0, + stderr: "", + json: { + normalized_result: { + summary: expect.objectContaining({ browser_windows: 0 }), + }, + }, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + workspaceCliTest( "sanitizes a real missing-argument dispatcher failure", async ({ cli }) => { diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 7c0705405f..d94cb7ec75 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -27,7 +27,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cliOutput.js", - "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n", + "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", ), workspace.write( "dist/mcpDoctor.js",