Skip to content

feat(go): emit Go parse tables from a spec, which is what Go has instead of a derive - #931

Merged
jdx merged 3 commits into
mainfrom
go/generate
Aug 17, 2026
Merged

feat(go): emit Go parse tables from a spec, which is what Go has instead of a derive#931
jdx merged 3 commits into
mainfrom
go/generate

Conversation

@jdx

@jdx jdx commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Go has no macros. What a Rust CLI gets from #[derive(Cli)] at compile time, a Go CLI has to get from a generator at build time — so this is the same milestone for usage-go that usage-derive is for the Rust side: the point at which a spec, rather than a hand-written table, is what a CLI is declared in.

//go:generate usage generate go -f mycli.usage.kdl -o tables.go

Follows #921 and #922, which landed the binder and the conformance suite.

Binding tables only

Help text, choices, defaults and env are absent, for the same reason they are absent from the Rust hot path: a successful parse never reads them, and mise's several hundred kilobytes of help strings do not belong in front of a parser. A cold table is separate work.

The output is gofmt-clean as it comes out

This took some care and is worth it — the alternative is every adopter running a formatter before they can commit, and this repo's own CI failing gofmt -l on the table #932 checks in. gofmt pads within runs of consecutive single-line entries and starts a new run after anything spanning lines, so the emitter models a literal as a list of fields and blocks rather than printing strings.

Verified against mise's spec, usage's own, and the examples spec: gofmt -w changes nothing in any of them.

What the author gets beyond speed

The key constants. An event carries a Key, so generated code dispatches on mise.FlagUseGlobal rather than comparing strings — and a flag renamed in the spec then fails to compile instead of silently never matching.

Three things are resolved at generation time so the parser reads one field per command instead of walking: unknown_flags inheritance, default_subcommand into a pointer at the node it names, and hidden aliases folded in beside visible ones (hiding is a help-output concern; binding never reads it).

Identifier collisions are ordinary rather than exotic, and are handled: mise declares both a macos-defaults command and a macos defaults path, and both want to be spelled CmdMacosDefaults.

Checked end to end

The fixture that keeps this honest in CI is #932, but before committing I confirmed the generated mise tables compile, parse mise use -g node@20, resolve x to exec through its hidden alias, split tasks run build extra --dry-run -- --verbose across ARGS and ARGS_LAST, and produce a package with no init function whose Root is a type D symbol — 211 commands and 711 flags that cost nothing before main.


Stack created with GitHub Stacks CLIGive Feedback 💬

🤖 Generated with Claude Code


Note

Medium Risk
Large new code generator with many spec edge cases, but it is additive build-time output; mistakes would break adopters' generated Go compile/parse behavior rather than runtime security.

Overview
Introduces usage generate go, the Go analogue of compile-time Rust derives: specs become check-in-ready Go source for github.com/jdx/usage/go/argv, typically via //go:generate usage generate go -f mycli.usage.kdl -o tables.go.

The new usage::go emitter writes binding-only tables (commands, flags, args, keys, aliases)—not help, choices, or defaults—using package-level var trees consumable by the linker without init. Output is gofmt-aligned (const blocks and struct field runs). Generation resolves unknown_flags inheritance, default_subcommand against root direct children only (avoids wrong deep matches), folds hidden aliases into bindings, and deduplicates Go identifier collisions with suffixed names.

The CLI accepts -f/--spec, -o, and -p/--package; invalid explicit package names error instead of silent mangling. Spec metadata, man page, Fig completion, docs, and command read/write effects are updated for the new subcommand.

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

Summary by CodeRabbit

  • New Features

    • Added generate go to generate Go source code from a CLI usage specification.
    • Supports specification files or inline input, custom output files, and Go package names.
    • Generated output includes commands, flags, arguments, aliases, subcommands, and related parsing behavior.
    • Validates package names and safely handles generated identifiers and special characters.
  • Documentation

    • Added CLI help, reference documentation, usage examples, and shell completion support for the new command.

…ead of a derive

Go has no macros. What a Rust CLI gets from `#[derive(Cli)]` at compile time, a
Go CLI has to get from a generator at build time, so `usage generate go` is the
same milestone for usage-go that usage-derive is for the Rust side — the point
at which a spec, rather than a hand-written table, is what a CLI is declared in.

    //go:generate usage generate go -f mycli.usage.kdl -o tables.go

It emits binding tables and nothing else. Help text, choices, defaults and `env`
are absent for the same reason they are absent from the Rust hot path: a
successful parse never reads them, and mise's several hundred kilobytes of help
strings do not belong in front of a parser. A cold table is separate work.

