Skip to content

feat(config): hold a value to the choices its setting declares - #868

Merged
jdx merged 1 commit into
agent/config-envfrom
agent/config-choices
Aug 13, 2026
Merged

feat(config): hold a value to the choices its setting declares#868
jdx merged 1 commit into
agent/config-envfrom
agent/config-choices

Conversation

@jdx

@jdx jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner

A spec's choice nodes reach the docs, the JSON schema and completions — and, until this, nothing
that resolved a value. PropMeta had no choices at all, so a CLI documenting three allowed values
took a fourth in silence and failed later, somewhere that could not say why. That is the declaration
and the behaviour drifting apart, which is the thing this crate exists to stop.

$ HK_STASH=svn hk check
stash expected one of git, patch-file, none but has `svn` (HK_STASH)

Where the check goes. Beside the type check, in both paths a value can arrive by — text through a
parser, and a structured value out of a file. Checking one and not the other is how a rule ends up
applying to the environment and not to the file beside it, so there is a test for each.

Collections are checked item by item. Choices on a list<string> mean each item is one of them —
the rule usage g json-schema already follows, putting the enum on every value position rather than
on the container — and the item is what the message quotes, since naming the whole list leaves
the user to work out which of five is wrong.

A refused value costs its own key, exactly like a value of the wrong type: a bad line in a
system-wide file must not stop a CLI from starting for every user on the machine.

Const::matches compares without building the Value a choice stands for: this runs once per
declared choice for every value supplied, and a setting with choices is usually a string, where the
comparison would otherwise allocate a copy to throw away. A setting with no choices returns
immediately.

explain now lists what a setting will take — somebody reading an explanation after a refusal has
already scrolled past the warning.

Verification

107 tests in the crate. Four mutations, each killing the right test: the text path unchecked, the
structured path unchecked, list items unchecked, and an empty choice list refusing everything (which
takes 30 tests with it, since it makes every ordinary setting unsettable).

Next in the stack: the generator emitting these from the spec, and refusing a declared default that
is not among them — an authoring mistake that would otherwise be a warning on every run of a shipped
binary.

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

A spec's `choice` nodes reached the docs, the JSON schema and completions, and
nothing that resolved a value. So a CLI documenting three allowed values took a
fourth in silence and failed later, somewhere that could not say why — the
declaration and the behaviour drifting apart, which is the thing this crate
exists to stop.

The check sits beside the type check, in both paths a value can arrive by, and
costs a setting without choices nothing. A collection is checked item by item,
because choices on a `list<string>` mean each item is one of them — the rule
`usage g json-schema` already follows, putting the enum on every value position
rather than on the container — and the *item* is what the message quotes, since
naming the whole list leaves the user to work out which of five items is wrong.

A refused value costs its own key, like a value of the wrong type: a bad line in
a system-wide file must not stop a CLI from starting for every user on the
machine. And `explain` now lists what a setting will take, because somebody
reading an explanation after a refusal has already scrolled past the warning.
@coderabbitai

coderabbitai Bot commented Aug 13, 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: 3a2f84c2-e07b-4c5b-9c0d-93aa43eb77aa

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.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds runtime enforcement and explanation output for configuration choices.

  • Adds choice metadata and scalar matching to configuration properties.
  • Validates parsed and structured values, including collection items.
  • Displays allowed values in explain.
  • The generated-registry path does not yet carry spec choices into the new runtime metadata.

Confidence Score: 4/5

The generated-registry path must populate the new choices field before merging, otherwise spec-declared restrictions remain unenforced.

Both ingestion paths correctly consult PropMeta::choices, but the production generator leaves that field at its empty default, disabling the new validation for generated registries.

Files Needing Attention: config/src/registry.rs and config-build/src/emit.rs

Important Files Changed

Filename Overview
config/src/registry.rs Adds choice metadata and recursive validation, but the metadata remains empty in generated registries because the producer was not updated.
config/src/layer.rs Applies choice validation consistently after parsing or coercion in both value-ingestion paths and returns per-key warnings.
config/src/value.rs Adds allocation-free, type-strict matching between supported scalar constants and runtime values.
config/src/explain.rs Displays declared allowed values when choices are available in runtime metadata.

Fix all with Greploop

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(config): hold a value to the choice..." | Re-trigger Greptile

Comment thread config/src/registry.rs
/// Empty means anything the type allows. Declared in the spec as `choice` nodes, where they
/// already reach the docs, the JSON schema and completions — and, until this, nothing that
/// *resolved* a value, so a CLI documenting three allowed values accepted a fourth in silence.
pub choices: &'static [Const],

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 Generated registries omit choices

When a CLI generates its registry from a spec containing choice nodes, the generator leaves PropMeta::choices empty, so refuses accepts every value and the declared restrictions remain unenforced.

Fix in Claude Code

@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 e78b52b. Configure here.

