From b4927a0a4fa7e8935b101d214b396bfa78208281 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:11:11 -0700 Subject: [PATCH] fix(cli): advertise defaulted inputs as optional in --schema and help (#1175) Incur derives `--schema`, the `--llms` manifests, and help synopses from Zod's output view, where a defaulted field always has a value, so every defaulted argument and option was listed as required even though the CLI accepts its omission. Wrap each field that fills an omitted value as optional after registration, keeping its default, constraints, metadata, and parsed value. Fixes #1169 Co-authored-by: Claude Opus 5.5 --- src/cli.ts | 2 + src/cli/omittableDefaults.ts | 66 ++++++++++ .../cli/cliInputSchemaRequired.test.ts | 114 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/cli/omittableDefaults.ts create mode 100644 tests/boundary/cli/cliInputSchemaRequired.test.ts diff --git a/src/cli.ts b/src/cli.ts index 6f6b84c3f..8b3d30008 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,7 @@ import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js import { registerElectronCommands } from "./cli/electronCommands.js"; import { registerJavaScriptRuntimeObservationCommands } from "./cliJavaScriptRuntimeCommands.js"; import { registerApplicationCommands } from "./cli/applicationCommands.js"; +import { presentOmittableDefaults } from "./cli/omittableDefaults.js"; import type { CliInstance } from "./cli/types.js"; import type { CliResultOutput } from "./cli/streamedJsonOutput.js"; @@ -78,5 +79,6 @@ export const createCli = ( registerElectronCommands(cli, logger, resultOutput); registerJavaScriptRuntimeObservationCommands(cli, logger); registerApplicationCommands(cli, logger); + presentOmittableDefaults(cli); return cli; }; diff --git a/src/cli/omittableDefaults.ts b/src/cli/omittableDefaults.ts new file mode 100644 index 000000000..eaf11c503 --- /dev/null +++ b/src/cli/omittableDefaults.ts @@ -0,0 +1,66 @@ +import { Cli, z } from "incur"; + +import type { CliInstance } from "./types.js"; + +type CommandMap = NonNullable>; +type CommandInputs = Partial>; + +const INPUT_GROUPS = ["args", "env", "options"] as const; + +// Incur types its router key without vars, env, or globals; accept any CLI. +const registeredCommands: WeakMap = Cli.toCommands; + +/** + * Advertise parser-filled defaults as omittable CLI inputs. + * + * Incur derives `--schema`, the `--llms` manifests, and help synopses from + * Zod's output view, where a defaulted field always has a value and is + * therefore listed as required. Callers may omit such a field, so each one is + * wrapped as optional while its default, constraints, metadata, and parsed + * value stay unchanged. + */ +export const presentOmittableDefaults = (cli: CliInstance): void => { + const commands = registeredCommands.get(cli); + if (commands !== undefined) presentCommands(commands); +}; + +const presentCommands = (commands: CommandMap): void => { + for (const entry of commands.values()) { + if ("_group" in entry) { + presentCommands(entry.commands); + if (entry.root !== undefined) presentInputs(entry.root); + } else if (!("_alias" in entry) && !("_fetch" in entry)) + presentInputs(entry); + } +}; + +const presentInputs = (command: CommandInputs): void => { + for (const group of INPUT_GROUPS) { + const schema = command[group]; + if (schema instanceof z.ZodObject) + command[group] = withOmittableDefaults(schema); + } +}; + +const withOmittableDefaults = (schema: z.ZodObject): z.ZodObject => { + const omittable = Object.entries(schema.shape).filter(([, field]) => + fillsOmission(field), + ); + if (omittable.length === 0) return schema; + return schema.safeExtend( + Object.fromEntries( + omittable.map(([key, field]) => [key, optionalWithMetadata(field)]), + ), + ); +}; + +/** Zod accepts an omitted value (`optin`) but always produces one (`optout`). */ +const fillsOmission = (field: z.ZodType): boolean => + field._zod.optin === "optional" && field._zod.optout === undefined; + +const optionalWithMetadata = (field: z.ZodType): z.ZodType => { + const metadata = field.meta(); + return metadata === undefined + ? field.optional() + : field.optional().meta(metadata); +}; diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts new file mode 100644 index 000000000..3bc95236f --- /dev/null +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -0,0 +1,114 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { Cli, z } from "incur"; +import { describe, expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; +import { createCliInventory } from "../../../scripts/lib/product-catalog.mjs"; + +type CliInstance = ReturnType; +type CommandMap = NonNullable>; + +const INPUT_GROUPS = ["args", "options"] as const; +const registeredCommands: WeakMap = Cli.toCommands; + +const advertisedGroupSchema = z + .object({ + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), + }) + .loose(); +const advertisedInputSchema = z.object({ + args: advertisedGroupSchema.optional(), + options: advertisedGroupSchema.optional(), +}); + +const serve = async ( + cli: CliInstance, + argv: readonly string[], +): Promise<{ readonly stdout: string; readonly exitCode: number }> => { + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { stdout, exitCode }; +}; + +const advertisedSchema = async (cli: CliInstance, command: string) => { + const result = await serve(cli, [command, "--schema", "--json"]); + expect(result.exitCode, command).toBe(0); + return advertisedInputSchema.parse(JSON.parse(result.stdout)); +}; + +const parserSchema = ( + cli: CliInstance, + command: string, + group: (typeof INPUT_GROUPS)[number], +): z.ZodObject | undefined => { + const entry = registeredCommands.get(cli)?.get(command); + if (entry === undefined || !(group in entry)) return undefined; + const schema: unknown = Reflect.get(entry, group); + return schema instanceof z.ZodObject ? schema : undefined; +}; + +const omissionRejected = (schema: z.ZodObject | undefined): string[] => + Object.entries(schema?.shape ?? {}) + .filter(([, field]) => !field.safeParse(undefined).success) + .map(([key]) => key) + .sort(); + +describe("CLI advertised input schemas", () => { + it("require exactly the inputs whose omission the parser rejects", async () => { + const cli = createCli({}); + const mismatches: string[] = []; + for (const command of createCliInventory(cli).primary) { + const advertised = await advertisedSchema(cli, command); + for (const group of INPUT_GROUPS) { + const required = (advertised[group]?.required ?? []).toSorted(); + const rejected = omissionRejected(parserSchema(cli, command, group)); + if (required.join() !== rejected.join()) + mismatches.push( + `${command} ${group}: advertised [${required.join()}], parser rejects [${rejected.join()}]`, + ); + } + } + expect(mismatches).toEqual([]); + }); + + it("accepts the one-argument keyed-archive call and keeps its constraints", async () => { + const cli = createCli({}); + const advertised = await advertisedSchema(cli, "inspect-keyed-archive"); + const ajv = new Ajv2020({ strict: false }); + const args = ajv.compile(advertised.args ?? {}); + const options = ajv.compile(advertised.options ?? {}); + + expect(args({ path: "./keyed.plist" })).toBe(true); + expect(args({})).toBe(false); + expect(options({})).toBe(true); + expect(options({ limit: 0 })).toBe(false); + expect(advertised.args?.properties?.["archive"]).toMatchObject({ + default: ".", + }); + expect(advertised.options?.properties?.["limit"]).toMatchObject({ + default: 20_000, + minimum: 1, + }); + expect( + parserSchema(cli, "inspect-keyed-archive", "args")?.parse({ path: "x" }), + ).toEqual({ path: "x", archive: "." }); + + const help = await serve(cli, ["inspect-keyed-archive", "--help"]); + expect(help.stdout).toContain( + "Usage: rea inspect-keyed-archive [archive] [options]", + ); + expect(help.stdout).toContain( + "archive Relative archive path when the target is a bundle", + ); + }); +});