feat(spec): a flag that has to be given on its own - #941
Conversation
|
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 adds whole-invocation exclusive flags across KDL specifications, derive-generated parsers, usage-lib parsing, clap bridging, and shadow generation.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (20): Last reviewed commit: "fix(parse): one colliding alias does not..." | Re-trigger Greptile |
Instruction countsNothing was compared, and so nothing was gated. No series appears on both sides: either the base has no measurements recorded, or the two were measured on different runner classes, which are deliberately not comparable — counts shift between machine types by more than a real regression does. New, nothing to compare against: 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. Shadow comparisonParsing
|
|
Pushed fixes for the two findings that were still live against the head. Both were the same shape of mistake: a piece of behavior riding along with a carrier that was only meant to carry something else. A child's Exclusivity was suppressing flattened defaults, not just flattened requiredness (@cursor Bugbot). A parent exclusive flag skips a flattened group's Tests
Each fails without its fix. #961 has been rebased onto this head; its own five commits are unchanged apart from the one-line adaptation to the new helper. This comment was generated by Claude Code. |
|
Both fixes are correct and the reasoning tracks cleanly through the code. Fix 1 — Fix 2 — The updated if !__usage_exclusive_present
|| <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident)
.is_some()
{
<#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?;
}also handles the symmetric case: if the exclusive flag lives inside the flattened group, its own companion-conflict errors should still surface, and this guard ensures they do. The two regression tests each fail without their respective fix and pass with it — the minimum useful proof. The rest of the conformance suite ( |
|
Pushed a fix for @cursor Bugbot's follow-up, which was right and was my own doing: the previous commit traded one direction of the bug for the other.
So it is no longer assigned. The merged flag keeps the ancestor's The derive never had this to reconcile: it keeps the two declarations as separate fields, so the ancestor's short alias stays bound to the ancestor's. It was already giving the right answer, and this brings usage-lib to it rather than the reverse. Tests — each fails without the change:
This comment was generated by Claude Code. |
a37ce6b to
9a5338d
Compare
|
Rebased onto the updated #934, itself now on Two things on main met code this branch wrote, and neither showed up as a conflict — the rebase replayed cleanly and then failed to build, which is worth knowing if you rebase this yourself:
The behaviour is unchanged by that second one: each was already gated on
This comment was generated by Claude Code. |
`--version`, `--dump-config`: asking for one means the rest of the command line
has nothing to act on. The spec could say that a flag conflicts with named other
flags and had no way to say it conflicts with *everything* — and no way at all to
put a positional on either side of a conflict, since a selector names a flag.
flag "--dump" exclusive=#true
Enforced in both parsers against everything the command declares, positionals
included, which is what makes it more than being in a group with every other
flag. Only what was supplied counts, the rule `conflicts` already follows: a
flag with a default standing beside an exclusive one is nobody saying anything,
and counting it would make the exclusive flag unusable on any command that has a
default.
The bridge carries it, unlike `requires` — `Arg::is_exclusive_set` is public — so
a CLI that already declares this in clap keeps it on the way through. Both
`gen-shadow` dialects write it too, since clap and the derive can each say it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A child re-declaring an inherited global merges into one flag object carrying both alias sets. Assigning the child's `exclusive` onto that object answered for the ancestor's aliases too, so a local child that omitted `exclusive` let an orphan ancestor alias through beside a selected subcommand — the opposite asymmetry from the one the previous commit fixed, and inconsistent with a global child re-declaration, which leaves those spellings exclusive. A single bool cannot hold two declarations' answers. The merged flag keeps the ancestor's, and exclusivity is resolved per occurrence through the same spelling ledger that already decides whether selecting the child is company — so the two questions cannot drift apart. The derive never had this to reconcile, keeping the declarations as separate fields; a conformance test now holds both to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ownership was still one boolean for the whole occurrence, and two cases need finer grain than that. An ancestor-only alias typed beside a child-exclusive spelling attributed everything to the ancestor, so the child's exclusivity never fired — `run -c --clean --verbose` was accepted. And an environment value, which has no spelling to attribute it by, was matched against the merged flag's whole alias set, which a child re-declaring one alias can never equal; the ancestor answered for it in both directions. `exclusivity_in_play` now returns the two sides separately: the child's, for the spellings the child declared, and the ancestor's, for the ones only it declared. Both can be in play at once, which is exactly the mixed-alias case. An environment value takes the declaration the selected command has in scope. The subcommand-as-company question asks which exclusivity is being enforced rather than who owns the flag, which is the thing it actually meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things arrived on main while this branch was open, and both meet it in code
this branch wrote. `conformance/src/tables.rs` builds a `FlagMeta` field by
field, so the `exclusive` added here left it one short. And a subcommand partial
is now an enum holding only the selected variant rather than a struct with one
field per variant, so `any_given`, `exclusive_given` and `apply_env` — the three
this branch added to cross the subcommand boundary — were still reaching for
`partial.v{i}`.
Matched to the `settings_given` beside them, which already asks the variant. The
behavior is unchanged: every one of these was already gated on `selected`, so
only the selected arm was ever consulted, and an arm that is not selected has
nothing to have been given.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 4e2b571. Configure here.
| exclusive_occurrence(flag) | ||
| && !overridden_flags.contains(&flag.name) | ||
| && (flag_was_parsed(flag) || flag_has_env(flag, custom_env)) | ||
| }); |
There was a problem hiding this comment.
Pre-subcommand merge loses exclusivity
High Severity
When a child re-declares an inherited global as local and exclusive, typing that shared spelling before the subcommand word attributes the occurrence to the pre-merge parent Arc stored in prefix_bindings. Exclusivity later looks up the post-merge Arc via flag_was_parsed, so the child's exclusivity never activates: companions are accepted and requiredness is not escaped, even though the same spelling after the subcommand works.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 4e2b571. Configure here.
There was a problem hiding this comment.
I checked this one and I think it is the rule working rather than a bug — with a test added so it stops being re-reported either way.
The child's --clean here is local. A local declaration describes the flag at the command that declares it, so ahead of the subcommand word it is not in scope at all and the flag can only be the ancestor's. The ancestor's exclusivity is therefore the one that answers — and in your example the ancestor is not exclusive, so nothing fires.
The check that convinced me is the same spec with the ancestor's exclusive flipped and nothing else changed:
| ancestor | ex --clean run |
|---|---|
global |
accepted |
global exclusive |
refused |
One rule, read off the ancestor both times. The second row is a_child_local_exclusive_redeclaration_belongs_to_the_child, already in the suite, whose message is "the ancestor spelling still conflicts with selecting the child". Attributing the pre-subcommand occurrence to the child instead would make that row legal and break that test — so the asymmetry you spotted is not an oversight, it is what decides that case.
Now pinned by a_local_child_declaration_is_not_in_scope_before_the_subcommand, which asserts both rows together so the rule is visible in one place.
Worth saying plainly: whether a global flag ought to change meaning by position is a spec-level question, not a parser one, and if @jdx wants both spellings to reach the child's declaration then that existing test is the thing to revisit first. I have not made that change.
I did fix the greptile finding alongside this, which was a real ownership bug in the same code.
This comment was generated by Claude Code.
…flag Ownership asked whether *every* spelling the child declared resolves to this flag. A child may declare one that another inherited global already owns — `-c --clean` beside an inherited `-c --config` — and that collision is settled in the other global's favor, so the child's `-c` resolves elsewhere. Requiring all of them then disowned the child from the `--clean` it plainly does own, and its `exclusive` stopped applying: `run --clean --verbose` was accepted. Any of them is enough, and still tells apart the case the check exists for: when a child re-declares a global as global there are two flags, and the child's own spellings resolve to the child's, so none of them lands on the ancestor's. Also pins the rule a local re-declaration follows before the subcommand word, which was read as a bug and is the same rule working: such a declaration describes the flag at the child, so ahead of the subcommand the flag can only be the ancestor's, and the ancestor's exclusivity answers whichever way it is set. The two halves of the new test differ in nothing but that setting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Two findings this round. One was real and is fixed; the other I believe is the rule working, and I have left that thread open for you rather than closing it myself. Fixed — one colliding alias disowned a child from its own flag (@greptile). Ownership asked whether every spelling the child declared resolves to this flag, and a single unrelated collision defeats that: with an inherited Not changed — a local declaration before the subcommand word (@cursor Bugbot). The child's
One rule, read off the ancestor both times — and the second row is Tests —
This comment was generated by Claude Code. |
|
The reasoning is sound. The The local-flag case follows from the same scope rule that governs every local flag: it doesn't exist before the subcommand token, so |
Stacked on #941. Third item on the "changes what a CLI does" list in PLAN.md. ```kdl flag "--tags <tag>" var=#true delimiter="," ``` ```rust #[usage(long, delimiter = ',')] tags: Vec<String>, ``` `--tags a,b,c` as three values. The spec could already record a clap **default** split by a delimiter — `lib/src/spec/arg.rs` has done that since the `--fs-events` fix — and had no way to say the same thing about a value someone *types*. An adopter using `value_delimiter` was losing behaviour rather than spelling. ### Split before anything judges the result This is the part that decides whether the feature is worth having. `choices` is asked about each value rather than about the word that carried them, and `var_min`/`var_max` count the values the user meant rather than the words they typed: ``` --env dev,prod # two values, both on the choices list — accepted --env dev,nope # reports `nope`, not `dev,nope` --env a,b,c # three values against var_max=2 — refused ``` Judging first would reject `dev,prod` against a list that both halves are on, which is what the first draft of this did. ### A delimiter needs somewhere to put what it splits So it goes with `var`. On a single-value flag everything after the first separator would be dropped silently — refused where it is written instead, in all three places a spec can declare one (root `arg`, `cmd` block `arg`, and `flag`), and a compile error in the derive, where the field has to be a `Vec`. A non-ASCII delimiter is refused too, since values are split by byte. ### Where the split runs The derive splits on the **cold path**, first in `check` and after the environment has filled what argv left out — `TAGS=a,b` is one word too. The binder is untouched: it collects words and knows nothing about what a value *is*, which is what keeps the hot path free of all of this. A split costs one allocation per value, only for a field that asked for one. ### The bridge carries it `Arg::get_value_delimiter` is public — the same getter the default splitting already used. Only where several values can land, because clap refuses `value_delimiter` with `num_args(1)` itself, and a spec recording one on a single-value argument would be a spec this crate then declines to parse. `cargo test --all --all-features`, `mise run lint`, `mise run render` and `mise run gen-shadow` are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core argv binding and both derive and spec parsers, so miscounting or split order could change CLI behavior; mitigated by extensive tests and alignment with clap semantics. > > **Overview** > Adds **`delimiter`** (spec KDL, `#[usage(delimiter = ',')]`, metadata) so one argv word can represent several values, matching clap’s `value_delimiter`. > > **Parsing behavior:** Values are **split on the delimiter before** `choices` and `var_min`/`var_max` run, so `--env dev,prod` is judged per value and limits count intended values, not raw words. The **derive** path splits in generated `check` after env fallback; **`usage-lib`** does the same for spec-driven parse; the **argv binder** stays word-oriented but uses `values_in` and can return **`VarTooMany`** when a delimited word exceeds `var_max` in one step. > > **Validation & bridge:** Delimiter requires a **`Vec`** field (compile-time in derive) and **`var`** in the spec; only **ASCII** separators are stored in binding tables. Clap import sets `var` when a delimiter is present and round-trips ASCII delimiters in KDL/docs/shadow codegen. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit abc6187. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>


Stacked on #934. Next item down the clap-parity list in PLAN.md, and the last of the "sets of flags" group.
--version,--dump-config: asking for one means the rest of the command line has nothing to act on. The spec could say a flag conflicts with named other flags, and had no way to say it conflicts with everything — nor any way to put a positional on either side of a conflict, since a selector names a flag.Why it is not a group of everything
Enforced against everything the command declares, positionals included. That is the part
conflictsandgroupstructurally cannot express: both name flags.ex --dump tis refused, and that is the case worth having.Only what was supplied counts, the rule
conflictsalready follows. A flag with adefaultstanding beside an exclusive one is nobody saying anything — counting it would make the exclusive flag unusable on any command that has a default, which is most of them.The bridge carries this one
Unlike
requiresin #925,Arg::is_exclusive_setis public, so a CLI that already declares this in clap keeps it on the way through rather than silently losing it. Bothgen-shadowdialects write it as well, since clap and the derive can each say it — no new entry in the dropped-properties report.Tests
exclusive_round_trips_and_comes_across_from_clap— the KDL both ways, and the bridgean_exclusive_flag_has_to_be_alone— usage-lib and the derive, each covering the other-flag case and the positional casean_exclusive_flag_is_not_disturbed_by_a_default— the rule above, which is the one a future change is most likely to get wrongcargo test --all --all-features,mise run lint,mise run renderandmise run gen-shadoware clean.🤖 Generated with Claude Code
Note
Medium Risk
Touches core post-parse validation in both the spec parser and derive-generated
check, including subtle global-flag merge and env/default interactions; mistakes could break parsing or wrongly skip or enforce requirements.Overview
Adds
exclusivefor flags (KDLexclusive=#true, derive#[usage(exclusive)]) so a flag like--dumpor--versionmust be the only input on the invocation—other flags, positionals, and selecting a subcommand all count as company. Defaults do not; env-supplied values do.Spec & bridges:
FlagMeta/SpecFlaggainexclusive; KDL round-trips; clap import usesis_exclusive_set; conformance tables andgen-shadowemit it.usage-lib parser: Post-parse validation tracks which spellings activated each flag, resolves child vs ancestor exclusivity on merged globals without merging the
exclusivebool, diagnoses companions, and treats an active exclusive as a requiredness escape (like clap) while still rejecting invalid combinations.usage-derive: Refactors post-binding into
apply_defaults/apply_envplus newCommandArgs/Subcommandshooks (any_given,exclusive_given, …) so exclusivity works across flattenedArgsand subcommands; exclusive suppresses sibling required checks but not flattened defaults.Docs and broad conformance tests cover flatten, globals, env, and alias redeclaration scenarios.
Reviewed by Cursor Bugbot for commit f119674. Bugbot is set up for automated code reviews on this repo. Configure here.