Comment thread config/src/registry.rs
parse: None,
envs: &[],
bindings: &[],
choices: &[],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Choices never reach generated registries

High Severity

PropMeta now carries choices and the layer paths refuse values that miss them, but usage-config-build still never writes the field — it stays the empty slice from PropMeta::new. Spec choice nodes therefore still never reach resolution for any generated registry, so a CLI documenting three allowed values continues to accept a fourth in silence.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e78b52b. Configure here.

@github-actions

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▂▂▂███ 175,112,529 → 175,078,982 -0.02% 15.80 → 16.65ms +5.35%
startup ▁▁▁▁▁▁███ 1,222,031 → 1,222,171 +0.01% 0.95 → 0.96ms +1.09%

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 29823 5960254 199x
usage: argv -> struct                             862 ns      0.86 µs
clap: build tree + parse -> struct             501591 ns    501.59 µs
clap: parse -> struct, tree reused              23594 ns     23.59 µs
clap: build tree only                          312165 ns    312.16 µs

e78b52b459b7 vs 7735e3540047 · measured on the runner, not pushed to the history.

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Both bots found the same gap, and it is real — but it is the next commit rather than a defect here:
#869 sits directly on top of this branch and emits choices from the spec's choice nodes, with an
end-to-end test that runs a config file through the generated registry and watches the merge refuse
a value the spec does not allow.

They are stacked, so this never reaches a release without it. Split because they are two different
things to get wrong: what the rule is (here — where it is checked, what a collection means, what a
refusal costs) and whether the declaration reaches the registry (there). A hand-written registry —
the shape every test in this crate uses, and what a CLI writes before it adopts the generator — is
enforced by this commit alone.

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

@jdx
jdx merged commit 9a66098 into main Aug 13, 2026
9 checks passed
@jdx
jdx deleted the agent/config-choices branch August 13, 2026 23:10
jdx added a commit that referenced this pull request Aug 14, 2026
…869)

The step between #868's enforcement and the spec that declares it:
`choice` nodes become `Const`s in
the generated registry, so the values a CLI documents are the values it
accepts. The end-to-end test
runs a config file through the *generated* registry and watches the
merge refuse `stash = "svn"`
while the declared default stands — the three places that used to
disagree (docs, schema, resolution)
now come from one declaration.

**A default the choices do not allow is refused here.** At run time it
is seeded as the bottom layer
and then goes through the same check as every other value, so it would
be a warning on every run of a
shipped binary — for a mistake only the author of the spec can fix:

```
`stash` defaults to `svn`, which is not one of the values it allows: git, none
```

A list default is held to them item by item, the way the values
themselves are.

Two mutations: not emitting the choices (caught by the
regenerate-and-diff test, which is how any
emitter change is caught here), and checking only the scalar default and
not the list one.

Still deliberately out: generating an *enum* per choice-bearing setting.
It needs variant naming,
a `FromValue` impl and its own error, and none of that is needed for the
values to be enforced —
`String` plus a refusal at the boundary is the smaller thing that makes
the declaration true.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches config resolution, codegen defaults, and value
display/coercion across three crates; mistakes could change which values
are accepted or what defaults ship, though coverage is heavy.
> 
> **Overview**
> **Choices in the generated registry** — `config-build` now emits
`choices` on each `PropMeta` and depends on `usage-config` so validation
uses the same `Ty::coerce` the runtime uses.
> 
> **Build-time spec validation** — The generator rejects defaults and
choices the declared type cannot read, defaults outside the allowed set
(including list defaults checked per-item or as a whole for scalar/`any`
types), and emits defaults via `coerced_const` so seeded values match
what resolution expects (e.g. `default "yes"` on `list<bool>` becomes
bool literals).
> 
> **Runtime choice matching** — `PropMeta::refuses` walks collections
using the **declared** type (not value shape), and `allows` coerces each
choice before comparing. `Const::matches` falls back to matching
`display()` text so cross-shaped literals (e.g. `choice 4` vs string
`"4"`, `choice "yes"` vs bool) agree with coercion.
> 
> **Shared float rendering** — `usage-config` and `usage-lib` format
whole-number floats with a decimal point (`1.0` vs `1`) so
default/choice comparisons and error messages stay consistent across
spec parse, codegen, and merge warnings.
> 
> **Tests** — Fixture `log_format`, golden output, end-to-end resolve
for invalid `stash`, and broad refusal tests in
`config-build/tests/refusals.rs`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
964d72b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Configuration settings now expose their permitted values.
  * Added the `log_format` setting with supported values `1` and `2`.
* Compatible scalar values are recognized across textual and numeric
representations.

* **Bug Fixes**
* Invalid scalar and list defaults are rejected with clear messages
showing invalid and allowed values.
* Invalid setting values fall back to the effective default and generate
a warning.
* Configuration validation messages now format values more clearly,
including whole-number decimals.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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