fix(cli): preserve typed failures through output filters

This commit is contained in:
morluto committed 2026-10-20 19:26:08 +08:00
1 parent cec8f1e8f3
commit 4714c7eaa3
4 files changed
+220 -9

No files matched your search

+5
View File
@@ -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
+17 -9
View File
@@ -219,9 +219,6 @@ export const createCliResultOutput = (
value: unknown,
metadata: CliResultOutputMetadata,
): Promise<boolean> {
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;
@@ -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;
}
});
@@ -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,
);
});