Skip to content

feat(argv): route a word that names nothing to the default subcommand - #848

Merged
jdx merged 2 commits into
agent/os-valuesfrom
agent/default-routing
Aug 13, 2026
Merged

feat(argv): route a word that names nothing to the default subcommand#848
jdx merged 2 commits into
agent/os-valuesfrom
agent/default-routing

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

default_subcommand was emitted into the spec and ignored by the parser, which the
previous commit recorded as a gap rather than a decision. usage-lib routes on it, and
this is the rule behind mise build meaning mise run build.

A word naming no subcommand now descends into the named one, and the cursor steps back so
the word is examined again against the command it reached — which lets it be that
command's argument or one of its subcommands, without this deciding which and without
two events for one word. The declaring command's own positional does not win, which is
what makes the property more than a synonym for an argument, and is mise's shape exactly:
the root has [TASK] and so does run.

Taken at most once per parse, as usage-lib does, so a chain of defaults cannot walk a CLI
deeper than anything the user typed.

The name is resolved by find_subcommand during const evaluation, so a name no
subcommand answers to is a compile error. The previous commit said the name could not be
checked because the variants are another expansion; that is true of the macro and not of
const eval, which can search the list the parent already holds.

Seven corpus vectors, six agreeing with usage-lib and one recording where it does not:
usage-lib holds the single name spec-wide and applies it at whichever command it stands
on, so ex config zzz descends into config ls when an unrelated command happens to
have a subcommand of that name. Read here as a property of the root, which is the only
place a spec can declare it.

default_subcommand on a nested Args is now refused outright instead of being dropped
in silence. Both derives share one parse, so the rule moved to where the context is
known — before, a nested struct got whichever check ran first, which named the wrong
problem.

No measurable cost: 40,370 instructions against 40,472, the branch being reached only by
a word that matched no subcommand. Allocations unchanged — 0 bare, 4 bound.

One thing this does not buy: mise build still fails in the shadow, because mise's spec
gives run no positional — its arguments are cleared and a mount supplies task names,
which usage-argv does not execute. Routing plus mounts is what lets mise delete its
hand-rolled dispatch; the gate test now says so rather than asserting the old answer.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com


Stack created with GitHub Stacks CLIGive Feedback 💬


Note

Medium Risk
Changes core subcommand routing and argv binding semantics (default descent, once-per-parse latch, token rewind), which can alter how bare words and edge cases parse across CLIs that declare default_subcommand.

Overview
Implements default_subcommand in the hot-path parser so a bare word like build can mean “descend into run and bind there,” matching usage-lib and mise’s mise buildmise run build shape.

Command gains default_subcommand plus find_subcommand (const-time name/alias lookup, compile error if the name is missing). In word(), after a failed subcommand match, the parser may descend once per parse (default_taken), rewind the argv cursor, and re-read the same token under the default command. Routing is skipped for flag-like tokens, after --, and when a positional already filled; unknown flags still bind at the current command.

Derive and conformance wire the root-level spec property into static tables; nested default_subcommand on Args is rejected. Corpus adds 09-default-subcommand.json (including one recorded usage-lib divergence: root-only vs spec-wide default). Gate shadow now expects mise build to route into run then fail with UnexpectedArg until mounts exist.

Reviewed by Cursor Bugbot for commit 46fff42. Bugbot is set up for automated code reviews on this repo. Configure here.

The decision behind this

Investigating flatten turned up that five of mise's ten #[clap(flatten)] sites are a
subcommand field beside a flatten of the same type the Ls variant holdsconfig,
tasks, settings, deps, tool all hand-rolling "with no subcommand, behave as ls".
Which raised the question of what default_subcommand actually means, and the answer was that
#842 got it wrong: usage-lib routes on it while parsing.

["mise", "build"] -> cmds ["mise", "run"], args [("TASK", String("build"))]

Even though the root declares its own arg "[TASK]". So the property is a binding rule, and
#842's test asserting otherwise was corrected in its own commit before this one.

Cost

before after
instructions, cold parse 40,472 40,370
allocations, bare / bound 0 / 4 0 / 4

No measurable cost. The branch is reached only by a word that already failed a subcommand
lookup, and the ±100 is code layout rather than work done — cachegrind is deterministic and
gave the same figure on repeated runs.

The name is now checked at compile time

#842 said "which command it names cannot be checked here — the enum's variants are in another
expansion". True of the macro, not of const evaluation: find_subcommand searches the list the
parent already holds, so

