From 570c40cde55d31e455ef9bc484b7b908658ded58 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:33:27 +0800 Subject: [PATCH] fix(cli): preserve failure semantics across full-output formats --- package-lock.json | 1 + package.json | 1 + scripts/bundle-runtime.mjs | 2 +- scripts/prepack.mjs | 2 +- scripts/rea.mjs | 8 +- scripts/verify/javascript/output.mjs | 4 +- src/cli.ts | 2 +- ...reamedJsonOutput.ts => cliResultOutput.ts} | 155 ++++++--- src/cli/jsonOutput.ts | 7 +- src/cliLogging.ts | 76 ++++- src/cliOutput.ts | 13 +- tests/boundary/cli/dispatcher.test.ts | 4 +- tests/boundary/cli/jsonOutput.test.ts | 295 +++++++++++++++++- tests/boundary/process/runtimeBundle.test.ts | 6 +- 14 files changed, 494 insertions(+), 82 deletions(-) rename src/cli/{streamedJsonOutput.ts => cliResultOutput.ts} (53%) diff --git a/package-lock.json b/package-lock.json index 749f4614f..5af71e98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "smol-toml": "1.9.0", "stream-chain": "4.2.6", "stream-json": "3.7.0", + "tokenx": "1.3.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", "yaml": "2.9.0", diff --git a/package.json b/package.json index 8a58184c8..5635c6d3c 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,7 @@ "smol-toml": "1.9.0", "stream-chain": "4.2.6", "stream-json": "3.7.0", + "tokenx": "1.3.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", "yaml": "2.9.0", diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index bac18ed2c..6b6b45140 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -24,7 +24,7 @@ export const bundleRuntime = async (root) => { { cli: "cli.js", cliOutput: "cliOutput.js", - streamedJsonOutput: "cli/streamedJsonOutput.js", + cliResultOutput: "cli/cliResultOutput.js", mcpDoctor: "mcpDoctor.js", }, ], diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index 9d0958d8e..edf3072e5 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -49,7 +49,7 @@ if (await pathExists(join(root, "src"))) { "dist/mcp/main.js", "dist/cli-runtime/cli.js", "dist/cli-runtime/cliOutput.js", - "dist/cli-runtime/streamedJsonOutput.js", + "dist/cli-runtime/cliResultOutput.js", "dist/cli-runtime/mcpDoctor.js", "dist/mcpDoctor.js", "dist/cli.js", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index d3380c9c2..78ffbdd21 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -37,7 +37,7 @@ const runtimeFiles = isMcpMode : [ "dist/cli-runtime/cli.js", "dist/cli-runtime/cliOutput.js", - "dist/cli-runtime/streamedJsonOutput.js", + "dist/cli-runtime/cliResultOutput.js", ]; if (!(await compiledRuntimeExists(runtimeFiles))) { @@ -60,8 +60,8 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { } else { const { cliConfigurationError, createCli } = await import("../dist/cli-runtime/cli.js"); - const { createStreamedCliJsonOutput } = - await import("../dist/cli-runtime/streamedJsonOutput.js"); + const { createCliResultOutput } = + await import("../dist/cli-runtime/cliResultOutput.js"); const { renderCliOutputArgumentError, renderEmptyFilteredCliOutput, @@ -77,7 +77,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exitCode = 1; } else { let wroteOutput = false; - const resultOutput = createStreamedCliJsonOutput(args, process.stdout); + const resultOutput = createCliResultOutput(args, process.stdout); await createCli(process.env, resultOutput).serve(args, { stdout: (output) => { if (resultOutput?.handled) { diff --git a/scripts/verify/javascript/output.mjs b/scripts/verify/javascript/output.mjs index dbdfdc340..e9bc9e588 100644 --- a/scripts/verify/javascript/output.mjs +++ b/scripts/verify/javascript/output.mjs @@ -10,7 +10,7 @@ import { finished } from "node:stream/promises"; import { Cli } from "incur"; -import { createStreamedCliJsonOutput } from "../../../dist/cli/streamedJsonOutput.js"; +import { createCliResultOutput } from "../../../dist/cli/cliResultOutput.js"; import { createEvidence } from "../../../dist/domain/evidence.js"; // This opt-in format-boundary check writes one temporary file larger than the @@ -79,7 +79,7 @@ try { destination = createWriteStream(path, { flags: "wx", mode: 0o600 }); const argv = [metadata.command, "--format", format, "--full-output"]; - const output = createStreamedCliJsonOutput(argv, destination); + const output = createCliResultOutput(argv, destination); assert.ok(output); let unexpectedOutputBytes = 0; let exitCode = 0; diff --git a/src/cli.ts b/src/cli.ts index cfb83b71e..238f8387d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,7 +44,7 @@ import type { CliInstance } from "./cli/types.js"; import { streamCliCommandResults, type CliResultOutput, -} from "./cli/streamedJsonOutput.js"; +} from "./cli/cliResultOutput.js"; /** Describe CLI settings that are invalid before any command runs, as MCP does. */ export const cliConfigurationError = ( diff --git a/src/cli/streamedJsonOutput.ts b/src/cli/cliResultOutput.ts similarity index 53% rename from src/cli/streamedJsonOutput.ts rename to src/cli/cliResultOutput.ts index 9d21eec0f..67e745b0b 100644 --- a/src/cli/streamedJsonOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -1,15 +1,24 @@ import { constants } from "node:buffer"; import type { Writable } from "node:stream"; -import { Cli, Filter } from "incur"; +import { Cli, Filter, Formatter } from "incur"; +import { estimateTokenCount, sliceByTokens } from "tokenx"; -import { parseCliOutputArguments } from "../cliOutput.js"; +import { + parseCliOutputArguments, + validateCliOutputArguments, +} from "../cliOutput.js"; +import { cliOperationFailurePayload } from "../cliLogging.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError, type AnalysisErrorProjection, } from "../domain/analysisErrorProjection.js"; -import { jsonOutputParts, writeJsonOutput } from "./jsonOutput.js"; +import { + jsonOutputParts, + writeJsonOutput, + writeOutputText, +} from "./jsonOutput.js"; import type { CliInstance } from "./types.js"; /** Result metadata supplied by the command that actually ran. */ @@ -100,19 +109,15 @@ const wrapCommand = ( }; }; -/** Preserve Incur's JSON controls while streaming the complete result. */ -export const createStreamedCliJsonOutput = ( +/** Preserve Incur output controls and stream JSON while framing failures. */ +export const createCliResultOutput = ( arguments_: readonly string[], destination: Writable, ): CliResultOutput | undefined => { const options = parseCliOutputArguments(arguments_); - if ( - options.parseError || - options.tokenWindow || - (options.format !== "json" && options.format !== "jsonl") - ) + if (options.parseError || !validateCliOutputArguments(arguments_).ok) return undefined; - const format = options.format; + const format = options.format as "json" | "jsonl" | "yaml" | "toon" | "md"; let handled = false; let failed = false; let error: AnalysisErrorProjection | undefined; @@ -128,27 +133,53 @@ export const createStreamedCliJsonOutput = ( }, async write(value, metadata) { if (metadata.format !== format) return false; + const failure = options.fullOutput + ? cliOperationFailurePayload(value) + : undefined; const filtered: unknown = options.filterOutput ? Filter.apply(value, Filter.parse(options.filterOutput)) : value; - const meta = { - command: metadata.command, - duration: metadata.duration, - }; + // 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) + return false; if (options.tokenCount) { - // Incur/tokenx materialize the formatted value. Measure its UTF-16 - // expansion before allowing that allocation, independently of UTF-8 bytes. + // Incur/tokenx materialize JSON. Measure its UTF-16 expansion before + // allowing that allocation, independently of UTF-8 bytes. Other formats + // retain Incur's materializing behavior. + const tokenValue = failure ?? filtered; let characters = 0; if ( - filtered !== undefined && - filtered !== null && - typeof filtered !== "function" && - typeof filtered !== "symbol" - ) - for (const part of jsonOutputParts(filtered, format)) - characters += part.length; - characters = Math.max(0, characters - 1); - if (characters <= constants.MAX_STRING_LENGTH) return false; + tokenValue !== undefined && + tokenValue !== null && + typeof tokenValue !== "function" && + typeof tokenValue !== "symbol" + ) { + if (format === "json" || format === "jsonl") { + for (const part of jsonOutputParts( + tokenValue, + format, + format === "json", + )) + characters += part.length; + characters = Math.max(0, characters - 1); + } + } + if (characters <= constants.MAX_STRING_LENGTH) { + if (failure === undefined) return false; + handled = true; + try { + await writeOutputText( + destination, + `${estimateTokenCount(Formatter.format(failure, format))}\n`, + ); + return true; + } catch (cause: unknown) { + failed = true; + throw cause; + } + } error = projectAnalysisError( new AnalysisResourceConstraintError( metadata.command, @@ -165,10 +196,26 @@ export const createStreamedCliJsonOutput = ( ), ); } + const meta = { + command: metadata.command, + duration: metadata.duration, + }; + const errorDocument = + options.fullOutput && failure !== undefined + ? fullFailureEnvelope( + failure, + meta, + format, + options.tokenLimit, + options.tokenOffset, + ) + : undefined; const document = options.fullOutput - ? filtered === undefined - ? { ok: true, meta } - : { ok: true, data: filtered, meta } + ? failure !== undefined + ? errorDocument + : filtered === undefined + ? { ok: true, meta } + : { ok: true, data: filtered, meta } : filtered === undefined || filtered === null || typeof filtered === "function" || @@ -179,15 +226,24 @@ export const createStreamedCliJsonOutput = ( // document from Incur's error formatter. handled = true; try { - await writeJsonOutput( - error === undefined - ? document - : options.fullOutput - ? { ok: false, error, meta } - : error, - destination, - format, - ); + if (failure !== undefined && format !== "json" && format !== "jsonl") { + // Incur's error sentinel only carries code/message/retryable. Render + // the full caller-visible failure at this output boundary. + await writeOutputText( + destination, + `${Formatter.format(document, format)}\n`, + ); + } else { + await writeJsonOutput( + error === undefined + ? document + : options.fullOutput + ? { ok: false, error, meta } + : error, + destination, + format as "json" | "jsonl", + ); + } } catch (cause: unknown) { failed = true; throw cause; @@ -196,3 +252,26 @@ export const createStreamedCliJsonOutput = ( }, }; }; + +const fullFailureEnvelope = ( + failure: unknown, + meta: Readonly>, + format: "json" | "jsonl" | "yaml" | "toon" | "md", + tokenLimit: number | undefined, + tokenOffset: number | undefined, +): Readonly> => { + if (tokenLimit === undefined && tokenOffset === undefined) + return { ok: false, error: failure, meta }; + const formatted = Formatter.format(failure, format); + const total = estimateTokenCount(formatted); + const offset = tokenOffset ?? 0; + const end = tokenLimit === undefined ? total : offset + tokenLimit; + if (offset === 0 && end >= total) return { ok: false, error: failure, meta }; + const actualEnd = Math.min(end, total); + const nextOffset = actualEnd < total ? actualEnd : undefined; + return { + ok: false, + error: `${sliceByTokens(formatted, offset, end)}\n[truncated: showing tokens ${offset}–${actualEnd} of ${total}]`, + meta: { ...meta, ...(nextOffset === undefined ? {} : { nextOffset }) }, + }; +}; diff --git a/src/cli/jsonOutput.ts b/src/cli/jsonOutput.ts index f7422a7bf..f3b2bcaa0 100644 --- a/src/cli/jsonOutput.ts +++ b/src/cli/jsonOutput.ts @@ -19,13 +19,16 @@ export const writeJsonOutput = async ( for (const part of bufferedJsonParts( jsonOutputParts(value, format, isTerminal(destination)), )) - await writePart(destination, part); + await writeOutputText(destination, part); }; const isTerminal = (destination: Writable): boolean => "isTTY" in destination && destination.isTTY === true; -const writePart = (destination: Writable, part: string): Promise => +export const writeOutputText = ( + destination: Writable, + part: string, +): Promise => new Promise((resolve, reject) => { const onError = (cause: unknown): void => reject(cause); destination.once("error", onError); diff --git a/src/cliLogging.ts b/src/cliLogging.ts index c74c43ab4..3a47dd257 100644 --- a/src/cliLogging.ts +++ b/src/cliLogging.ts @@ -12,20 +12,64 @@ import type { Logger } from "pino"; const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -/** Identify caller-visible CLI outcomes that mean the requested operation failed. */ -export const isCliOperationFailure = (value: unknown): boolean => { - if (!isRecord(value)) return false; - if ( - isRecord(value.error) && - analysisErrorProjectionSchema.safeParse(value.error).success - ) - return true; - if (analysisCliErrorEnvelopeSchema.safeParse(value).success) return true; - if (analysisErrorProjectionSchema.safeParse(value).success) return true; - if (value.healthy === false) return true; - return false; +const loggedFailures = new WeakMap(); + +/** Return the canonical failure projection owned by the CLI result boundary. */ +const projectCliOperationFailure = ( + value: unknown, +): AnalysisErrorProjection | undefined => { + if (!isRecord(value)) return undefined; + const direct = analysisErrorProjectionSchema.safeParse(value); + if (direct.success) return value as AnalysisErrorProjection; + const envelope = analysisCliErrorEnvelopeSchema.safeParse(value); + if (envelope.success) { + const { + error: _label, + input_path: inputPath, + input_reason: inputReason, + ...projection + } = value; + const parsed = projection as AnalysisErrorProjection; + if (typeof inputPath !== "string" && typeof inputReason !== "string") + return parsed; + return { + ...parsed, + details: { + ...parsed.details, + ...(typeof inputPath === "string" ? { input_path: inputPath } : {}), + ...(typeof inputReason === "string" + ? { input_reason: inputReason } + : {}), + }, + }; + } + if (isRecord(value.error)) { + const nested = analysisErrorProjectionSchema.safeParse(value.error); + if (nested.success) return value.error as AnalysisErrorProjection; + } + return undefined; }; +/** Return the canonical projection or original record classified as failed. */ +const inferCliOperationFailure = (value: unknown): unknown | undefined => { + const projection = projectCliOperationFailure(value); + if (projection !== undefined) return projection; + if (isRecord(value) && value.healthy === false) return value; + return undefined; +}; + +export const cliOperationFailurePayload = ( + value: unknown, +): unknown | undefined => { + if (isRecord(value) && loggedFailures.has(value)) + return loggedFailures.get(value); + return inferCliOperationFailure(value); +}; + +/** Identify caller-visible CLI outcomes that mean the requested operation failed. */ +export const isCliOperationFailure = (value: unknown): boolean => + cliOperationFailurePayload(value) !== undefined; + /** Log one CLI command with duration and a stable success or failure status. */ export const logCliCommand = async ( logger: Logger, @@ -36,7 +80,13 @@ export const logCliCommand = async ( const startedAt = performance.now(); try { const value = await execute(); - const failed = isCommandFailure?.(value) ?? isCliOperationFailure(value); + const inferredFailure = inferCliOperationFailure(value); + const failed = isCommandFailure?.(value) ?? inferredFailure !== undefined; + if (isRecord(value)) + loggedFailures.set( + value, + failed ? (inferredFailure ?? value) : undefined, + ); logger[failed ? "error" : "info"]( { command, diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 7093a187c..5119eebc3 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -20,6 +20,8 @@ interface ParsedCliOutputArguments { readonly parseError: boolean; readonly tokenCount: boolean; readonly tokenWindow: boolean; + readonly tokenLimit: number | undefined; + readonly tokenOffset: number | undefined; } /** Read effective Incur output controls at the executable boundary. */ @@ -30,14 +32,18 @@ export const parseCliOutputArguments = ( let filterOutput: string | undefined; let fullOutput = false; let tokenCount = false; - let tokenWindow = false; + let tokenLimit: number | undefined; + let tokenOffset: number | undefined; const parsed = (parseError: boolean): ParsedCliOutputArguments => ({ filterOutput, format, fullOutput, parseError, tokenCount, - tokenWindow: parseError ? false : tokenWindow, + tokenWindow: + !parseError && (tokenLimit !== undefined || tokenOffset !== undefined), + tokenLimit: parseError ? undefined : tokenLimit, + tokenOffset: parseError ? undefined : tokenOffset, }); for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; @@ -71,7 +77,8 @@ export const parseCliOutputArguments = ( const numericValue = Number(value); if (!Number.isFinite(numericValue) || value.trim() === "") return parsed(true); - tokenWindow = true; + if (argument === "--token-limit") tokenLimit = numericValue; + else tokenOffset = numericValue; index += 1; } continue; diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index efd6ca414..e1cd84a0e 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -32,8 +32,8 @@ describe("executable dispatcher", () => { "export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", ), workspace.write( - "dist/cli-runtime/streamedJsonOutput.js", - "export const createStreamedCliJsonOutput = () => undefined;\n", + "dist/cli-runtime/cliResultOutput.js", + "export const createCliResultOutput = () => undefined;\n", ), workspace.write( "dist/cli-runtime/mcpDoctor.js", diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index fd03f0e92..5e8e86e7f 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -2,15 +2,19 @@ import { constants } from "node:buffer"; import { createHash } from "node:crypto"; import { Writable } from "node:stream"; -import { Cli } from "incur"; +import { Cli, Formatter } from "incur"; +import { estimateTokenCount } from "tokenx"; +import { parse as parseYaml } from "yaml"; import { describe, expect, it } from "vitest"; import { writeJsonOutput } from "../../../src/cli/jsonOutput.js"; import { - createStreamedCliJsonOutput, + createCliResultOutput, streamCliCommandResults, -} from "../../../src/cli/streamedJsonOutput.js"; +} from "../../../src/cli/cliResultOutput.js"; +import { logCliCommand } from "../../../src/cliLogging.js"; import { registerManagedCommands } from "../../../src/cli/managedCommands.js"; +import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; import { silentLogger } from "../../../src/logger.js"; const collect = async ( @@ -157,16 +161,283 @@ describe("incremental CLI JSON", () => { ); }); -describe("streamed CLI result surface", () => { +describe("CLI result output surface", () => { + it.each(["json", "yaml", "toon", "md"] as const)( + "places typed analysis failures in the full-output error envelope (%s)", + async (format) => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const failure = { + error: "Analysis failed", + code: "target_unavailable", + category: "unavailable", + message: "The selected artifact is missing.", + retryable: false, + remediation: { action: "Check that the path exists." }, + details: { source: "artifact-open" }, + input_path: "missing.dll", + input_reason: "read-failed", + }; + const argv = [ + "inspect-managed-artifact", + "missing.dll", + "--format", + format, + "--full-output", + "--filter-output", + "message", + ]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) + throw new Error("Missing structured output surface"); + const cli = Cli.create("rea-output-test", { sync: false }); + registerManagedCommands(cli, silentLogger, async () => failure); + streamCliCommandResults(cli, output); + let fallback = ""; + const previousExitCode = process.exitCode; + try { + await cli.serve(argv, { + stdout: (text) => { + if (!output.handled) fallback += text; + }, + }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + + const text = Buffer.concat(chunks).toString("utf8"); + expect(output.handled).toBe(true); + expect(fallback).toBe(""); + if (format === "json" || format === "yaml") { + const document = format === "json" ? JSON.parse(text) : parseYaml(text); + expect(document).toMatchObject({ + ok: false, + error: { + code: failure.code, + category: failure.category, + message: failure.message, + retryable: false, + remediation: failure.remediation, + details: { + ...failure.details, + input_path: failure.input_path, + input_reason: failure.input_reason, + }, + }, + meta: { command: "inspect-managed-artifact" }, + }); + expect(() => + analysisErrorProjectionSchema.parse(document.error), + ).not.toThrow(); + expect(document).not.toHaveProperty("data"); + } else { + expect(text).toContain("false"); + expect(text).toContain(failure.code); + expect(text).not.toContain("true"); + } + }, + ); + + it.each([ + { + name: "custom command failure", + value: { + status: "failed", + message: "Update was not applied.", + remediation: "Check the package manager, then retry.", + details: { stage: "install" }, + }, + isFailure: () => true, + }, + { + name: "unhealthy doctor result", + value: { healthy: false, checks: [{ name: "provider", ok: false }] }, + isFailure: undefined, + }, + ])("preserves $name as a full-output error payload", async (scenario) => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const argv = ["fixture", "--json", "--full-output"]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) throw new Error("Missing JSON output surface"); + const cli = Cli.create("rea-output-test", { sync: false }); + cli.command("fixture", { + run: () => + logCliCommand( + silentLogger, + "fixture", + async () => scenario.value, + scenario.isFailure, + ), + }); + streamCliCommandResults(cli, output); + const previousExitCode = process.exitCode; + try { + await cli.serve(argv, { stdout: () => undefined }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toMatchObject({ + ok: false, + error: scenario.value, + }); + }); +}); + +describe("full-output token windows", () => { + it("preserves Incur token-window metadata when truncating a TOON failure", async () => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const argv = [ + "inspect-managed-artifact", + "missing.dll", + "--format", + "toon", + "--full-output", + "--token-limit", + "3", + ]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) throw new Error("Missing TOON output surface"); + const cli = Cli.create("rea-output-test", { sync: false }); + registerManagedCommands(cli, silentLogger, async () => ({ + code: "target_unavailable", + category: "unavailable", + message: "The selected artifact is missing.", + retryable: false, + remediation: { action: "Check that the path exists." }, + details: { source: "artifact-open" }, + })); + streamCliCommandResults(cli, output); + const previousExitCode = process.exitCode; + try { + await cli.serve(argv, { stdout: () => undefined }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + const text = Buffer.concat(chunks).toString("utf8"); + expect(text).toContain("[truncated: showing tokens 0–3 of"); + expect(text).toMatch(/nextOffset:?\s*3/); + expect(text).toContain("ok: false"); + }); +}); + +describe("logger classification handoff", () => { + it("honors an explicit success for data containing error-shaped fields", async () => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const result = { + error: { + code: "target_unavailable", + category: "unavailable", + message: "Source data describes a missing target.", + retryable: false, + remediation: { action: "Use the value as source data." }, + }, + source: "captured-metadata", + }; + const argv = ["fixture", "--json", "--full-output"]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) throw new Error("Missing JSON output surface"); + const cli = Cli.create("rea-output-test", { sync: false }); + cli.command("fixture", { + run: () => + logCliCommand( + silentLogger, + "fixture", + async () => result, + () => false, + ), + }); + streamCliCommandResults(cli, output); + const previousExitCode = process.exitCode; + try { + await cli.serve(argv, { stdout: () => undefined }); + expect(process.exitCode).toBe(previousExitCode); + } finally { + process.exitCode = previousExitCode; + } + expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toMatchObject({ + ok: true, + data: result, + }); + }); +}); + +describe("full-output token counts", () => { + it("counts the full failure payload before applying output filters", async () => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const failure = { + code: "target_unavailable", + category: "unavailable", + message: "The selected artifact is missing.", + retryable: false, + remediation: { action: "Check that the path exists." }, + details: { source: "artifact-open" }, + }; + const argv = [ + "inspect-managed-artifact", + "missing.dll", + "--json", + "--full-output", + "--token-count", + "--filter-output", + "message", + ]; + const output = createCliResultOutput(argv, destination); + if (output === undefined) throw new Error("Missing JSON output surface"); + const cli = Cli.create("rea-output-test", { sync: false }); + registerManagedCommands(cli, silentLogger, async () => failure); + streamCliCommandResults(cli, output); + const previousExitCode = process.exitCode; + try { + await cli.serve(argv, { stdout: () => undefined }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + expect(output.handled).toBe(true); + expect(Buffer.concat(chunks).toString("utf8")).toBe( + `${estimateTokenCount(Formatter.format(failure, "json"))}\n`, + ); + }); +}); + +describe("CLI result output surface", () => { it.each([ - [], - ["--format", "md"], ["--json", "--token-limit", "5"], ["--json", "--format", "invalid"], ])("delegates unsupported output controls %j", (...arguments_) => { - expect(createStreamedCliJsonOutput(arguments_, new Writable())).toBe( - undefined, - ); + expect(createCliResultOutput(arguments_, new Writable())).toBe(undefined); }); it("keeps ownership after a failed write so no second document is emitted", async () => { @@ -176,7 +447,7 @@ describe("streamed CLI result surface", () => { callback(cause); }, }); - const output = createStreamedCliJsonOutput(["--json"], destination); + const output = createCliResultOutput(["--json"], destination); if (output === undefined) throw new Error("Missing JSON output surface"); await expect( output.write( @@ -228,7 +499,7 @@ describe("streamed CLI result surface", () => { // A terminal destination exercises the indented encoding past the limit. Object.defineProperty(destination, "isTTY", { value: true }); const argv = ["inspect-managed-members", "fixture.dll", "--json"]; - const output = createStreamedCliJsonOutput(argv, destination); + const output = createCliResultOutput(argv, destination); if (output === undefined) throw new Error("Missing JSON output surface"); streamCliCommandResults(cli, output); let exitCode = 0; @@ -248,7 +519,7 @@ describe("streamed CLI result surface", () => { const chunks: Buffer[] = []; const tokenArgv = [...argv, "--token-count", "--full-output"]; - const tokenOutput = createStreamedCliJsonOutput( + const tokenOutput = createCliResultOutput( tokenArgv, new Writable({ write(chunk: Buffer, _encoding, callback) { diff --git a/tests/boundary/process/runtimeBundle.test.ts b/tests/boundary/process/runtimeBundle.test.ts index ab09521f3..a10beef12 100644 --- a/tests/boundary/process/runtimeBundle.test.ts +++ b/tests/boundary/process/runtimeBundle.test.ts @@ -50,7 +50,7 @@ export const lazyUrl = import.meta.url;`, 'export const generatedPackageMetadata = { version: "1.0.0" };', "dist/cli.js": 'export { facts, run } from "./main.js";', "dist/cliOutput.js": 'export { facts } from "./provider/probe λ.js";', - "dist/cli/streamedJsonOutput.js": + "dist/cli/cliResultOutput.js": 'export { facts } from "../provider/probe λ.js";', "dist/mcpDoctor.js": 'export { run } from "./main.js";', "dist/main.js": ` @@ -98,7 +98,7 @@ export const run = async () => { [ "--input-type=module", "--eval", - `const { facts } = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cli.js")).href)}); const output = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliOutput.js")).href)}); const streamed = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "streamedJsonOutput.js")).href)}); process.stdout.write(JSON.stringify(facts === output.facts && facts === streamed.facts));`, + `const { facts } = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cli.js")).href)}); const output = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliOutput.js")).href)}); const streamed = await import(${JSON.stringify(pathToFileURL(join(cliDirectory, "cliResultOutput.js")).href)}); process.stdout.write(JSON.stringify(facts === output.facts && facts === streamed.facts));`, ], { cwd: workspace.root }, ); @@ -136,7 +136,7 @@ export const run = async () => { await workspace.write("package.json", '{"type":"module"}'); for (const path of [ "dist/cliOutput.js", - "dist/cli/streamedJsonOutput.js", + "dist/cli/cliResultOutput.js", "dist/mcpDoctor.js", ]) await workspace.write(path, "export {};\n");