Skip to content

refactor(spec): make missed SpecCommand fields a compile error, and fix the four that were already missed - #740

Merged
jdx merged 7 commits into
mainfrom
refactor/spec-field-coverage
Jul 25, 2026
Merged

refactor(spec): make missed SpecCommand fields a compile error, and fix the four that were already missed#740
jdx merged 7 commits into
mainfrom
refactor/spec-field-coverage

Conversation

@jdx

@jdx jdx commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Follow-up to the review on #739, where effect was dropped by SpecCommand::merge and by the docs model. Neither omission could have been caught by anything but a reviewer noticing.

The problem

Adding a field to SpecCommand means updating seven places — the struct, Default, the prop-parse arm, the child-node-parse arm, the KDL serializer, merge, the builder, and docs::models::SpecCommand + its From. Nothing enforces any of them, and every omission fails silently.

Auditing every existing field against the three impls turned up four fields already falling through, all on main:

field gap effect
deprecated not carried by merge an included spec silently un-deprecates a command
restart_token never reaches the docs model no template can render it
examples not written by the KDL serializer spec.to_string() drops every example
help_md, before_help_md, after_help_md accepted only as props, serialized as child nodes any spec with markdown help fails to reparse: Error: unsupported cmd key help_md

The last one is the worst and I only found it because of the test in the final commit.

The fix

merge and both From impls now destructure their source with no ... Add a field and you get three compile errors pointing at exactly the places that owe it a decision. Runtime-derived fields are bound to _ with a comment so the reason is visible rather than implied. No macros, no new machinery, no runtime cost.

A round-trip test covers what the compiler can't. The parser is a match on node names, not a struct pattern, so a field the serializer writes and the parser rejects still compiles — which is exactly how the help_md bug survived. The test parses a spec exercising every field, serializes it, reparses, and compares the two serde representations. Comparing serialized values rather than named fields means a field added later is covered without anyone remembering to extend the test.

Commits

Each is reviewable on its own, and the refactor is behavior-neutral:

  1. refactor(spec): exhaustive destructuring — no behavior change; the four gaps are bound with NOTE comments so they're visible before being fixed
  2. fix(spec): carry deprecated through merge
  3. fix(docs): pass restart_token to the docs model
  4. fix(spec): serialize examples to KDL
  5. fix(spec): accept help_md / before_help_md / after_help_md as child nodes
  6. test(spec): assert a spec survives a KDL round-trip

Considered and skipped

A #[derive(SpecNode)] macro generating parse/serialize/merge from field attributes would cover all of it, but the irregular fields are a real tax — deprecated accepts bool-or-string, alias splits into visible and hidden, the collection children each parse differently. Destructuring plus the round-trip test gets nearly the same coverage for a fraction of the machinery. Worth revisiting if the struct keeps growing.

Notes

  • Branched off main rather than #739, so it's independently mergeable. Whichever lands second gets a trivial conflict in the destructuring lists (one added binding for effect).
  • subcommand_lookup became pub(crate) so other modules can name it in a pattern. Still private outside the crate.
  • serde_json added as a dev-dependency for the round-trip comparison.

Verified

cargo test -p usage-lib passes, 271 + doctests. cargo test --workspace has the same two pre-existing failures in usage-cli --test examples (test_usage_double_slash_execution, ..._old) present on a clean main. cargo clippy has one pre-existing error in choices.rs under a feature-gated path, also present on main and untouched here.

This PR was generated by an AI coding assistant.


Note

Medium Risk
Changes core spec merge, KDL parse/serialize, and docs mapping used when composing specs (includes/mounts), but fixes are narrow and guarded by new round-trip and merge tests.

Overview
SpecCommand maintenance is enforced at compile time by replacing .. skips with exhaustive destructuring in merge, KDL serialization (From<&SpecCommand> for KdlNode), and the docs From impl—new struct fields must be handled explicitly in each path.

Behavior fixes close four silent gaps: deprecated is merged like other optional metadata; restart_token is copied into the docs model; examples are emitted when serializing to KDL; and help_md / before_help_md / after_help_md are accepted as child nodes on reparse (matching how they are written), so round-tripped specs no longer fail with unsupported help_md keys.

Tests add deprecated merge coverage and a KDL parse → to_string() → reparse test that compares full serde_json snapshots, plus fixture guards so the round-trip exercise stays meaningful. subcommand_lookup is pub(crate) only so other modules can name it in destructuring patterns.

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

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jdx, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: f5c0d5d6-3861-42ed-bb40-5955e7ca16ce

📥 Commits

Reviewing files that changed from the base of the PR and between 405cebf and 53055a0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • lib/Cargo.toml
  • lib/src/docs/models.rs
  • lib/src/spec/cmd.rs

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.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes SpecCommand field propagation exhaustive and repairs previously omitted merge, documentation-model, and KDL round-trip behavior.

  • Preserves deprecated during command merges and exposes restart_token to documentation renderers.
  • Serializes examples and accepts markdown-help child nodes during KDL parsing.
  • Adds a comprehensive round-trip fixture, including the fields identified in the previous review thread.
  • Adds serde_json as a test-only dependency for structural comparisons.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/src/spec/cmd.rs Exhaustive destructuring now guards command merging and KDL serialization, while the expanded round-trip fixture populates all fields identified in the previous review.
lib/src/docs/models.rs The documentation model now carries restart_token, and exhaustive source destructuring makes future omitted-field decisions compile-time visible.
lib/Cargo.toml Adds serde_json as a development dependency for full-model round-trip assertions.
Cargo.lock Records the usage-lib development dependency on the already-resolved serde_json package.

Reviews (3): Last reviewed commit: "test(spec): exercise every round-trippab..." | Re-trigger Greptile

Comment thread lib/src/spec/cmd.rs
@jdx

jdx commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Good catch, and it mattered — before_help_md and after_help_md are parser paths this branch adds, so the fix was shipping untested. Comparing two default values is always equal, which is exactly how a round-trip test degrades into passing for free.

Fixed by populating before_help_long, after_help_long, before_help_md, after_help_md, command-level complete, and mounts in the fixture.

I also added a guard so this can't silently regress: after the equality check, the test asserts each of those fields is actually non-default in the parsed spec, and fails with fixture does not exercise \`` otherwise. Verified it isn't vacuous by adding a bogus field name and watching it fail.

This comment was generated by an AI coding assistant.

@jdx
jdx enabled auto-merge (squash) July 25, 2026 20:40
jdx and others added 7 commits July 25, 2026 20:41
…be missed

Adding a field to SpecCommand means updating seven places, and nothing
enforces any of them. Miss one and data is silently dropped: a merge that
loses a value, a docs model that can't see it, a spec that doesn't survive
serialization.

Make merge, From<&SpecCommand> for KdlNode, and the docs model's From
destructure their source with no `..`, so a new field is a compile error in
each place that owes it a decision. Runtime-derived fields are bound to `_`
with a comment rather than skipped, so the reason is visible.

Behavior is unchanged. Three fields turned out to already fall through gaps;
each is bound with a NOTE here and fixed in its own commit so the change is
reviewable on its own:

- `deprecated` is not carried by merge
- `restart_token` never reaches the docs model
- `examples` is not serialized to KDL

subcommand_lookup becomes pub(crate) so other modules can name it in a
pattern; it stays private outside the crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge composes included and mounted specs onto a command. It handled every
other optional field but not `deprecated`, so an overlay that merely
restated a command's help silently un-deprecated it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
restart_token was parsed and serialized but never copied into
docs::models::SpecCommand, so no doc, manpage or CLI-help template could
render it even though the spec declared it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SpecExample already had a KdlNode conversion, but From<&SpecCommand> for
KdlNode never called it, so `spec.to_string()` silently dropped every
example a spec declared.

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

These three were only accepted as props, but From<&SpecCommand> for KdlNode
writes them as child nodes. Any spec that set markdown help therefore
serialized to KDL that usage itself refused to parse:

    Error: unsupported cmd key help_md

Mirror how long_help, before_long_help and after_long_help are already
handled in the child-node match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exhaustive destructuring makes the compiler catch a field that merge or a
serializer forgets, but it cannot catch the parser: that is a match on node
names, not a struct pattern, so a field the serializer writes and the parser
rejects compiles fine. Both bugs fixed in the preceding two commits were of
that shape.

Parse a spec exercising every field, serialize it, reparse, and compare the
serde representations. Comparing serialized values rather than named fields
means a field added later is covered without anyone remembering to extend
this test.

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

The fixture left before_help_long, after_help_long, before_help_md,
after_help_md, mounts and command-level complete at their defaults, so
equality passed whether or not those fields survived. Two of them are
parser paths this branch adds, which meant the fix shipped untested.

Populate them, and assert that each field is actually non-default in the
parsed spec. Without that guard the test degrades silently: an edit that
empties the fixture still passes, because comparing two empty values is
always equal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the refactor/spec-field-coverage branch from d5d7838 to 53055a0 Compare July 25, 2026 20:42
@jdx
jdx merged commit 9ab337e into main Jul 25, 2026
6 checks passed
@jdx
jdx deleted the refactor/spec-field-coverage branch July 25, 2026 20:43
tmeijn pushed a commit to tmeijn/dotfiles that referenced this pull request Aug 11, 2026
⚠️ **CAUTION: this is a major update, indicating a breaking change!** ⚠️

This MR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [usage](https://github.com/jdx/usage) | tools | major | `3.5.6` → `5.1.0` |

MR created with the help of [el-capitano/tools/renovate-bot](https://gitlab.com/el-capitano/tools/renovate-bot).

**Proposed changes to behavior should be submitted there as MRs.**

---

### Release Notes

<details>
<summary>jdx/usage (usage)</summary>

### [`v5.1.0`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#510---2026-08-09)

[Compare Source](jdx/usage@v5.0.0...v5.1.0)

##### 🚀 Features

- **(spec)** parse usage comments from strings by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;782](jdx/usage#782)

##### 🐛 Bug Fixes

- **(spec)** avoid inferred metadata from included specs by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;786](jdx/usage#786)

##### 🧪 Testing

- **(windows)** make the suite runnable on Windows by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;771](jdx/usage#771)

##### 📦️ Dependency Updates

- update rust crate rmcp to v3 by [@&#8203;renovate\[bot\]](https://github.com/renovate\[bot]) in [#&#8203;780](jdx/usage#780)

### [`v5.0.0`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#500---2026-08-02)

[Compare Source](jdx/usage@v4.1.0...v5.0.0)

##### 🚀 Features

- **(cli)** allow overriding the shell program with USAGE\_SHELL\_<SHELL> by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;767](jdx/usage#767)

##### 🐛 Bug Fixes

- **(cli)** forward parsed args to WSL bash via WSLENV on windows by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;764](jdx/usage#764)
- **(cli)** let generate markdown write to stdout by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;766](jdx/usage#766)
- **(complete)** use `type -P` so the CLI-presence guard ignores shell functions by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;760](jdx/usage#760)
- **(parse)** enforce double\_dash="required" for positional args by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;762](jdx/usage#762)
- **(windows)** run `run=` scripts with sh when available by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;765](jdx/usage#765)

##### 🎨 Styling

- fix clippy and deprecation warnings in test and bench targets by [@&#8203;JamBalaya56562](https://github.com/JamBalaya56562) in [#&#8203;763](jdx/usage#763)

### [`v4.1.0`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#410---2026-07-30)

[Compare Source](jdx/usage@v4.0.0...v4.1.0)

##### 🚀 Features

- **(cli)** declare what each usage command does to the world by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;751](jdx/usage#751)
- **(mcp)** serve a usage spec to an agent over stdio by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;746](jdx/usage#746)
- **(spec)** add a top-level `repository` field by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;747](jdx/usage#747)

##### 🐛 Bug Fixes

- **(parse)** keep a re-declared global's aliases on one flag by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;752](jdx/usage#752)
- complete repeated variadic args by [@&#8203;Jai-JAP](https://github.com/Jai-JAP) in [#&#8203;753](jdx/usage#753)

##### New Contributors

- [@&#8203;Jai-JAP](https://github.com/Jai-JAP) made their first contribution in [#&#8203;753](jdx/usage#753)

### [`v4.0.0`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#400---2026-07-25)

[Compare Source](jdx/usage@v3.6.0...v4.0.0)

##### 🚀 Features

- **(spec)** allow effect= on flags and args by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;742](jdx/usage#742)

### [`v3.6.0`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#360---2026-07-25)

[Compare Source](jdx/usage@v3.5.7...v3.6.0)

##### 🚀 Features

- **(spec)** add effect= to declare what a command does to the world by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;739](jdx/usage#739)

##### 🚜 Refactor

- **(spec)** make missed SpecCommand fields a compile error, and fix the four that were already missed by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;740](jdx/usage#740)

### [`v3.5.7`](https://github.com/jdx/usage/blob/HEAD/CHANGELOG.md#357---2026-07-25)

[Compare Source](jdx/usage@v3.5.6...v3.5.7)

##### 🐛 Bug Fixes

- **(parse)** don't leak the mounting CLI's flags into mounted commands; scan past non-global flags by [@&#8203;jdx](https://github.com/jdx) in [#&#8203;738](jdx/usage#738)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever MR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODguMCIsInVwZGF0ZWRJblZlciI6IjQzLjI4OC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJSZW5vdmF0ZSBCb3QiLCJhdXRvbWF0aW9uOmJvdC1hdXRob3JlZCIsImRlcGVuZGVuY3ktdHlwZTo6bWFqb3IiXX0=-->
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