The output is gofmt-clean as it comes out, which took some care and is worth it:
the alternative is every adopter running a formatter before they can commit, and
this repo's own CI failing `gofmt -l` on the table it will check in. gofmt pads
within runs of consecutive single-line entries and starts a new run after
anything spanning lines, so the emitter models a literal as a list of fields and
blocks rather than printing strings. Verified against mise's spec, usage's own,
and the examples spec: `gofmt -w` changes nothing in any of them.

What the generated file gives an author beyond speed is the key constants. An
event carries a Key, so generated code dispatches on `mise.FlagUseGlobal`
instead of comparing strings, and a flag that is renamed in the spec fails to
compile rather than silently never matching.

Three things resolved at generation time, so the parser reads one field per
command instead of walking: `unknown_flags` inheritance, `default_subcommand`
into a pointer at the node it names, and hidden aliases folded in beside visible
ones — hiding is a help-output concern and binding never reads it.

Identifier collisions are ordinary rather than exotic, and are handled: mise
declares both a `macos-defaults` command and a `macos defaults` path, and both
want to be spelled `CmdMacosDefaults`.

Checked end to end before committing, though the checked-in fixture that will
keep it honest is the next commit in this stack: the generated mise tables
compile, parse `mise use -g node@20`, resolve `x` to `exec` through its alias,
split `tasks run build extra --dry-run -- --verbose` across ARGS and ARGS_LAST,
and produce a package with no init function whose Root is a type D symbol — 211
commands and 711 flags that cost nothing before main.

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

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 8dea4480-437f-4494-8ae8-22a9fd3c3f8e

📥 Commits

Reviewing files that changed from the base of the PR and between ad5a6b3 and 858445b.

📒 Files selected for processing (1)
  • lib/src/go/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/src/go/mod.rs

Included review availability: Your plan includes up to 4 reviews per rolling hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds a Go parse-table emitter to the library and exposes it through a new generate go CLI command. The command supports file or inline specs, package selection, stdout or file output, command effects, completion metadata, and reference documentation.

Changes

Go generation flow

