fix(derive): let a ---only argument follow a variadic - #823
Conversation
The check refused any argument after a variadic, on the grounds that the variadic takes every remaining word. That is true except of the one case that matters: an argument only fillable after a `--`, because the `--` is what stops the variadic. mise declares exactly that on `run`, `exec` and `git` — `[ARGS]…` for the words before the separator and `[-- ARGS_LAST]…` for the ones after — so the check was refusing a shape three of its commands rely on. usage-argv already bound it correctly and so does usage-lib; only the derive's own validation disagreed. Found by generating a shadow CLI from mise's committed spec, which is what that exercise is for: four commands failed to compile and the parser was not the one at fault. Two corpus vectors pin the behaviour, with and without the separator.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThe PR aligns derive validation with usage-argv’s separator and variadic semantics while preventing generated-key collisions across modules.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (5): Last reviewed commit: "test(derive): make the identity test pro..." | Re-trigger Greptile |
Instruction counts
No instruction-count regression above 1%. Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run. Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.
|
An argument may follow a variadic when it requires a `--`, because the separator is what stops the variadic. Review pointed out that the exemption was written as a property of each argument rather than of the command line: two variadics behind a `--` both passed the check, and the first would take every word after the only separator there is, leaving the second unreachable — the very thing the rule exists to refuse. So a variadic that takes what follows the separator now ends the list: nothing may come after it, variadic or not.
|
Valid, and the framing was the useful part: I had written the exemption as a property of each argument when it is a property of the command line. Two variadics behind a So a variadic that takes what follows the separator now ends the list: nothing may come after it, variadic or not. Two unit tests, one for a second AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
Review asked whether a `--`-required variadic with a finite `var_max` frees the argument after it: the parser would advance once the bound was reached, so the layout would be legal and the new check wrong to refuse it. Measured instead of argued. usage-argv gives the variadic both words and leaves the next argument empty; **usage-lib stops at `var_max` and fills the next argument**. The two have disagreed all along and nothing recorded it. The grammar sides with usage-argv — a variadic "collects every word still available", and `var_min`/`var_max` are checks on what it got — so the check stays, and the grammar now says so in as many words. The divergence is a corpus vector rather than a silent difference, with a note that clap behaves the way usage-lib does, which makes this a question about the grammar rather than a bug on either side.
…ffer Review called this out on the nesting PR and it reproduces: two commands declared byte-for-byte alike in different modules — an `add::Op` and a `remove::Op` — share a key, and `Spec::to_kdl` asserts that no two things in a CLI do. A correct CLI failed the assertion, in the debug build tests run in. The old comment argued the residue was harmless because a key only chooses which `match` arm to jump to and each arm checks the event came from its own table. That much is true, and it is why nothing ever misbound; what it missed is the safety net above it, which called a legitimate CLI a collision. A macro cannot see a module path, but the generated code can: the declaration's hash is now mixed with `module_path!()` in a `const fn`, so the two halves of a key are decided in the two places that can each see half. Keys become named `const` items because a key is a `match` pattern as well as a table field, and a pattern cannot be an expression — `BASE | KIND_FLAG | 0` would parse as an or-pattern. A test declares the two identical commands and checks both that the spec has no duplicate keys and that each command still binds its own flag.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 80daef3. Configure here.
The two commands it declares were not identical: their doc comments differed, and the fingerprint hashes the whole item, attributes included. So their keys already diverged and the test passed whether or not the module was folded in — it asserted the fix while demonstrating nothing. They are byte-for-byte identical now, and I checked the test fails without the fix: stub the module out of `key_base` and it reports two things sharing a key, which is what it is supposed to notice.
|
The The identity-test finding was right and worth more than its severity. AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
) Stacked on #823. This is the first half of the gate — the thing the whole experiment is supposed to be judged by. `xtask gen-shadow <spec.kdl> <dir>` reads a usage spec and writes a crate of derived types declaring the same CLI. **mise's committed 5,592-line spec compiles**: 211 commands, 711 flags, 128 arguments, four levels deep, in 2.6 seconds. ## What it drops, and says so ``` dropped, because the derive cannot express it yet: `double_dash = "automatic"` on an argument: 3 a default on a flag that collects values: 1 a flag's second long or short form: 13 ``` Seventeen out of roughly a thousand. The generator names each class and counts it, because a silent cap reads as "all of it was expressible". ## A first measurement The gate parses one command line and exits, beside a null binary that does everything *except* parse. Subtracting the second from the first is what turns a process measurement into a parser measurement — about two thirds of a small binary's instructions are the dynamic loader and libc starting up, and neither parser is responsible for those. | invocation | instructions to parse | | --- | --- | | `mise` | 99,281 | | `mise use -g node@20` | 104,720 | | `mise settings set experimental true` | 104,650 | | `mise -vv ls --installed` | 105,754 | | `mise tasks run build -- --verbose` | 104,650 | Near constant regardless of depth, which is what static tables should look like: there is no tree to build. **The clap side is the next PR** — until it lands, this number stands on its own rather than as a ratio, and the ~3.1M figure from the original analysis is not something I have reproduced here. ## Two things worth review - **The generated crate is excluded from the workspace.** A shadow of mise trips `large_enum_variant` on the commands with thirty flags, which the real mise answers by boxing its variants — something the derive cannot express yet, so it's on the roadmap rather than papered over with an `allow`. The gate depends on the shadow by path so it is still built, and the smoke tests live in the gate crate, where they get linted like anything else. - **The shadow is checked in, not built by a `build.rs`.** The compile-time comparison wants a fixed input, and a generated file in the tree is a diff a reviewer can read when the derive's vocabulary changes. CI runs `mise run gen-shadow` and fails on a diff, the same way it does for `render`. Along the way, a note on fidelity: mise's spec declares **no positionals on the top-level `run`**, and that is deliberate — `src/cli/usage.rs` clears them (`run.args = vec![]`) and adds a `mount` of `mise tasks --usage` plus `restart_token = ":::"`, so the project's real tasks are what complete after `run` rather than a static `[TASK]`. The shadow can carry neither the mount nor the restart token yet, so its `run` is slightly cheaper to parse than real mise's would be. The smoke tests use `tasks run` for the separator shape and the root's own `[TASK]` for a bare task. (I first suspected the clap bridge was dropping them, since `Run(Box<run::Run>)` is the only boxed variant of the commands involved and the same struct registered unboxed under `tasks run` kept all three positionals. I tested that against clap 4 directly: boxing loses nothing, in clap or in the bridge. Recording it here because it is a tempting wrong answer.) *AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Large generated shadow and new workspace/CI wiring for the gate; not security-critical, but the shadow exercises the derive at full mise scale and CI will fail on generator drift. > > **Overview** > Adds the first half of the **performance gate**: generate a compileable shadow of a real CLI from its usage spec, then measure parsing at mise's full scale. > > **`xtask gen-shadow`** turns any `.usage.kdl` into a crate of derived types. mise's checked-in 5,592-line spec compiles (211 commands, 711 flags, 128 args). Features the derive cannot express yet are counted and reported rather than silently dropped. The generated `shadow-mise` crate is committed and **excluded from the workspace** (it trips `large_enum_variant`); the gate depends on it by path. > > The new **`gate`** crate provides one-shot parse binaries (`parse-usage` vs a null `parse-none` baseline) plus smoke tests for real mise invocations — including the `[ARGS]…` / `[-- ARGS_LAST]…` separator shape and nested/global-flag cases. > > CI now runs `mise r gen-shadow` and fails if the checked-in shadow drifts, same as `render`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4470be0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Stacked on #825. Settles the question left open in #823, the way you called it. ## What was open `var_max` had two coherent readings, and the two implementations had each quietly picked one. For `arg "[a]" var=#true var_max=1` then `arg "[b]"`, given `ex x y`: | | result | | --- | --- | | usage-argv (before) | `a = ["x","y"]`, then `var_too_many` | | usage-lib | `a = ["x"]`, `b = "y"` | | clap (`num_args = 0..=1`) | same as usage-lib | ## The decision **A limit, not a check.** clap behaves that way, every spec in the fleet is generated from a clap command, and it is the only reading under which `[a]… [b]` can be filled at all. So `var_max` moves into the hot `Arg`/`Flag` tables — the ones kept deliberately free of anything but what binding needs — as `Option<u32>`, and the parser counts what a variadic has taken. `var_min` stays a post-binding check, since no single word tells you a variadic will end up short. `var_too_many` accordingly stops being reachable for a bounded variadic and now describes only a repeatable flag's occurrences. **The cost: 55 instructions per parse, 0.1%.** Wall clock unchanged at 2.0µs. That was the thing worth checking, since the objection to this reading was that it puts a validation concept in the binder. ## What follows from it - **The derive's rule relaxes.** An argument after an *unbounded* variadic is still refused; after a bounded one it is allowed, and a bounded variadic behind a `--` no longer spends the separator. The error message now names both things that stop a variadic, so it points at a fix rather than just a refusal. - **Four corpus vectors**, at the **binding** layer where the question now lives: the bound hands over, the bound with nothing after it is an `unexpected_arg` rather than a silent drop, unbounded still takes everything, and the flag form. - **One divergence recorded rather than fixed**: for a flag whose single occurrence collects, usage-lib does not collect at all — it takes one value, so the second word falls through to the positional. That predates this change (`long-variadic-flag-arg` documents the same gap) and bounding what usage-argv collects is consistent with it collecting in the first place. `docs/spec/argv.md` states the rule, and PLAN.md records the decision with its reasoning so it does not get re-litigated. *AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core argv binding semantics and parser hot-path behavior; mistakes could mis-route tokens or break CLIs relying on the old post-binding `var_max` for variadics, though coverage is broad. > > **Overview** > Settles **`var_max` as a binding limit**, not a post-parse count check, so bounded variadics stop collecting and following positionals can be filled (clap `num_args` / usage-lib behavior). > > **usage-argv** adds `var_max: Option<u32>` on `Flag` and `Arg`, tracks per-occurrence counts (`collected` / `arg_taken`), resets counts when advancing positionals or jumping past `--`, and stops variadic flag collection when the bound is hit. > > **usage-derive** emits `var_max` into hot tables for variadic args/flags only; compile-time rules allow an argument after a **bounded** variadic (or after `--`); post-binding `var_too_many` applies only to **repeatable** flags counting occurrences. > > **Conformance bridge**, corpus vectors, spec docs, and **PLAN.md** are updated (including revised bench numbers); bounded variadic-flag behavior vs usage-lib remains a recorded divergence. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 285503b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added maximum value limits for variadic flags and positional arguments. * Bounded variadic arguments can pass remaining values to subsequent arguments. * Added conflict detection for incompatible flags. * Documented `var_max` behavior and clarified validation rules. * **Bug Fixes** * Corrected variadic value collection, overflow handling, inline values, and parser state resets. * Preserved minimum-value validation after parsing. * Improved handling around separators and repeatable flag occurrences. * **Tests** * Expanded coverage for bounded and unbounded variadics, positional fallthrough, overflow, and double-dash scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->