error[E0080]: evaluation panicked: `default_subcommand` names a command that this one does not have

A wrong name is a compile error rather than a spec property nothing validates.

What this does not buy

mise build still does not work end to end in the shadow, and it is worth being plain about
why: mise's spec gives run no positional at allsrc/cli/usage.rs clears them and adds
mount run="mise tasks --usage", so task names are meant to come from running that.
usage-argv does not execute mounts, so after descending there is nothing to bind to. Routing
plus mounts is what would let mise delete its hand-rolled dispatch; this is half of it. The
gate test now records that instead of asserting the old answer.

Verification

  • Seven corpus vectors. Six agree with usage-lib — checked automatically, since an absent
    reference label is an assertion the reference test enforces in both directions.
  • Six parser unit tests: routing, a named subcommand not routed, re-examination against the
    reached command, the once-per-parse latch, a flag before the word, and nothing routing past
    --.
  • Mutation-checked: removing the latch fails the latch test; removing the cursor rewind fails
    four.
  • End-to-end through the derive, plus a test that the resolved pointer is the table's own entry
    rather than a copy.

One divergence, and a question

default-is-declared-for-the-root records it: usage-lib holds the single name for the whole
spec and applies it at whichever command it is standing on, so with default_subcommand "ls" declared at the top, ex config zzz descends into config ls — an unrelated command
acquiring a default because a name happened to match one level down.

["config", "zzz"] -> cmds ["ex", "config", "ls"], args [("WHAT", String("zzz"))]

This parser reads it as a property of the root, which is the only place a spec can declare one.
That looks like the intended meaning and usage-lib's looks accidental — but it is your call,
and if you agree it is a small fix in lib/src/parse.rs. The corpus label makes the difference
fail loudly the moment it changes either way.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a83a484c-bdc6-475e-9cb2-66c31cefdefc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jdx
jdx marked this pull request as ready for review August 12, 2026 23:18

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a32d361. Configure here.

Comment thread argv/src/lib.rs
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR implements default-subcommand routing in the compiled argv parser and resolves configured defaults against canonical subcommand names and aliases at compile time.

  • Adds once-per-parse routing and token re-examination in usage-argv.
  • Emits resolved default-subcommand pointers from usage-derive.
  • Extends conformance vectors and parser, derive, and gate tests for routing behavior and edge cases.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
argv/src/lib.rs Adds default-subcommand table state, alias-aware const resolution, and once-per-parse routing; the prior alias rejection is fixed.
derive/src/codegen.rs Emits the configured default as a compile-time-resolved pointer while preserving visible and hidden aliases in the command alias table.
derive/src/model.rs Documents compile-time name validation and retains root-versus-nested declaration checks.
conformance/src/argv.rs Attaches the spec-level default to the root command used by corpus execution.
corpus/09-default-subcommand.json Adds coverage for routing, aliases, separators, unknown flags, and root-only semantics.

Fix All in Greploop

Reviews (7): Last reviewed commit: "fix(argv): route only a word, and let an..." | Re-trigger Greptile

Comment thread argv/src/lib.rs Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁▁▂███ 152,797,288 → 152,857,178 +0.04% 14.15 → 14.56ms +2.84%
startup ▅▅▅█▅▅▅▅▅▅▁▁ 1,201,844 → 1,201,836 -0.00% 0.96 → 0.95ms -1.44%

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.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

usage clap ratio
instructions, cold parse 40221 5962046 148x
usage: argv -> struct                            1741 ns      1.74 µs
clap: build tree + parse -> struct             503130 ns    503.13 µs
clap: parse -> struct, tree reused              23478 ns     23.48 µs
clap: build tree only                          316625 ns    316.62 µs

46fff423795c vs 076fe59bdc97 · measured on the runner, not pushed to the history.

jdx added a commit that referenced this pull request Aug 12, 2026
Two findings on #848, both real and both confirmed against usage-lib.

An unrecognised dash-prefixed token becomes a value when `unknown_flags` is `value`, and it
was reaching the routing path as though it were a subcommand word — so `ex --wat` descended
into the default command instead of binding where it was typed. usage-lib stops looking for
subcommands at an unrecognised flag; verified there, `ex --wat` comes back as commands
`["ex"]` with `ROOT_TASK = "--wat"`. Only a token that could have named a command routes
now, which also excludes the `--` that a `preserve` argument asks for as a value. (Cursor
Bugbot.)

`find_subcommand` compared names only, so `default_subcommand "r"` pointing at an alias of
`run` failed to compile rather than resolving. usage-lib matches names, aliases and hidden
aliases alike, so aliases resolve here too. (Greptile.)

