Skip to content

feat(spec): the config vocabulary - #835

Merged
jdx merged 2 commits into
agent/config-bugsfrom
agent/config-vocabulary
Aug 13, 2026
Merged

feat(spec): the config vocabulary#835
jdx merged 2 commits into
agent/config-bugsfrom
agent/config-vocabulary

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Stacked on #832. What a config block can say — so docs, a JSON schema, completions and a resolver come from one declaration instead of the five hand-written copies the fleet has now.

Three node kinds

config {
    // Kinds usage knows nothing about, with the metadata docs need to describe them.
    source "git" name="git config" doc_hint="git config `{key}`" set_hint="git config {key} {value}"
    source "pkl" name="hk.pkl"

    // The rc-style chain, ascending precedence.
    file "~/.config/hk/config.pkl" scope="global"
    file "hk.pkl" findup=#true

    prop "jobs" type="uint" default=0 default_note="0 = auto-detect" help="…" since="1.0.0" {
        cli "--jobs" "-j"
        env "HK_JOBS" "HK_JOB"        // aliases, highest first
        source "git" "hk.jobs"        // its key in a declared kind
        example "hk check --jobs 4"
    }
    prop "exclude" type="list<string>" merge="union" { default "target" "node_modules" }
    prop "stash" type="string" { choices { choice "git" help="Use `git stash`" } }
    prop "trusted" type="bool" scope="global"       // never from a project file
    prop "ci" type="bool" hide=#true scope="env" { x "mise.rust_type" "BoolOrString" }
    prop "old.key" deprecated="Use new.key" renamed_to="new.key" \
        deprecated_warn_at="2026.12.0" deprecated_remove_at="2027.12.0"
}

source is what lets docs say "settable with git config hk.check" without usage having any idea what git is. scope="global" is not decoration — mise treats "a checked-in file must not be able to change this" as a security property, so it is declared rather than left to each tool.

Types

base := bool | string | int | uint | float | path | url | duration | object
type := base | list<type> | set<type> | map<base, type> | option<type> | type "|" type

An unknown name is kept verbatim rather than refused — that's the type-level escape hatch, so a spec naming a type only its own tool understands still loads everywhere. Old spellings (data_type=, boolean, integer, number, usize, array<…>, optional<…>) are accepted and normalized.

x extensions

x "ns.key" value: preserved in order, written back out, in usage g json, interpreted by nothing here. Where mise.rust_type, mise.parse_env and aube.npm_shared live. This is the seam that lets a CLI with special rules describe its settings without usage having to model those rules.

Verification

  • The whole vocabulary round-trips, field by field, in one test — anything the spec cannot write out is something a tool would lose by saving its own file.
  • The parsed model is committed as a snapshot — the artifact usage g json hands downstream, and what a port in another language diffs against.
  • Every KDL example on the reference page is parsed by a test. That page previously documented file, findup, default "k" "v", alias and config_filenone of which the parser has ever accepted — while never mentioning prop, the one thing it did. It's in the site nav. It cannot drift like that again.
  • Unknown vocabulary is refused rather than half-read (five cases tested), which is what min_usage_version is for.

One finding for elsewhere: running that docs test against the other reference pages reports five failures. Four are catalogue blocks — lists of alternative spellings that aren't specs (one even puts an argument after a variadic, which the parser rightly refuses). The fifth is real: flag "flag1" in cmd.md is missing its dashes and the parser rejects it. Left for its own change rather than smuggled in here.

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


Note

Medium Risk
Large expansion of the spec parser and serialized config shape; behavior changes (strict errors, merge rules for files/sources) could break hand-written specs that relied on old permissive or undocumented behavior, though compatibility aliases and tests aim to limit that.

Overview
Adds a full config vocabulary to usage specs so settings, file chains, and external source kinds can be declared once for docs, JSON export, and resolvers.

config block now supports source (opaque kinds like git/pkl with doc hints), file (precedence chain with findup, scope, format), and richer prop nodes: type grammar, cli/env aliases, bindings, choices, merge/scope, deprecation/renames, x extensions, and list defaults.

Introduces SpecConfigType (list<>, map<>, unions, legacy synonym normalization) alongside the old five-value data_type, with stricter parsing (unknown keys refused, no silent child drops, union defaults not coerced via legacy type).

docs/spec/reference/config.md is rewritten to match the parser; lib/tests/docs_examples.rs parses every KDL example on that page. Generated CLI JSON gains empty sources/files on config.

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

@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: a1c341fa-3abb-486a-8585-ec44864a05e0

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 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR defines and documents a comprehensive configuration vocabulary backed by a richer parsed model.

  • Adds source kinds, ordered configuration files, property metadata, typed defaults, choices, bindings, and extensions.
  • Adds parsing and serialization for composable configuration types while rejecting malformed expressions and invalid binding values.
  • Updates include merging, generated JSON, snapshots, reference documentation, and documentation-example coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/src/spec/config.rs Adds the expanded configuration model, strict parsing, round-trip serialization, and merge/emptiness handling; the previously reported binding and merge defects are fixed.