Stacked on #821.
The derive refused any argument after a variadic, on the grounds that a variadic takes every remaining word. True except in the one case that matters: an argument only fillable after a
--, since the--is what stops the variadic.mise declares exactly that on
run,execandgit:So the check was refusing a shape three of mise's commands depend on. Both parsers were already right — usage-argv binds it, usage-lib binds it — only the derive's validation disagreed.
How it was found
By generating a shadow CLI from mise's committed 5,592-line spec and trying to compile it. Four commands failed, and the parser was not at fault. That's what the shadow exercise is for, and it's the reason to do it before touching mise rather than after.
Two corpus vectors pin the behaviour, with and without the separator, and a derive test checks the emitted spec still says which argument is which — otherwise docs and completions would describe a different CLI from the one that runs.
AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.
Note
Medium Risk
Touches derive validation and key generation for all derived CLIs; behavior is corrective but key values change and incorrect validation could accept unreachable argument shapes.
Overview
Allows a
double_dash = "required"argument to follow a variadic in derive validation. The old rule rejected any argument after a variadic; the--that such an argument requires is exactly what stops the first variadic, which mise needs onrun/exec/git. Anything after a separator-consuming variadic is still rejected.Folds
module_path!()into generated keys via a newusage_argv::key_base, so byte-identical declarations in different modules no longer collide. That collision used to failSpec::to_kdl's uniqueness assert on otherwise valid CLIs. Codegen now emits named key consts (needed formatchpatterns) instead of inlining the hash.Adds corpus vectors and derive/identity tests for both behaviors, plus a note that
var_maxis a post-binding check and does not hand leftover words to the next argument.Reviewed by Cursor Bugbot for commit dd0436c. Bugbot is set up for automated code reviews on this repo. Configure here.