Three corpus vectors, all agreeing with usage-lib, and both fixes mutation-checked: with
the guard removed the unknown-flag test fails, and with the alias lookup removed the alias
test fails to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the agent/default-routing branch from a32d361 to 83172c4 Compare August 12, 2026 23:34

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Addressed in the latest push.

Both findings were real, and both confirmed against usage-lib before fixing.

  • Unknown flags routing (Bugbot): `ex --wat` in usage-lib comes back as commands `["ex"]` with the token bound to the root positional. Only a token that could have named a command routes now, which also excludes the `--` a `preserve` argument asks for as a value.
  • Aliases (Greptile): usage-lib resolves the name against names, aliases and hidden aliases, so `find_subcommand` does too — before, pointing `default_subcommand` at an alias failed to compile.

Three corpus vectors, all agreeing with usage-lib, and both fixes mutation-checked.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

jdx added a commit that referenced this pull request Aug 12, 2026
Two findings on #848, both real and both confirmed against usage-lib.

An unrecognised dash-prefixed token becomes a value when `unknown_flags` is `value`, and it
was reaching the routing path as though it were a subcommand word — so `ex --wat` descended
into the default command instead of binding where it was typed. usage-lib stops looking for
subcommands at an unrecognised flag; verified there, `ex --wat` comes back as commands
`["ex"]` with `ROOT_TASK = "--wat"`. Only a token that could have named a command routes
now, which also excludes the `--` that a `preserve` argument asks for as a value. (Cursor
Bugbot.)

`find_subcommand` compared names only, so `default_subcommand "r"` pointing at an alias of
`run` failed to compile rather than resolving. usage-lib matches names, aliases and hidden
aliases alike, so aliases resolve here too. (Greptile.)

Three corpus vectors, all agreeing with usage-lib, and both fixes mutation-checked: with
the guard removed the unknown-flag test fails, and with the alias lookup removed the alias
test fails to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the agent/default-routing branch from 83172c4 to af67354 Compare August 12, 2026 23:40
@jdx
jdx force-pushed the agent/default-routing branch from af67354 to 7ebfff5 Compare August 12, 2026 23:45
@jdx
jdx force-pushed the agent/default-routing branch 2 times, most recently from af67354 to d4da025 Compare August 13, 2026 00:54
jdx and others added 2 commits August 12, 2026 21:38
`default_subcommand` was emitted into the spec and ignored by the parser, which the
previous commit recorded as a gap rather than a decision. usage-lib routes on it, and
this is the rule behind `mise build` meaning `mise run build`.

A word naming no subcommand now descends into the named one, and the cursor steps back so
the word is examined again against the command it reached — which lets it be that
command's argument or one of *its* subcommands, without this deciding which and without
two events for one word. The declaring command's own positional does not win, which is
what makes the property more than a synonym for an argument, and is mise's shape exactly:
the root has `[TASK]` and so does `run`.

Taken at most once per parse, as usage-lib does, so a chain of defaults cannot walk a CLI
deeper than anything the user typed.

The name is resolved by `find_subcommand` during const evaluation, so a name no
subcommand answers to is a compile error. The previous commit said the name could not be
checked because the variants are another expansion; that is true of the macro and not of
const eval, which can search the list the parent already holds.

Seven corpus vectors, six agreeing with usage-lib and one recording where it does not:
usage-lib holds the single name spec-wide and applies it at whichever command it stands
on, so `ex config zzz` descends into `config ls` when an unrelated command happens to
have a subcommand of that name. Read here as a property of the root, which is the only
place a spec can declare it.

`default_subcommand` on a nested `Args` is now refused outright instead of being dropped
in silence. Both derives share one parse, so the rule moved to where the context is
known — before, a nested struct got whichever check ran first, which named the wrong
problem.

No measurable cost: 40,370 instructions against 40,472, the branch being reached only by
a word that matched no subcommand. Allocations unchanged — 0 bare, 4 bound.

One thing this does not buy: `mise build` still fails in the shadow, because mise's spec
gives `run` no positional — its arguments are cleared and a mount supplies task names,
which usage-argv does not execute. Routing plus mounts is what lets mise delete its
hand-rolled dispatch; the gate test now says so rather than asserting the old answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings on #848, both real and both confirmed against usage-lib.