lib/src/spec/config_type.rs Implements composable configuration types and now rejects the malformed delimiter and empty-union cases reported previously.
lib/src/spec/mod.rs Integrates the new configuration type module and preserves non-empty source/file-only configuration during serialization and composition.
docs/spec/reference/config.md Replaces the configuration reference stub with the new vocabulary, type grammar, precedence, scope, and extension documentation.
lib/tests/docs_examples.rs Adds parsing coverage for KDL examples on the configuration reference page.

Reviews (7): Last reviewed commit: "fix(spec): stop the config block accepti..." | Re-trigger Greptile

Comment thread lib/src/spec/config_type.rs Outdated
Comment thread lib/src/spec/config.rs
Comment thread lib/src/spec/config.rs
Comment thread lib/src/spec/config.rs
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁▁▁▂██ 152,857,235 → 152,856,695 -0.00% 14.64 → 14.25ms -2.63%
startup ▁███████████ 1,201,903 → 1,201,836 -0.01% 1.04 → 0.95ms -8.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 40499 5962046 147x
usage: argv -> struct                            1737 ns      1.74 µs
clap: build tree + parse -> struct             516441 ns    516.44 µs
clap: parse -> struct, tree reused              24080 ns     24.08 µs
clap: build tree only                          308553 ns    308.55 µs

4ae32fe0ed75 vs cca57930b536 · measured on the runner, not pushed to the history.

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

All three findings were real, and there was a fourth hiding behind the third. Fixed in b41d239.

Config merge drops new fields — confirmed, and worse than described: is_empty gating the writer meant a config block declaring only file locations was reported empty and omitted entirely. merge now merges sources per kind (a set, like props) and replaces files when the other spec declares any. Files are an ordered precedence chain rather than a set, and interleaving two chains has no meaningful answer — in the case include exists for, only one side declares them at all. That reasoning is in the code.

Malformed types normalize silently — confirmed for all three inputs, and int> was the worst of them: it became Base::Custom("int>"), a "type" every consumer treats as a string. Custom is the escape hatch for names a newer usage understands; it must not swallow an expression that failed to parse. Empty union members are no longer dropped, and a base name is refused if it is empty or contains any of < > , |. That last check found the fourth case: map<,string> was parsing as a map keyed by Custom("").

Non-string bindings become strings — confirmed. env, cli, source and example hold names, so string_args now uses ensure_string like the rest of the parser instead of to_string().

The one place where "convert rather than refuse" was actually wrong in the other direction was the list default: default 80 443 for a list<int> is two numbers, and forcing strings there would make the JSON schema in #839 describe the setting wrongly. So default_list is now typed like the scalar default is, which the schema generator picks up for free — it emits [80, 443] rather than ["80", "443"].

Five tests, seven mutations, each one verified to fail with its fix reverted. The three descendant PRs are rebased; the docs model needed one follow-on change for the typed list, folded into #837.

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

Comment thread lib/src/spec/config.rs
Comment thread lib/src/spec/config.rs
@jdx
jdx force-pushed the agent/config-vocabulary branch from b41d239 to 87418b9 Compare August 12, 2026 23:07

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Also fixed the builder inconsistency Bugbot flagged (Builder env skips envs list). It was real: env and envs are two spellings of one thing — env="X" and env "A" "B" — and the parser reconciles them so a consumer can read either field, but SpecConfigProp::env() set only env. A spec assembled in Rust therefore serialized with an empty envs, and every reader of that field saw a setting no variable could set.

env() now maintains both, and calling it more than once means the alias list, highest precedence first — the same thing env "HK_JOBS" "HK_JOB" means in a spec. One test covers all three construction paths (builder, property, child list) so the invariant cannot hold in one and not the others, mutation-verified.

The Config merge drops sources and files finding is the same one Greptile raised and is fixed in the commit above.

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

Comment thread lib/src/spec/config.rs

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Three more findings from the later review round, all real, all fixed in the amended head.

A union claimed a legacy type it does not have. data_type_of ran unions through simplified(), so bool|string reported Boolean — and that legacy field decides how a default is read, so type="bool|string" default="true" came back as a boolean, contradicting the very value_type it was derived from. A union has no counterpart among the old five values, so it maps to Null like every other composite.

source and file ignored their children. They validated their properties and never looked at a child block, so nested vocabulary was dropped in silence — the one thing this block is strict about not doing, and which prop already refused. Both now refuse a block outright.

The two env spellings could disagree. The sync ran only when one side was empty, so a prop writing both env="FIRST" and env "SECOND" left env and envs asserting different things — usage g json exposing the pair, and a writer that chooses between them by list length, so a round trip dropped a value. env= is now simply shorthand for a one-element list: both forms feed envs in written order and env is always its first entry, so they agree by construction rather than by a sync that might not run. Tested through all four construction paths — builder, property, child list, and both at once — plus a round trip.

The builder inconsistency from the earlier round is fixed here too.

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

Comment thread lib/src/spec/config.rs Outdated
Comment thread lib/src/spec/config.rs Outdated
What a `config` block can say, so that documentation, a JSON schema, completions and a
resolver can all be generated from one declaration instead of five hand-written copies.