Layer / File(s) Summary
Go source emitter
lib/src/go/mod.rs, lib/src/lib.rs
Adds Go source generation for command trees, flags, arguments, aliases, subcommands, defaults, validation, escaping, package handling, and edge-case tests.
CLI command integration
cli/src/cli/generate/..., cli/src/command_effects.rs
Adds generate go, resolves file or inline input, validates package names, writes to stdout or a file, and assigns read/write effects.
CLI specification and documentation
cli/assets/*, cli/usage.usage.kdl, docs/cli/reference/*
Registers the command in completion metadata, usage definitions, manuals, and generated reference pages.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 85844

This PR adds build-time Go table generation and related documentation and tests; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as generate go CLI
  participant GoRun as Go::run
  participant Generator as go::generate
  participant Output as stdout or output file
  CLI->>GoRun: resolve file or inline spec
  GoRun->>Generator: pass Spec and GoOptions
  Generator-->>GoRun: generated Go source
  GoRun->>Output: write source
Loading

Possibly related PRs

  • jdx/usage#905: Introduced the command-effect classification used by generate go.

Poem

A rabbit packs Go tables tight,
With flags and commands lined just right.
Specs hop in, source hops out,
Packages bloom without a doubt.
generate go bounds through the night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding Go parse-table generation from a specification.

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 changed the title go/generate feat(go): emit Go parse tables from a spec, which is what Go has instead of a derive Aug 16, 2026
@greptile-apps

greptile-apps Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Go parse-table generator and wires it into the CLI, generated references, and command-effect metadata. It also adds identifier sanitization, collision handling, inherited parser settings, and direct-child default-subcommand resolution.

  • Emits binding-only Go command, flag, argument, and key tables from usage specs.
  • Adds usage generate go with file, output, inline-spec, and package options.
  • Adds snapshots for complete output, identifier collisions, inherited unknown-flag behavior, and default-subcommand wiring.

Confidence Score: 4/5

The PR is not yet safe to merge because a valid root alias/name collision can make generated Go tables select the wrong default command.

The repaired lookup is restricted to root children, but its first-match collision behavior differs from the reference parser and can route omitted-command input into the wrong command.

Files Needing Attention: lib/src/go/mod.rs

Important Files Changed

Filename Overview
lib/src/go/mod.rs Implements the Go table emitter, but colliding root names and aliases can still wire the repaired default-subcommand pointer to the wrong child.
cli/src/cli/generate/go.rs Adds the CLI wrapper with explicit package validation, shared spec loading, generation, and output handling.
cli/src/cli/generate/mod.rs Registers and dispatches the new Go generator through the existing generation pipeline.
cli/src/command_effects.rs Classifies generation as read-only unless an output file is supplied.
cli/usage.usage.kdl Adds the dogfooded command specification and its four options.
lib/src/lib.rs Exposes the new Go generator module from the library.

Fix all with Greploop

Fix All in Claude Code

Reviews (3): Last reviewed commit: "fix(go): reserve the suffixed identifier..." | Re-trigger Greptile

Comment thread lib/src/go/mod.rs
Comment thread lib/src/go/mod.rs Outdated

@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 3 potential issues.

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 1ea0918. Configure here.

Comment thread lib/src/go/mod.rs
fields.push("Variadic: true".to_string());
if let Some(max) = arg.var_max {
fields.push(format!("VarMax: {}", clamp_var_max(max)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Variadic flag VarMax ignored

Medium Severity

For a variadic flag argument like `--include &lt;pattern&gt;...`, KDL puts var_max on the flag, but flag_literal only reads arg.var_max. That leaves VarMax out of the generated table, so the Go parser treats the flag as unbounded and can consume positionals past the intended limit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1ea0918. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not changing this one — the two spellings are different questions, and the corpus pins them apart.

On the flag's argument it bounds one occurrence's values, which binding decides, and the emitter does emit it. That is corpus vector a-bound-stops-a-variadic-flag, labelled binding:

flag "--include <pattern>…" {
  arg "<pattern>…" var=#true var_max=2
}
{Key: FlagInclude, Name: "include", ..., Variadic: true, VarMax: 2}

On the flag it counts occurrences — --include a --include b — which no single token can decide, so it is a post-binding check. That is flag-var-too-many, labelled post-binding for exactly that reason.

usage-argv's own table builder splits it the same way (f.arg.as_ref().filter(|a| a.var).and_then(|a| a.var_max)), as does the derive, whose counts_occurrences narrows the check to a repeatable flag with a single-value argument. So reading arg.var_max here matches the grammar and both Rust implementations.

I have added a test pinning both halves so the question does not have to be re-derived next time.

This comment was generated by Claude Code.

Comment thread lib/src/go/mod.rs
Comment thread lib/src/go/mod.rs
… tree

Three review findings on the emitter, one of which the checked-in mise tables in
the next commit had already made visible: `Root.DefaultSubcommand` pointed at
`oci run` rather than the top-level `run`.

`default_subcommand` names a subcommand *of the root* — the spec declares it once
at the top — but the resolution scanned every command in the tree and took the
first match in depth-first order, which for mise is `oci run`. A parse would then
have descended into a command that is not the root's child at all, so `mise build`
would have run something under `oci`. The comment above the code already said "the
root's own subcommands"; the code just did not do it, which is the version of this
mistake that survives review easiest.

The root's key was spelled rather than claimed, so a subcommand named `root` was
handed `CmdRoot` too and the file declared the constant twice and would not
compile. It goes through the same counter as everything else now, and gets
`CmdRoot2` as any other collision would.

A package name that is not a Go identifier produced a file that would not compile
either — `--package my-pkg`, or a spec whose bin is `go` or `type`, both of which
are plausible names for a CLI. Names derived from the spec are sanitized, since
the author did not choose `bin` for this purpose and cannot be asked to fix it;
an explicit `--package` is refused with a message instead, because quietly
turning `my-pkg` into `mypkg` is a surprise waiting in somebody's build script.
`is_valid_package` is public so the check and the sanitizing cannot drift.

Not changed: the fourth finding, that a variadic flag's `var_max` is dropped. The
two spellings are different questions and the corpus pins them apart. On the
flag's *argument* it bounds one occurrence's values and belongs in the binding
table, which is the corpus vector `a-bound-stops-a-variadic-flag`, and the
emitter does emit it. On the flag itself it counts *occurrences*, which no single
token can decide, so it is a post-binding check — `flag-var-too-many`, labelled
post-binding for exactly that reason. usage-argv's tables and the derive's
`counts_occurrences` split it the same way. A test now pins both halves so the
question does not have to be re-derived next time.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@lib/src/go/mod.rs`:
- Around line 93-106: Update GoNameGen::unique to ensure the generated
identifier itself is unused, not just the requested base: retry suffixes until
the candidate spelling is free, then reserve that candidate in self.taken before
returning it. Preserve the unsuffixed base when available and prevent collisions
such as a later base matching an earlier generated suffix.
- Around line 561-589: Update is_valid_package to reject the exact package names
"_" and "init", while preserving "__" as valid. Update package_ident so
sanitized results equal to either reserved name are prefixed with "cli",
alongside the existing empty, digit-starting, and keyword checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 46d66738-ff4f-4f34-911e-ec57d4ae63a8

📥 Commits

Reviewing files that changed from the base of the PR and between 25aa153 and ad5a6b3.

⛔ Files ignored due to path filters (4)
  • lib/src/go/snapshots/usage__go__tests__a_default_subcommand_points_into_the_tree.snap is excluded by !**/*.snap
  • lib/src/go/snapshots/usage__go__tests__a_whole_cli.snap is excluded by !**/*.snap
  • lib/src/go/snapshots/usage__go__tests__colliding_names_get_distinct_identifiers.snap is excluded by !**/*.snap
  • lib/src/go/snapshots/usage__go__tests__unknown_flags_are_inherited_and_overridable.snap is excluded by !**/*.snap
📒 Files selected for processing (12)
  • cli/assets/fig.ts
  • cli/assets/usage.1
  • cli/src/cli/generate/go.rs
  • cli/src/cli/generate/mod.rs
  • cli/src/command_effects.rs
  • cli/usage.usage.kdl
  • docs/cli/reference/commands.json
  • docs/cli/reference/generate.md
  • docs/cli/reference/generate/go.md
  • docs/cli/reference/index.md
  • lib/src/go/mod.rs
  • lib/src/lib.rs

Included review availability: Your plan includes up to 4 reviews per rolling hour; 1 remains after this review.

Comment thread lib/src/go/mod.rs
Comment thread lib/src/go/mod.rs
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

Nothing 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: markdown on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, startup on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.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 71984 5895248 81x
usage: argv -> struct                            1181 ns      1.18 µs
clap: build tree + parse -> struct             502313 ns    502.31 µs
clap: parse -> struct, tree reused              23824 ns     23.82 µs
clap: build tree only                          308544 ns    308.54 µs

858445b88b1d vs 25aa1535c847 · measured on the runner, not pushed to the history.

… names

Two follow-up findings on the emitter, both about emitting a file that does not
compile.

`unique` counted per base and never claimed the spelling it handed out, so a
third command could be given an identifier the second had already taken:
`macos-defaults` and `macos defaults` produce `CmdMacosDefaults` and
`CmdMacosDefaults2`, and a command named `macos-defaults2` then asked for
`CmdMacosDefaults2` directly and got it. It searches for a free candidate now and
reserves it. The third lands on `CmdMacosDefaults22`, which is unlovely and
unique; the test asserts no constant is declared twice rather than pinning the
suffix scheme, which is the property that actually matters.

`_` and `init` are refused as package names, for two different reasons that are
worth keeping straight. `package _` is rejected where it is written — `invalid
package name _`. `package init` declares perfectly well and cannot be *imported*:
an import binds the package name as an identifier in file scope, and `init` may
only be a func, so an importer gets `cannot import package as init - init must be
a func`. A table package exists to be imported, so it is out either way.

Both checked against the compiler rather than taken from the citation offered,
which is about the import and would have had this rejecting `package init` for a
reason that is not true. `__` stays valid, and only the exact names are reserved,
so `initialize` is fine.

`package_ident` now asks `is_valid_package` instead of repeating its conditions,
so the sanitizer cannot come to disagree with the check about what is acceptable
— and a test asserts that every name the sanitizer produces is one the validator
accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread lib/src/go/mod.rs
Comment on lines +285 to +289
.find(|e| {
&e.cmd.name == name
|| e.cmd.aliases.contains(name)
|| e.cmd.hidden_aliases.contains(name)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Default alias collisions misroute parsing

When a root command alias matches another root command's canonical name and default_subcommand uses that name, this first-match lookup selects the earlier command while the reference parser selects the later mapping, causing generated Go parsers to descend into and bind arguments for the wrong command.

Knowledge Base Used: Compiled argv parsing and derives

Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch on the behaviour, but I am not changing the emitter — this is a pre-existing disagreement between the two Rust implementations, and the emitter is matching the right one.

The two resolutions really do differ:

  • usage-lib builds a lookup map, inserting name → name and then alias → name per subcommand in declaration order (SpecCommand::find_subcommand). A later canonical name overwrites an earlier alias, so run resolves to the canonical run — the "later mapping" you describe.
  • usage-argv scans per candidate, checking name then aliases, and returns the first match (find_subcommand, the const fn the Rust derive uses to resolve default_subcommand at compile time). So the earlier command wins by its alias.

The emitter feeds usage-argv's Go twin, and conformance/src/argv.rs resolves it the same per-candidate way. Changing it would make the Go parser disagree with the Rust parser it is a port of, in order to agree with a third implementation — and it would not even be self-consistent, because a typed run goes through the same per-candidate scan in both usage_argv::Parser::find_subcommand and the Go findNamed. The default_subcommand pointer would then select a different command than typing the word does.

So the divergence is in subcommand resolution generally rather than in default_subcommand, and it predates this PR. The repo already has a mechanism for exactly this — a corpus vector carrying a reference.diverges note, which conformance/tests/reference.rs then checks in both directions. That is where it belongs, and writing the vector means deciding which reading the grammar wants, which is a call for @jdx rather than something to settle inside a Go codegen PR. I have flagged it separately.

Worth adding that the spec being described here is arguably ill-formed anyway: one command's alias colliding with another's canonical name means the alias is unreachable in usage-lib and the canonical name is unreachable in usage-argv. A duplicate check at the level where the whole tree is visible — where Spec::to_kdl already checks duplicate flag forms and keys — would be the real fix.

This comment was generated by Claude Code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Understood — the emitter targets usage-argv (the per-candidate scan), and changing it to match usage-lib's map-based resolution would break consistency between default_subcommand pointer resolution and a typed subcommand name going through the same findNamed path. That's a real correctness argument, not just a style preference.

The original comment overfitted to usage-lib's behavior as the reference. You're right that the divergence predates this PR and belongs in a reference.diverges corpus note rather than here.

Shall I remember this as a rule: "when reviewing usage-argv or its Go emitter, treat usage-argv's per-candidate scan (not usage-lib's map-based resolution) as the authoritative reference for subcommand resolution behavior"?

@jdx
jdx merged commit 139e4e4 into main Aug 17, 2026
11 checks passed
@jdx
jdx deleted the go/generate branch August 17, 2026 03:27
jdx added a commit that referenced this pull request Aug 17, 2026
…ft unnoticed (#932)

The generator landed in #931 with snapshot tests over small fixtures,
which prove it emits what it meant to and nothing about whether the
result works. This is the other half: mise's committed spec, generated
into `go/internal/shadow/mise`, checked in, and parsed against.

Checked in rather than built by the test, for the two reasons the Rust
shadows are — a reviewer can read the diff when the emitter's vocabulary
changes, and CI runs `mise run gen-go` and fails if regenerating
produces one. A change to the generator that nobody meant now has to be
committed rather than discovered later.

mise is the fixture because it is the largest usage CLI there is (211
commands, 711 flags, 128 positionals, four deep) and because every shape
that has been awkward to express came from it. The cases are invocations
out of its own docs, hand-written on purpose: what the generator
produces is only worth checking if it parses the words users actually
type.

## Two properties measured at scale rather than at fixture size

**Keys are unique and dense.** Generated code dispatches on a `Key`, so
two entries sharing one would bind the wrong field. The Rust derive
hashes its way around this because two macro expansions cannot see each
other; a generator sees the whole spec and can count, so a collision
would be inexcusable rather than unlucky. Checked across all 989
entries.

**A parse still allocates nothing.** A scope lookup that collected flags
into a slice, or a walk that built one per token, is invisible on a spec
with four flags and obvious on one with 711. **110 ns and 0
allocations** for `mise use -g node@20`.

## One case pins a hole rather than a property

`run --wat` is `unexpected_arg`, because mise's spec gives `run` no
positional at all — it clears them and adds `mount run="mise tasks
--usage"`, so task names come from running that, and binding does not
resolve mounts. When mounts are answered that case changes, and pinning
it means it changes loudly.

## Also here

The README's conformance count was stale on `main`: #926 imported the
argv questions clap's suite answers, taking the corpus from 123 vectors
to 152. The Go parser answers all **122** binding vectors, up from 101,
**without a change** — which is a better advertisement for the corpus
than the original number was.

---

<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>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly generated data, docs, CI, and tests; no runtime product
behavior changes beyond guarding the Go emitter output.
> 
> **Overview**
> Adds **checked-in Go binding tables** for mise’s full spec under
`go/internal/shadow/mise`, regenerated via **`mise run gen-go`** (`usage
generate go` on `benches/mise.usage.kdl`), mirroring the Rust shadow
workflow so emitter changes show up in review and CI.
> 
> **CI** now runs `gen-go` and fails if `render`, `gen-shadow`, or
`gen-go` leave a dirty tree.
> 
> **Tests** on the shadow tables cover real mise invocations, **root
`default_subcommand run`** (not `oci run`), **unique dense `Key`s**
across ~1000 entries, and **zero allocations** at full CLI scale (plus a
benchmark). **`go/README.md`** documents `//go:generate`, the shadow
package, updated corpus counts (122 binding / 30 post-binding skipped),
and drops the generator from “what is missing.”
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
dcd3446. 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>
jdx added a commit that referenced this pull request Aug 17, 2026
Binding says which token becomes which flag or argument. This says
whether what landed is acceptable, and fills in what the command line
left empty.

**The corpus goes from 122 answered to 145 of 152.**

```
$ mise run test:go
    152 vectors: 145 answered, 7 not yet
```

Follows #931 and #932. `required`, `choices`, the `env`-then-`default`
fallback, `var_min` and `var_max` all need to know something no single
token can tell you, which is why they were left out of the parser rather
than overlooked.

## Shape

They read a second, cold table — `Meta`, indexed by the same key the
parse table carries, so the two cannot drift on identity — and a program
that never applies them never touches it. The zero-allocation test now
covers a parse with metadata present, since the property that matters is
that binding does not reach for any of this.

Deliberately **not a framework**. `Fill` and `Check` are pure functions
over what binding produced, because the caller is the one that knows how
it accumulated: generated code assigns to a field, a harness with no
target type appends to a slice. Inventing a value model here would force
both through it.

## Three things the corpus settled that guessing would have got wrong

**An environment variable set to the empty string is set.** `EX_JOBS=`
is a value — treating empty as unset would make it mean something no
other empty value in the grammar means. The value is one token, never
re-split: quoting is the shell's job and there was no shell here at all.

**A flag that holds no value reads its variable as a yes or a no**, by
an allow-list — `1`, `true`, `True`, `TRUE` — matching usage-lib
exactly. So `yes`, `on` and `TrUe` are all false. Worth pinning rather
than discovering, since `EX_VERBOSE=0` meaning verbose would be a trap.
The corpus pins four cases; `TestEnvTruth` records the rest.

**`var_max` counts occurrences here, never values.** A variadic's
per-occurrence bound is a limit binding applies, so judging the total
again afterwards would fail an invocation that never broke it. (This is
the same distinction as the one discussed on #931.)

## What is left

Seven vectors, all relationships *between* flags — `conflicts`,
`overrides`, `required_unless` — which need a name resolved to the entry
it refers to. Listed by id with a reason rather than inferred from the
spec, because `overrides-loser-is-not-refilled-from-env` is as much an
env question as an overrides one and inference would have exempted
vectors nobody meant to exempt. The skip count is asserted against the
list, so it cannot become a way of hiding failures.

Also still open, and noted in the README: `usage generate go` emits the
parse tables but not the `Meta` ones, so these rules are reachable today
from a spec lowered at run time rather than from a generated package.
Proving them against the corpus came first, the way the binder did
before the generator.

---

<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>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Behavior changes CLI validation semantics (env, defaults, required,
choices, variadic bounds) and expands the conformance harness; parser
hot path is explicitly tested to stay zero-alloc, but adopters must call
the new APIs correctly.
> 
> **Overview**
> Adds a **cold `Meta` / `Metadata` table** alongside the parse tables
and **`Fill` / `Check`** in `post.go` so binding stays allocation-free
while **required**, **choices**, **env → default** fallback,
**`var_min`**, and **flag `var_max` (occurrences)** are judged after the
last token. Post-binding failures reuse **`argv.Error`** with new codes
and fields (`Name`, `Choices`, `Bound`/`Got`).
> 
> **`spec.Build()`** now emits parse tables and metadata in one pass
(shared keys); the JSON spec builder picks up the extra declaration
fields and usage-lib-aligned rules (defaults on nested arg values, no
nested `env`, choices on the value).
> 
> **Conformance** runs post-binding vectors with per-vector env,
accumulates by flag/arg key along the command path, and documents **7
remaining** inter-flag vectors (`conflicts`, `overrides`,
`required_unless`) in an asserted `notYet` list. README updates the pass
count (**145 / 152**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
de6de50. 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>
@jdx
jdx restored the go/generate branch August 17, 2026 04:36
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