An unrecognised dash-prefixed token becomes a value when `unknown_flags` is `value`, and it
was reaching the routing path as though it were a subcommand word — so `ex --wat` descended
into the default command instead of binding where it was typed. usage-lib stops looking for
subcommands at an unrecognised flag; verified there, `ex --wat` comes back as commands
`["ex"]` with `ROOT_TASK = "--wat"`. Only a token that could have named a command routes
now, which also excludes the `--` that a `preserve` argument asks for as a value. (Cursor
Bugbot.)

`find_subcommand` compared names only, so `default_subcommand "r"` pointing at an alias of
`run` failed to compile rather than resolving. usage-lib matches names, aliases and hidden
aliases alike, so aliases resolve here too. (Greptile.)

Three corpus vectors, all agreeing with usage-lib, and both fixes mutation-checked: with
the guard removed the unknown-flag test fails, and with the alias lookup removed the alias
test fails to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the agent/default-routing branch from d4da025 to 46fff42 Compare August 13, 2026 02:38
@jdx
jdx merged commit c22910a into main Aug 13, 2026
9 checks passed
@jdx
jdx deleted the agent/default-routing branch August 13, 2026 03:14
jdx added a commit that referenced this pull request Aug 13, 2026
A spec declares one `default_subcommand`, once, at the top — and the
parser looked that name
up at whichever command it happened to be standing on. So a command with
an unrelated
subcommand of the same name acquired a default of its own: with
`default_subcommand "ls"`
declared for the program, `ex config zzz` descended into `config ls` and
bound `zzz` there.
Nothing declared that, and there is no way to declare it, which is what
makes it a bug
rather than a feature.

Gated on still being at the root, which `out.cmds` already records — it
holds just the root
until something descends.

Found while teaching usage-argv to route on the property: the two
disagreed, and the corpus
recorded it as a divergence pending a decision. jdx's call was to fix it
here. The vector
that recorded the difference is now an ordinary agreeing one, and
deleting its label was not
optional — the reference test checks labels in both directions, so it
failed with an
instruction to remove it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes argv routing for nested commands when a subcommand name
matches the root default; behavior is now aligned with the spec and
corpus, but it is a user-visible parse change for those edge cases.
> 
> **Overview**
> **`default_subcommand` is spec-wide and root-only**, but usage-lib was
resolving the declared name against whatever command the parser was on.
That let nested commands inherit a default when they merely had a
subcommand with the same name (e.g. top-level `default_subcommand "ls"`
made `ex config zzz` route into `config ls`).
> 
> The discovery-phase guard now requires **`out.cmds.len() == 1`** in
addition to the existing once-per-parse flag, so default routing runs
only at the root. **`ex config zzz`** becomes **`unexpected_arg`**
instead of silently descending; root-level routing is unchanged.
> 
> The corpus case **`default-is-declared-for-the-root`** drops its
divergence label and expects agreement. **PLAN.md** records the fix in
usage-lib rather than an open decision. A focused unit test covers
nested vs root behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f1c2af9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

## What was happening

`default_subcommand` is a property of the `Spec`, declared once at the
top — there is no
syntax for a per-command one. But the parser looked that single name up
against
`out.cmd`, whichever command it was standing on. So a command with an
unrelated subcommand
of the same name silently acquired a default:

```
spec: default_subcommand "ls"; cmd ls; cmd config { cmd ls { arg "[WHAT]" } }

before: ["config", "zzz"] -> cmds ["ex", "config", "ls"], args [("WHAT", "zzz")]
after:  ["config", "zzz"] -> error: unexpected word: zzz
```

Nothing declared that behavior and nothing could have, which is what
makes it a bug rather
than a feature. Gated on still being at the root, which `out.cmds`
already records — it holds
just the root until something descends.

## Verification

- A usage-lib test asserting both halves: the nested command does *not*
route, and the root
  still does. Mutation-checked — with the guard removed it fails.
- All 351 existing usage-lib tests still pass, including the five that
already covered
`default_subcommand` (explicit selection, same-named child, nested
subcommands via mount,
  and discovery precedence).
- The corpus vector that recorded this as a divergence is now an
ordinary agreeing vector.
**Deleting its label was not optional:** `reference_labels_are_accurate`
checks labels in
both directions, so it failed with `usage-lib now agrees — delete the
label`. That is the
mechanism working as intended, and it is also the proof the fix took
effect.

## Provenance

Found while teaching usage-argv to route on this property (#848): the
two implementations
disagreed, and rather than guess I recorded it as a divergence pending
your decision. You
said fix it, so this is that fix.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5;
version: unavailable.*

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant