fix(cli): preserve typed failures through output filters
This commit is contained in:
1 parent
cec8f1e8f3
commit
4714c7eaa3
4 files changed
+220
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user