* fix(cli): accept --flag=value for global output flags `rea <command> --format=json` failed with "Unknown flag: --format". `--filter-output=...`, `--token-limit=...` and `--token-offset=...` failed the same way. Incur reads these global flags only in spaced form, so the equals form fell through to the command parser. Command options already accept both forms, and `rea mcp doctor` parses `--format=` itself. A valued output flag with no value (`--format` last, or `--format=`) also reported the flag as unknown with code UNKNOWN. normalizeCliOutputArguments, in the module that owns output-argument interpretation, now splits the equals forms of the four valued output flags. The entry point passes the normalized argv to rea's output validation and to Incur, so both read the same effective values. A missing value now fails with MISSING_OUTPUT_VALUE and names the flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): detect missing output values while parsing output flags The missing-value check scanned argv again after parsing, so it also checked tokens the parser had already consumed as values. For example, `--filter-output --format` selects the field `--format`, yet the second scan reported `--format` as missing a value. parseCliOutputArguments now records the first valued flag it skips for lack of a value, in the same pass that consumes values. Cover normalizeCliOutputArguments directly: values containing `=`, empty values, and command options that only share a prefix. Export it in the dispatcher test's runtime stub as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { createPackageWithOptions } from "@electron/asar";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
|
|
|
import {
|
|
normalizeCliOutputArguments,
|
|
renderCliOutputArgumentError,
|
|
renderEmptyFilteredCliOutput,
|
|
validateCliOutputArguments,
|
|
} from "../../../src/cliOutput.js";
|
|
|
|
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
|
|
|
|
describe("CLI output argument boundary", () => {
|
|
it("rejects token windows that would corrupt structured output", () => {
|
|
for (const format of ["json", "jsonl", "yaml"] as const) {
|
|
const validation = validateCliOutputArguments([
|
|
"providers",
|
|
"--token-limit",
|
|
"5",
|
|
"--format",
|
|
format,
|
|
]);
|
|
expect(validation).toMatchObject({
|
|
ok: false,
|
|
format,
|
|
code: "UNSUPPORTED_OUTPUT_COMBINATION",
|
|
});
|
|
if (!validation.ok) {
|
|
const rendered = renderCliOutputArgumentError(validation);
|
|
expect(rendered).not.toContain("[truncated:");
|
|
if (format === "json" || format === "jsonl")
|
|
expect(JSON.parse(rendered)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
else
|
|
expect(rendered).toMatch(
|
|
/^ok: false\nerror:\n code: UNSUPPORTED_OUTPUT_COMBINATION\n/u,
|
|
);
|
|
}
|
|
}
|
|
expect(
|
|
validateCliOutputArguments([
|
|
"providers",
|
|
"--token-limit",
|
|
"5",
|
|
"--format",
|
|
"toon",
|
|
]),
|
|
).toEqual({ ok: true });
|
|
expect(
|
|
validateCliOutputArguments(["providers", "--token-count", "--json"]),
|
|
).toEqual({ ok: true });
|
|
});
|
|
});
|
|
|
|
describe("CLI output flag value forms", () => {
|
|
it("splits only the equals forms of valued output flags", () => {
|
|
expect(
|
|
normalizeCliOutputArguments([
|
|
"inspect-sqlite-database",
|
|
"--table=a=b",
|
|
"--format=json",
|
|
"--filter-output=a.b,c[0,1]",
|
|
"--token-limit=5",
|
|
"--token-offset=",
|
|
"--json",
|
|
"--format-hint=x",
|
|
]),
|
|
).toEqual([
|
|
"inspect-sqlite-database",
|
|
"--table=a=b",
|
|
"--format",
|
|
"json",
|
|
"--filter-output",
|
|
"a.b,c[0,1]",
|
|
"--token-limit",
|
|
"5",
|
|
"--token-offset",
|
|
"",
|
|
"--json",
|
|
"--format-hint=x",
|
|
]);
|
|
});
|
|
|
|
it("reports a missing value only for a flag the parser did not consume", () => {
|
|
expect(
|
|
validateCliOutputArguments([
|
|
"capabilities",
|
|
"--json",
|
|
"--filter-output",
|
|
"--format",
|
|
]),
|
|
).toEqual({ ok: true });
|
|
expect(
|
|
validateCliOutputArguments(["capabilities", "--json", "--format"]),
|
|
).toMatchObject({ ok: false, code: "MISSING_OUTPUT_VALUE" });
|
|
});
|
|
});
|
|
|
|
describe("unclosed CLI output filter slices", () => {
|
|
it.each([
|
|
"providers[",
|
|
"providers[0,1].name[",
|
|
"name,providers[",
|
|
"][,]",
|
|
"providers],name[0,1]",
|
|
])("rejects %s before the filter parser runs", (filter) => {
|
|
expect(
|
|
validateCliOutputArguments([
|
|
"providers",
|
|
"--json",
|
|
"--filter-output",
|
|
filter,
|
|
]),
|
|
).toMatchObject({ ok: false, code: "INVALID_OUTPUT_FILTER" });
|
|
});
|
|
|
|
it("preserves effective-filter and earlier flag-error precedence", () => {
|
|
for (const arguments_ of [
|
|
[
|
|
"--filter-output",
|
|
"providers[",
|
|
"--filter-output",
|
|
"capabilities[0,1].provider.id,open",
|
|
],
|
|
["--filter-output", "capabilities[-1,].provider.id"],
|
|
["--filter-output", "capabilities[[0,1]"],
|
|
["--format", "invalid", "--filter-output", "providers["],
|
|
["--token-limit", "invalid", "--filter-output", "providers["],
|
|
])
|
|
expect(validateCliOutputArguments(arguments_)).toEqual({ ok: true });
|
|
});
|
|
|
|
workspaceCliTest(
|
|
"fails promptly across output formats without invoking the command",
|
|
async ({ cli }) => {
|
|
for (const format of ["json", "jsonl", "yaml", "toon", "md"]) {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"analyze",
|
|
"/absent/target",
|
|
"--format",
|
|
format,
|
|
"--filter-output",
|
|
"result[0,1].name[",
|
|
],
|
|
timeoutMs: 3000,
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toContain("INVALID_OUTPUT_FILTER");
|
|
expect(result.stdout).toContain("closing ]");
|
|
expect(result.stderr).toBe("");
|
|
}
|
|
const splitPath = await cli.run({
|
|
arguments: ["providers", "--json", "--filter-output", "][,]"],
|
|
timeoutMs: 3000,
|
|
});
|
|
expect(splitPath.exitCode).toBe(1);
|
|
expect(splitPath.json).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_OUTPUT_FILTER" },
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"retains closed slices and multiple filter paths",
|
|
async ({ cli }) => {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"providers",
|
|
"--json",
|
|
"--filter-output",
|
|
"capabilities[0,1].provider.id,open",
|
|
],
|
|
timeoutMs: 3000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.json).toEqual({
|
|
capabilities: [{ provider: { id: expect.any(String) } }],
|
|
open: false,
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("CLI output valued-flag parsing", () => {
|
|
workspaceCliTest(
|
|
"preserves complete JSON errors when native builtin flags follow unusual arguments",
|
|
async ({ cli }) => {
|
|
for (const arguments_ of [
|
|
["providers", "--", "--json", "--token-limit", "5"],
|
|
["providers", "--format=json", "--token-limit=5"],
|
|
]) {
|
|
const result = await cli.run({ arguments: arguments_ });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).not.toContain("[truncated:");
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
}
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"fails before emitting a truncated JSON document",
|
|
async ({ cli }) => {
|
|
const result = await cli.run({
|
|
arguments: ["providers", "--token-limit", "5", "--json"],
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.json).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"names missing output values and honors equals-style output values",
|
|
async ({ cli }) => {
|
|
for (const [arguments_, flag] of [
|
|
[["capabilities", "--json", "--format", ""], "--format"],
|
|
[["capabilities", "--json", "--filter-output", ""], "--filter-output"],
|
|
[["capabilities", "--json", "--token-limit", ""], "--token-limit"],
|
|
[["capabilities", "--json", "--token-offset="], "--token-offset"],
|
|
[["capabilities", "--format"], "--format"],
|
|
] as const) {
|
|
const result = await cli.run({ arguments: arguments_ });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).not.toContain("[truncated:");
|
|
expect(result.json).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "MISSING_OUTPUT_VALUE",
|
|
message: expect.stringContaining(`${flag} requires a value`),
|
|
},
|
|
});
|
|
}
|
|
const equalsForms = await cli.run({
|
|
arguments: [
|
|
"capabilities",
|
|
"--format=json",
|
|
"--filter-output=capabilities[0,1]",
|
|
],
|
|
});
|
|
expect(equalsForms.exitCode).toBe(0);
|
|
expect(equalsForms.json).toMatchObject({
|
|
capabilities: [expect.any(Object)],
|
|
});
|
|
const equalsTokenWindow = await cli.run({
|
|
arguments: ["capabilities", "--json", "--token-limit=5"],
|
|
});
|
|
expect(equalsTokenWindow.exitCode).toBe(1);
|
|
expect(equalsTokenWindow.json).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
const validTokenWindow = await cli.run({
|
|
arguments: ["capabilities", "--json", "--token-limit", "5"],
|
|
});
|
|
expect(validTokenWindow.exitCode).toBe(1);
|
|
expect(validTokenWindow.json).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
const emptyFormatWithTokenWindow = await cli.run({
|
|
arguments: [
|
|
"capabilities",
|
|
"--json",
|
|
"--format",
|
|
"",
|
|
"--token-limit",
|
|
"5",
|
|
],
|
|
});
|
|
expect(emptyFormatWithTokenWindow.exitCode).toBe(1);
|
|
expect(emptyFormatWithTokenWindow.stdout).not.toContain("[truncated:");
|
|
expect(emptyFormatWithTokenWindow.json).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" },
|
|
});
|
|
|
|
for (const arguments_ of [
|
|
["capabilities", "--json", "--token-limit", "not-a-number"],
|
|
[
|
|
"capabilities",
|
|
"--format",
|
|
"unsupported-format",
|
|
"--json",
|
|
"--token-limit",
|
|
"5",
|
|
],
|
|
]) {
|
|
const result = await cli.run({ arguments: arguments_ });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).not.toContain("UNSUPPORTED_OUTPUT_COMBINATION");
|
|
expect(result.stdout).not.toContain("[truncated:");
|
|
}
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("CLI output argument boundary", () => {
|
|
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: expect.stringContaining('"rea_progress":'),
|
|
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: expect.stringContaining('"rea_progress":'),
|
|
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: expect.stringContaining('"rea_progress":'),
|
|
json: {
|
|
normalized_result: {
|
|
summary: expect.objectContaining({ browser_windows: 0 }),
|
|
},
|
|
},
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"preserves missing-argument coordinates through every dispatcher formatter",
|
|
async ({ cli }) => {
|
|
const json = await cli.run({ arguments: ["--json", "analyze"] });
|
|
expect(json).toMatchObject({
|
|
exitCode: 1,
|
|
json: {
|
|
code: "VALIDATION_ERROR",
|
|
message: expect.stringContaining("expected string"),
|
|
fieldErrors: [
|
|
{
|
|
path: "path",
|
|
code: "invalid_type",
|
|
missing: true,
|
|
expected: "string",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
for (const arguments_ of [
|
|
["analyze"],
|
|
["--format", "yaml", "analyze"],
|
|
["--full-output", "analyze"],
|
|
]) {
|
|
const result = await cli.run({ arguments: arguments_ });
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toContain("VALIDATION_ERROR");
|
|
expect(result.stdout).toContain("fieldErrors");
|
|
expect(result.stdout).toContain("path");
|
|
expect(result.stdout).toContain("expected string");
|
|
}
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"preserves invalid option names and enum constraints in structured diagnostics",
|
|
async ({ cli }) => {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"search",
|
|
"/caller/local/input",
|
|
"needle",
|
|
"--kind",
|
|
"unrecognized",
|
|
"--json",
|
|
],
|
|
});
|
|
expect(result).toMatchObject({
|
|
exitCode: 1,
|
|
json: {
|
|
code: "VALIDATION_ERROR",
|
|
fieldErrors: [
|
|
{
|
|
path: "kind",
|
|
code: "invalid_value",
|
|
missing: false,
|
|
message: expect.stringContaining('"strings"|"procedures"'),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
workspaceCliTest(
|
|
"prints field-specific missing-argument recovery in terminal mode",
|
|
async ({ processes }) => {
|
|
const result = await processes.run(
|
|
process.execPath,
|
|
[
|
|
"--input-type=module",
|
|
"--eval",
|
|
[
|
|
'import { createCli } from "./dist/cli.js";',
|
|
'Object.defineProperty(process.stdout, "isTTY", { value: true });',
|
|
'await createCli({}).serve(["analyze"], { exit: (code) => { process.exitCode = code; } });',
|
|
].join("\n"),
|
|
],
|
|
{ cwd: process.cwd() },
|
|
);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toContain(
|
|
"Error: missing required argument <path>",
|
|
);
|
|
expect(result.stdout).toContain("See below for usage.");
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("compiled CLI artifact diagnostics", () => {
|
|
workspaceCliTest(
|
|
"preserves artifact diagnostics in JSON output",
|
|
async ({ cli, workspace }) => {
|
|
const source = workspace.path("source");
|
|
await mkdir(source);
|
|
await writeFile(join(source, "main.js"), "console.log('ok');\n");
|
|
const archive = workspace.path("fixture.asar");
|
|
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
|
await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n");
|
|
|
|
const result = await cli.run({
|
|
arguments: ["--json", "inspect-artifact", archive],
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
const output = JSON.stringify(result.json);
|
|
expect(output).toContain('"logical_path":"main.js"');
|
|
expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u);
|
|
expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u);
|
|
expect(output).toContain('"unpacked":true');
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
});
|