diff --git a/deno.lock b/deno.lock index 58c63d7..ada0074 100644 --- a/deno.lock +++ b/deno.lock @@ -16,6 +16,7 @@ "jsr:@ts-morph/bootstrap@0.27": "0.27.0", "jsr:@ts-morph/common@0.27": "0.27.0", "npm:@standard-schema/spec@1": "1.1.0", + "npm:@types/node@*": "22.15.15", "npm:arktype@^2.1.20": "2.1.20", "npm:ts-case-convert@^2.1.0": "2.1.0" }, @@ -102,6 +103,12 @@ "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, "arktype@2.1.20": { "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", "dependencies": [ @@ -111,6 +118,9 @@ }, "ts-case-convert@2.1.0": { "integrity": "sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==" + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" } }, "workspace": { diff --git a/lib/commands.ts b/lib/commands.ts index a7dfc24..c2e0392 100644 --- a/lib/commands.ts +++ b/lib/commands.ts @@ -165,21 +165,47 @@ function command( }; let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - if (ctx.args[0] === "--help" || ctx.args[0] === "-h") { - let config = inner.inspect({ ...cmdCtx, args: [] }); + // Run inner parser first with all args. If --help/-h survives + // in the inner parser's remainder (no descendant consumed it), + // produce help for this command. This preserves nested command + // routing: a descendant commands() parser dispatches first and + // its command() handles --help at the correct level. + let config = inner.inspect(cmdCtx); + + let innerRemainder = config.result.remainder?.args ?? []; + let dashDashIndex = innerRemainder.indexOf("--"); + let searchEnd = dashDashIndex === -1 + ? innerRemainder.length + : dashDashIndex; + let helpPosition = -1; + for (let i = 0; i < searchEnd; i++) { + if ( + innerRemainder[i] === "--help" || innerRemainder[i] === "-h" + ) { + helpPosition = i; + break; + } + } + + if (helpPosition !== -1) { let help = { ...config.help, progname: [...ctx.progname, name], opts: [...config.help.opts, helpOpt], }; let text = format({ ...config, help }); + let helpArgs = [ + ...innerRemainder.slice(0, helpPosition), + ...innerRemainder.slice(helpPosition + 1), + ]; + let helpRemainder = { ...remainder, args: helpArgs }; return { type: "command", parser, result: { ok: true as const, value: { name, help: true as const, text } as Command, - remainder, + remainder: helpRemainder, }, name, description: inner.description, @@ -187,11 +213,10 @@ function command( config, commands: {}, help, - remainder, + remainder: helpRemainder, }; } - let config = inner.inspect(cmdCtx); let result = config.result.ok ? { ok: true as const, diff --git a/lib/object.ts b/lib/object.ts index 21bba78..e68aaec 100644 --- a/lib/object.ts +++ b/lib/object.ts @@ -118,11 +118,16 @@ function scopeInput( if (args.length > 0) { let matched = new Set(); - let prev: string[] | undefined; - while ( - args.length > 0 && (prev === undefined || args.length < prev.length) - ) { - prev = args; + let skipped: string[] = []; + + while (args.length > 0) { + if (args[0] === "--") { + skipped.push(...args); + args = []; + break; + } + + let consumed = false; for (let [key, parser] of entries) { let f = asField(parser); if (!f) continue; @@ -153,10 +158,18 @@ function scopeInput( } matched.add(key); args = match.remainder; + consumed = true; break; } } + + if (!consumed) { + skipped.push(args[0]); + args = args.slice(1); + } } + + args = skipped; } let scoped = new Map< diff --git a/lib/program.ts b/lib/program.ts index 4f2f2cf..e3f0981 100644 --- a/lib/program.ts +++ b/lib/program.ts @@ -55,6 +55,54 @@ export function program( let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; let main = config.inspect(rootCtx); + // Post-parse: if help/version was not detected at args[0], + // check if it survived into the config parser's remainder. + // When a sub-parser (command) handles --help, it removes the token + // from its result remainder, so it won't appear here. + let resultRemainder = main.result.remainder; + if (!help && !ver) { + let remainderArgs = resultRemainder?.args ?? []; + let dashDashIndex = remainderArgs.indexOf("--"); + let searchEnd = dashDashIndex === -1 + ? remainderArgs.length + : dashDashIndex; + + for (let i = 0; i < searchEnd; i++) { + if (remainderArgs[i] === "--help" || remainderArgs[i] === "-h") { + help = true; + resultRemainder = { + ...resultRemainder, + args: [ + ...remainderArgs.slice(0, i), + ...remainderArgs.slice(i + 1), + ], + }; + break; + } + } + + if (!help && version) { + let versionArgs = resultRemainder?.args ?? []; + let versionDashDash = versionArgs.indexOf("--"); + let versionSearchEnd = versionDashDash === -1 + ? versionArgs.length + : versionDashDash; + for (let i = 0; i < versionSearchEnd; i++) { + if (versionArgs[i] === "--version" || versionArgs[i] === "-v") { + ver = version; + resultRemainder = { + ...resultRemainder, + args: [ + ...versionArgs.slice(0, i), + ...versionArgs.slice(i + 1), + ], + }; + break; + } + } + } + } + let value: Program = { ...(help ? { help: true } : {}), ...(ver ? { version: ver } : {}), @@ -65,7 +113,7 @@ export function program( ? { ok: true as const, value, - remainder: main.result.ok ? main.result.remainder : remainder, + remainder: resultRemainder, } : main.result; diff --git a/specs/configliere-spec.md b/specs/configliere-spec.md index 372ed3f..7ef7706 100644 --- a/specs/configliere-spec.md +++ b/specs/configliere-spec.md @@ -154,8 +154,9 @@ provide CLI entry-point behavior. **Parse result.** The outcome of parsing: `Done` (success with typed value) or `Fail` (failure with error). Both carry a `remainder` of unconsumed input. -**Remainder.** The unconsumed portion of input after a parser has consumed what -it recognizes. Remainder flows upward from child to parent. +**Remainder.** The unconsumed portion of input after a parser has attempted to +match what it recognizes. "Did not consume" means "did not match," not "did not +reach due to early termination." Remainder flows upward from child to parent. **Source.** A record of where a field's value came from: `{ sourceName, sourceType, value, issues }`. Source types include `"none"`, @@ -420,6 +421,11 @@ scoping itself does not change precedence — it changes which values are visibl - MUST scope CLI args by matching against child field paths (via `scopeInput`). - MUST scope config-file values by extracting each child's key from value objects. +- MUST parse known options interspersed with unrecognized tokens. When no field + matcher claims a token, skip it, preserve it in remainder, and continue + matching. `--` terminates option processing; `--` and all subsequent tokens go + to remainder as-is. Unrecognized tokens appear in remainder in original + relative order. **Type expectation:** `object({ port: field(type("number")), host: field(type("string")) })` MUST @@ -454,11 +460,34 @@ progname, and delegation to a config parser. - MUST screen for `--help`/`-h` and `--version`/`-v` before delegating to the config parser. +- MUST also detect `--help`/`-h` and `--version`/`-v` in the config parser's + remainder after delegation, for tokens that no sub-parser handled. When a + sub-parser (e.g., `command()`) handles `--help`, it removes the token from its + result remainder, so it will not be detected again at the program level. +- `--help`/`-h` and `--version`/`-v` MUST be detected regardless of position in + argv (before `--`). Detection includes consumption: the handled token MUST NOT + appear in `result.remainder.args`. - MUST set `progname: [name]` in the root context. - MUST produce `Parser>` where `T` is the config parser's value type. - MUST merge preamble options (help, version) into the help output alongside the config parser's help. +> **Subcommand help routing.** When `program()` wraps `commands()`, +> subcommand-targeted help (e.g., `myapp dev --help`) is handled by `command()`, +> not by `program()`. `command()` runs the inner parser first with all args; if +> `--help` survives in the inner parser's remainder (no descendant consumed it), +> `command()` produces help for that command. `program()` only catches +> help/version tokens that survive into the config parser's remainder — i.e., +> tokens that no sub-parser handled. + +> **Provisional: Help-validation interaction (Decision B).** When `--help` is +> detected alongside missing required fields, `command()` runs the inner parser +> with all args; whether the inner parse succeeds or fails, the command still +> returns `ok: true` with `help: true` if `--help` is found in remainder. At the +> program level, the `(main.result.ok || help || ver)` guard ensures `ok: true` +> when help is detected. Whether finer-grained help-mode validation suppression +> is needed is deferred. + ### 8.5 `inject(fn)` **Role:** Defers a parse phase behind a dependency. The factory function `fn` @@ -765,6 +794,15 @@ key. Fields use path for CLI option key derivation and env-var key mapping. When both parsers are known at construction time, all help is available before any phase executes. This is the simplest case and MUST be fully supported. +### 12.1.1 Interspersed extraction guarantee + +A phase-1 parser MUST extract all known flags regardless of their position +relative to tokens that belong to later phases. Because `object()` uses +interspersed parsing (§8.2), a bootstrap parser defining `--config` but not a +positional `` will successfully extract `--config` from +`run ./suite --config app.json`, passing `run` and `./suite` through as +remainder for phase-2. + ### 12.2 Dependency-generated phases `inject((dep: D) => Parser)` represents a phase whose parser depends on @@ -1211,6 +1249,26 @@ Tests MUST verify that provided extraction helpers (currently `ConfigType`, instances. [Lock down contract — for whatever helpers exist at the time.] If the helper set evolves, tests MUST cover whatever helpers are provided. +### 18.11 Interspersed parsing and help detection + +- `object()` interspersed parsing: flags before, after, and interleaved with + unknown positionals; all-consume and no-consume cases [Verify current] +- `--` terminates option processing [Lock down contract] +- Remainder preserves original token order for unrecognized tokens [Lock down + contract] +- `--help`/`-h` position-insensitive at both program and command level [Lock + down contract] +- Subcommand help shape preserved: `config.help === true`, `config.text` present + [Lock down contract] +- Handled help/version tokens do not appear in `result.remainder.args` [Lock + down contract] +- Contextual args consumed during help are not in result remainder [Lock down + contract] +- Phased parsing: phase-1 extracts known flags regardless of position [Verify + current] +- Help-validation interaction: `--help` with missing required fields + (observation, provisional) [Verify current] + --- _This specification is derived from reverse-engineering research across 13 diff --git a/test/commands.test.ts b/test/commands.test.ts index dbff586..8939d9f 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -253,5 +253,45 @@ describe("commands", () => { expect(value.text).toMatch(/inner/); } }); + + it("routes --help to nested inner command", () => { + let nested = commands({ + outer: commands({ + inner: object({ + flag: field(type("boolean"), field.default(false)), + }), + }), + }); + let result = parseOk(nested, { + args: ["outer", "inner", "--help"], + }); + expect(result.name).toBe("outer"); + let inner = (result as unknown as { + config: { name: string; help: boolean; text: string }; + }).config; + expect(inner.name).toBe("inner"); + expect(inner.help).toBe(true); + expect(inner.text).toMatch(/flag/); + }); + + it("routes non-first --help to nested inner command", () => { + let nested = commands({ + outer: commands({ + inner: object({ + port: field(type("number"), field.default(3000)), + }), + }), + }); + let result = parseOk(nested, { + args: ["outer", "inner", "--port", "4000", "--help"], + }); + expect(result.name).toBe("outer"); + let inner = (result as unknown as { + config: { name: string; help: boolean; text: string }; + }).config; + expect(inner.name).toBe("inner"); + expect(inner.help).toBe(true); + expect(inner.text).toMatch(/port/); + }); }); }); diff --git a/test/help-position.test.ts b/test/help-position.test.ts new file mode 100644 index 0000000..3ff919d --- /dev/null +++ b/test/help-position.test.ts @@ -0,0 +1,318 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import assert from "node:assert"; +import { type } from "arktype"; +import { cli, field } from "../lib/field.ts"; +import { object } from "../lib/object.ts"; +import { commands } from "../lib/commands.ts"; +import { program } from "../lib/program.ts"; +import type { Program } from "../lib/program.ts"; +import { parseSync } from "./test-helpers.ts"; + +describe("help position-insensitivity", () => { + describe("top-level program help", () => { + let app = program({ + name: "myapp", + config: object({ + config: field(type("string | undefined")), + watch: field(type("boolean"), field.default(false)), + }), + }); + + it("detects --help first (existing behavior)", () => { + let result = parseSync(app, { + args: ["--help", "--config", "f.json"], + }); + assert(result.ok); + let value = result.value as Program<{ config?: string; watch: boolean }>; + expect(value.help).toBe(true); + }); + + it("detects --help last via post-parse remainder check", () => { + let result = parseSync(app, { + args: ["--config", "f.json", "--help"], + }); + assert(result.ok); + let value = result.value as Program<{ config?: string; watch: boolean }>; + expect(value.help).toBe(true); + expect(value.config?.config).toEqual("f.json"); + expect(result.remainder.args).toEqual([]); + }); + + it("detects --help after unknown positional", () => { + let result = parseSync(app, { + args: ["./suite", "--help"], + }); + assert(result.ok); + let value = result.value as Program<{ config?: string; watch: boolean }>; + expect(value.help).toBe(true); + expect(result.remainder.args).toEqual(["./suite"]); + }); + + it("detects -h shorthand in non-first position", () => { + let result = parseSync(app, { + args: ["--config", "f.json", "-h"], + }); + assert(result.ok); + let value = result.value as Program<{ config?: string; watch: boolean }>; + expect(value.help).toBe(true); + expect(value.config?.config).toEqual("f.json"); + expect(result.remainder.args).toEqual([]); + }); + + it("does not detect --help after --", () => { + let result = parseSync(app, { + args: ["--", "--help"], + }); + assert(result.ok); + let value = result.value as Program<{ config?: string; watch: boolean }>; + expect(value.help).toBeUndefined(); + expect(result.remainder.args).toEqual(["--", "--help"]); + }); + }); + + describe("version position-insensitivity", () => { + let app = program({ + name: "myapp", + version: "1.0.0", + config: object({ + config: field(type("string | undefined")), + }), + }); + + it("detects --version first (existing behavior)", () => { + let result = parseSync(app, { + args: ["--version"], + }); + assert(result.ok); + expect(result.value.version).toEqual("1.0.0"); + }); + + it("detects --version in non-first position", () => { + let result = parseSync(app, { + args: ["--config", "f.json", "--version"], + }); + assert(result.ok); + expect(result.value.version).toEqual("1.0.0"); + expect(result.value.config?.config).toEqual("f.json"); + expect(result.remainder.args).toEqual([]); + }); + }); + + describe("subcommand-targeted help", () => { + let app = program({ + name: "myapp", + config: commands({ + run: object({ + suite: field(type("string | undefined"), cli.argument()), + port: field(type("number"), field.default(3000)), + }), + }), + }); + + it("cmd --help (first after command name)", () => { + let result = parseSync(app, { args: ["run", "--help"] }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + expect(typeof cmd.text).toBe("string"); + expect(result.remainder.args).toEqual([]); + }); + + it("cmd --help", () => { + let result = parseSync(app, { + args: ["run", "--port", "4000", "--help"], + }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + expect(cmd.text).toMatch(/port/); + expect(result.remainder.args).toEqual([]); + }); + + it("cmd --help", () => { + let result = parseSync(app, { + args: ["run", "./suite", "--help"], + }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + expect(result.remainder.args).toEqual([]); + }); + + it("routes --help to nested inner command through program()", () => { + let nested = program({ + name: "myapp", + config: commands({ + outer: commands({ + inner: object({ + flag: field(type("boolean"), field.default(false)), + }), + }), + }), + }); + let result = parseSync(nested, { args: ["outer", "inner", "--help"] }); + assert(result.ok); + let outer = result.value.config as { + name: string; + config: { name: string; help: boolean; text: string }; + }; + expect(outer.name).toBe("outer"); + expect(outer.config.name).toBe("inner"); + expect(outer.config.help).toBe(true); + expect(outer.config.text).toMatch(/flag/); + expect(result.remainder.args).toEqual([]); + }); + + it("routes non-first --help to nested inner command through program()", () => { + let nested = program({ + name: "myapp", + config: commands({ + outer: commands({ + inner: object({ + port: field(type("number"), field.default(3000)), + }), + }), + }), + }); + let result = parseSync(nested, { + args: ["outer", "inner", "--port", "4000", "--help"], + }); + assert(result.ok); + let outer = result.value.config as { + name: string; + config: { name: string; help: boolean; text: string }; + }; + expect(outer.name).toBe("outer"); + expect(outer.config.name).toBe("inner"); + expect(outer.config.help).toBe(true); + expect(outer.config.text).toMatch(/port/); + expect(result.remainder.args).toEqual([]); + }); + }); + + describe("path comparison", () => { + let app = program({ + name: "myapp", + config: commands({ + run: object({ + port: field(type("number"), field.default(3000)), + }), + }), + }); + + it("--help at args[0] after command (scan at index 0)", () => { + let result = parseSync(app, { args: ["run", "--help"] }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + expect(result.remainder.args).toEqual([]); + }); + + it("--help at non-first after command (scan at later index)", () => { + let result = parseSync(app, { + args: ["run", "--port", "4000", "--help"], + }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + // Contextual values available in help text + expect(cmd.text).toMatch(/port/); + expect(result.remainder.args).toEqual([]); + }); + }); + + describe("Decision B observation (provisional)", () => { + let app = program({ + name: "myapp", + config: commands({ + run: object({ + suite: field(type("string"), cli.argument()), + port: field(type("number"), field.default(3000)), + }), + }), + }); + + it("--help with all required fields present", () => { + let result = parseSync(app, { + args: ["run", "./suite", "--help"], + }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + expect(result.remainder.args).toEqual([]); + }); + + it("--help with required field missing — documents actual behavior", () => { + // Decision B: command() runs inner parser with args excluding --help. + // When required field (suite) is missing, inner parser fails, but + // command() still returns ok: true with help: true. + let result = parseSync(app, { + args: ["run", "--help"], + }); + assert(result.ok); + let cmd = result.value.config as { + name: string; + help: boolean; + text: string; + }; + expect(cmd.name).toBe("run"); + expect(cmd.help).toBe(true); + // help: true is reachable even when required fields are missing, + // because command() wraps the result regardless of inner parse outcome. + }); + }); + + describe("spec-locking", () => { + it("--help / -h are position-insensitive within program()", () => { + let app = program({ + name: "myapp", + config: object({ + flag: field(type("boolean"), field.default(false)), + }), + }); + + let first = parseSync(app, { args: ["--help"] }); + assert(first.ok); + expect(first.value.help).toBe(true); + + let last = parseSync(app, { args: ["--flag", "--help"] }); + assert(last.ok); + expect(last.value.help).toBe(true); + + let shorthand = parseSync(app, { args: ["--flag", "-h"] }); + assert(shorthand.ok); + expect(shorthand.value.help).toBe(true); + }); + }); +}); diff --git a/test/interspersed.test.ts b/test/interspersed.test.ts new file mode 100644 index 0000000..93e91f6 --- /dev/null +++ b/test/interspersed.test.ts @@ -0,0 +1,244 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import assert from "node:assert"; +import { type } from "arktype"; +import { cli, field } from "../lib/field.ts"; +import { object } from "../lib/object.ts"; +import { inject } from "../lib/inject.ts"; +import { parseOk, parseSync } from "./test-helpers.ts"; + +describe("interspersed parsing", () => { + let bootstrap = object({ + watch: field(type("boolean"), field.default(false)), + config: field(type("string | undefined")), + }); + + describe("behavior", () => { + it("parses flags before unknown positional", () => { + let result = parseSync(bootstrap, { + args: ["--watch", "--config", "f.json", "./suite"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: true, config: "f.json" }); + expect(result.remainder.args).toEqual(["./suite"]); + }); + + it("parses flags after unknown positional", () => { + let result = parseSync(bootstrap, { + args: ["./suite", "--watch", "--config", "f.json"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: true, config: "f.json" }); + expect(result.remainder.args).toEqual(["./suite"]); + }); + + it("parses flags interleaved with multiple unknown positionals", () => { + let result = parseSync(bootstrap, { + args: ["./a", "--watch", "./b", "--config", "f.json"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: true, config: "f.json" }); + expect(result.remainder.args).toEqual(["./a", "./b"]); + }); + + it("consumes all tokens when all are recognized", () => { + let result = parseSync(bootstrap, { + args: ["--watch", "--config", "f.json"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: true, config: "f.json" }); + expect(result.remainder.args).toEqual([]); + }); + + it("puts all tokens in remainder when none are recognized", () => { + let result = parseSync(bootstrap, { + args: ["./a", "./b", "./c"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: false, config: undefined }); + expect(result.remainder.args).toEqual(["./a", "./b", "./c"]); + }); + + it("terminates option processing at --", () => { + let result = parseSync(bootstrap, { + args: ["--watch", "--", "--config", "f.json"], + }); + assert(result.ok); + expect(result.value).toEqual({ watch: true, config: undefined }); + expect(result.remainder.args).toEqual(["--", "--config", "f.json"]); + }); + }); + + describe("phased parsing", () => { + it("extracts --config after unknown positional in bootstrap phase", () => { + let phase1 = object({ + config: field(type("string | undefined")), + }); + + let result = parseSync(phase1, { + args: ["run", "./suite", "--config", "f.json"], + }); + assert(result.ok); + expect(result.value.config).toEqual("f.json"); + expect(result.remainder.args).toEqual(["run", "./suite"]); + }); + + it("phase-2 receives clean remainder and parses successfully", () => { + let phase1 = inject((_deps: void) => + object({ + suite: field(type("string"), cli.argument()), + port: field(type("number"), field.default(3000)), + }) + ); + + let r1 = phase1.parse({ + args: ["./suite", "--port", "4000"], + }); + assert(r1.ok); + + let resolved = r1.value(); + let r2 = resolved.parse(); + assert(r2.ok); + expect(r2.value).toEqual({ suite: "./suite", port: 4000 }); + }); + }); + + describe("regression", () => { + it("handles flags-only input (no positionals)", () => { + let config = object({ + debug: field(type("boolean"), field.default(false)), + port: field(type("number"), field.default(3000)), + }); + let result = parseSync(config, { + args: ["--debug", "--port", "3000"], + }); + assert(result.ok); + expect(result.value).toEqual({ debug: true, port: 3000 }); + expect(result.remainder.args).toEqual([]); + }); + + it("handles boolean switch before unknown positional", () => { + let config = object({ + debug: field(type("boolean"), field.default(false)), + entry: field(type("string | undefined"), cli.argument()), + }); + let result = parseSync(config, { + args: ["--debug", "./app"], + }); + assert(result.ok); + expect(result.value).toEqual({ debug: true, entry: "./app" }); + expect(result.remainder.args).toEqual([]); + }); + + it("handles --flag=value after unknown positional", () => { + let config = object({ + port: field(type("number"), field.default(3000)), + }); + let result = parseSync(config, { + args: ["./unknown", "--port=4000"], + }); + assert(result.ok); + expect(result.value).toEqual({ port: 4000 }); + expect(result.remainder.args).toEqual(["./unknown"]); + }); + + it("handles negative switch after unknown positional", () => { + let config = object({ + debug: field(type("boolean"), field.default(true)), + }); + let result = parseSync(config, { + args: ["./unknown", "--no-debug"], + }); + assert(result.ok); + expect(result.value).toEqual({ debug: false }); + expect(result.remainder.args).toEqual(["./unknown"]); + }); + + it("collects array fields across unknown positionals", () => { + let config = object({ + repeat: field(type("string[]"), field.array()), + }); + let result = parseSync(config, { + args: ["--repeat", "a", "./unknown", "--repeat", "b"], + }); + assert(result.ok); + expect(result.value).toEqual({ repeat: ["a", "b"] }); + expect(result.remainder.args).toEqual(["./unknown"]); + }); + + it("preserves remainder order", () => { + let config = object({ + debug: field(type("boolean"), field.default(false)), + port: field(type("number"), field.default(3000)), + }); + let result = parseSync(config, { + args: ["./a", "--debug", "./b", "--port", "3000", "./c"], + }); + assert(result.ok); + expect(result.remainder.args).toEqual(["./a", "./b", "./c"]); + }); + + it("positional claims first eligible non-dash token", () => { + let config = object({ + entry: field(type("string | undefined"), cli.argument()), + }); + let result = parseSync(config, { + args: ["./a", "./b"], + }); + assert(result.ok); + expect(result.value.entry).toEqual("./a"); + expect(result.remainder.args).toEqual(["./b"]); + }); + }); + + describe("spec-locking", () => { + it("interspersed parsing is default behavior of object()", () => { + let config = object({ + flag: field(type("boolean"), field.default(false)), + }); + let result = parseSync(config, { + args: ["unknown", "--flag"], + }); + assert(result.ok); + expect(result.value.flag).toBe(true); + expect(result.remainder.args).toEqual(["unknown"]); + }); + + it("-- terminates option processing unconditionally", () => { + let config = object({ + flag: field(type("boolean"), field.default(false)), + }); + let result = parseSync(config, { + args: ["--", "--flag"], + }); + assert(result.ok); + expect(result.value.flag).toBe(false); + expect(result.remainder.args).toEqual(["--", "--flag"]); + }); + + it("remainder preserves original token order", () => { + let config = object({ + x: field(type("boolean"), field.default(false)), + }); + let result = parseSync(config, { + args: ["c", "a", "--x", "b"], + }); + assert(result.ok); + expect(result.remainder.args).toEqual(["c", "a", "b"]); + }); + + it("positional first-match preserved under wider scope", () => { + let config = object({ + entry: field(type("string | undefined"), cli.argument()), + flag: field(type("boolean"), field.default(false)), + }); + let result = parseSync(config, { + args: ["first", "--flag", "second"], + }); + assert(result.ok); + expect(result.value.entry).toEqual("first"); + expect(result.value.flag).toBe(true); + expect(result.remainder.args).toEqual(["second"]); + }); + }); +});