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", + ); + }); +});