Three node kinds. `source` declares a kind usage knows nothing about — a git config, a pkl
file, an `.npmrc` — with the display metadata docs need to say "settable with
`git config hk.check`" without usage having any idea what git is. `file` lists config file
locations in ascending precedence, with `findup` and a `scope`, which is the rc-style chain
documentation has to describe. `prop` is the setting.

Props gain what the fleet`s registries actually declare: a type in a small expression
grammar (`list<string>`, `map<string, url>`, `option<path>`, `bool|string`, with unknown
names kept verbatim as the escape hatch), typed defaults including list defaults, several
env var spellings in precedence order, the flags that set it, its keys in each custom source
kind, choices with per-value help, a `merge` policy, a `scope` that keeps a setting out of
files a repository can carry, the deprecation triple plus `renamed_to`, `hide`, `since`, a
named value parser, `writes_to`, examples, and `help_heading`.

And `x "ns.key" value` for everything usage should not interpret: `mise.rust_type`,
`aube.npm_shared`. Preserved in order, written back out, present in `usage g json`, read by
nothing here. It is the seam that lets a CLI with special rules describe its settings
without usage modelling those rules.

The old spellings still work: `data_type=` is read as `type=`, and `boolean`/`integer`/
`number`/`usize`/`array<>`/`optional<>` are accepted and normalized. Unknown vocabulary is
refused rather than half-read, which is what `min_usage_version` is for.

The reference page is rewritten, because the one it replaces documented a syntax that had
never parsed — `file`, `findup`, `default "k" "v"`, `alias`, `config_file`, none of which
the parser has ever accepted, on a page in the site nav. A test now parses every KDL
example on it, so it cannot drift again. Running that test against the other reference
pages reports five failures; four are catalogue blocks listing alternative spellings, but
one is real — `flag "flag1"` in cmd.md is missing its dashes and the parser rejects it.
Left for its own change rather than smuggled in here.

The whole vocabulary is round-tripped in a test, field by field, and the parsed model is
committed as a snapshot: that is the artifact `usage g json` hands downstream and what an
implementation in another language can diff against.
@jdx
jdx force-pushed the agent/config-vocabulary branch from d587ccf to 2769a49 Compare August 13, 2026 00:20

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Two more, both real.

#null in an extension did not round-trip. x nodes promise to come back out exactly as they went in, and #null had nowhere to come back to — it was stored as an empty string and written as "", so tool-private metadata was altered by saving the file. Refused now, which is the only honest answer when the value has no representation.

A second env or cli node dropped the first. example and source accumulate across nodes; these two assigned, so a spec writing them on two lines silently lost the first line's aliases. They extend now, like their neighbours.

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

Comment thread lib/src/spec/config.rs
@jdx
jdx force-pushed the agent/config-vocabulary branch from 2769a49 to 39a8d87 Compare August 13, 2026 01:00

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Right, and it is the case I missed when I made env and cli accumulate last round: default still cleared the list before filling it, so a prop declaring its list default over two lines kept only the second. It accumulates now, like its neighbours. Mutation-verified.

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

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

Comment thread lib/src/spec/config.rs
Five holes Greptile found in the vocabulary, each the same shape as the bugs
this stack opened with: input that changes meaning instead of being refused.

`SpecConfig::merge` copied only props, and `is_empty` counted only props. So
the case `include` exists for — a spec with many settings keeping them in their
own file — silently dropped that file's `source` and `file` declarations, and a
block that declared just where config files live was reported empty and left
out by the writer entirely.

The type grammar dropped empty members and kept stray delimiters, so four
typos loaded as something else: `bool|` as plain `bool`, `bool||string` as the
two-member union, `map<string,>` as a map to strings, and `int>` as a *custom
type named* `int>` that every consumer then treats as a string. `Base::Custom`
is the escape hatch for names a newer usage understands; it must not swallow an
expression that failed to parse. A name is now refused if it is empty or holds
one of `< > , |`.

`env`, `cli`, `source` and `example` hold names, and a non-string argument was
rendered with `to_string()` rather than refused — `env #true` became a variable
called `#true`, written back out quoted as though somebody had meant it.

And a list default is now typed like the scalar one, so `default 80 443` for a
`list<int>` stays two numbers through the writer and the JSON schema instead of
becoming two strings.

Five tests, seven mutations, each verified to fail without its fix.
@jdx
jdx force-pushed the agent/config-vocabulary branch from 39a8d87 to 4ae32fe Compare August 13, 2026 01:07

jdx commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Right — and it is the same class as source and file, which I fixed a round ago with a helper I then failed to apply to the one node left. A choice is properties-only too, so a nested block is refused rather than dropped, through the same refuse_children. Mutation-verified, and the existing test grew a third case so the three nodes are checked together.

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

@jdx
jdx merged commit 076fe59 into main Aug 13, 2026
9 checks passed
@jdx
jdx deleted the agent/config-vocabulary branch August 13, 2026 02:38
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