fix(spec): make the config block survive being written out - #832
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughConfiguration defaults now use typed values instead of source strings. KDL parsing validates and preserves typed defaults, serialization emits native values, and merging applies later declarations. Python and TypeScript string literals use shared escaping. ChangesTyped configuration defaults
Estimated code review effort: 4 (Complex) | ~45 minutes Mergeability Score: ⚪ Minimal · up to This PR makes config defaults typed, preserves data types on write, rejects invalid nested properties and non-finite values, and changes config merges to last-wins; the stated defect-specific tests and full workspace checks pass, so no actionable merge-blocking risk remains beyond normal checks. Sequence Diagram(s)sequenceDiagram
participant KDL
participant SpecConfig
participant PythonSDK
KDL->>SpecConfig: Parse and validate typed default
SpecConfig->>SpecConfig: Coerce default to declared type
SpecConfig->>PythonSDK: Provide typed configuration property
PythonSDK-->>KDL: Render escaped Python literal
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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. Comment |
Greptile SummaryThe PR makes config properties round-trip safely through KDL and preserves typed defaults for SDK generation.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; unquoted integers outside the supported range now fail parsing explicitly, and quoted out-of-range integer defaults declared as integers are also rejected after all properties have been read. Important Files Changed
Reviews (5): Last reviewed commit: "fix(spec): make the config block survive..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
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/sdk/python/mod.rs`:
- Around line 137-156: Update the numeric branches in the default-rendering
match within the Python field generation function to emit only validated integer
or float literals. Parse string defaults into numeric values or reject invalid
string/type combinations during spec parsing, and ensure malformed values such
as executable expressions never reach v.display() or generated Python source.
In `@lib/src/spec/config.rs`:
- Around line 27-34: Update ConfigValue::from_kdl to propagate a parse error
when converting KdlValue::Integer fails i64::try_from, instead of returning
None; reserve None exclusively for KdlValue::Null. Update the callers around the
default handling at lines 124-126 to propagate this error so out-of-range
declared defaults are not treated as absent.
🪄 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: d5722d81-cf99-4a0e-aaad-a9d416ff3532
📒 Files selected for processing (3)
lib/src/sdk/python/mod.rslib/src/spec/config.rslib/src/spec/data_types.rs
Instruction counts
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 comparisonParsing
|
|
Three real ones and one that doesn't reproduce. The Critical was the important one, and it was a consequence of my own fix in this PR. String default emitted as a Python expression (CodeRabbit, Critical). Rendering by the declared
There's an end-to-end test with that exact payload asserting the output contains no Out-of-range integer silently dropped (greptile + CodeRabbit, same bug). KDL parses Whole floats becoming ints (Bugbot, Medium) — does not reproduce. I measured it before and after: kdl 6.5's float rendering already keeps the decimal point. I had written a All four fixes mutation-checked: revert the fix, the test fails (except the float one, which is how I caught it). AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
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/spec/config.rs`:
- Around line 27-30: Reject non-finite values at every float-construction path,
including KDL float parsing, string coercion, and
SpecConfigProp::default_value(f64), while preserving non-finite string defaults
as SpecConfigValue::String. Add regressions covering `#nan/`#inf/#-inf,
default="NaN", and successful Python module execution without emitted NaN or
inf.
🪄 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: 0ad84e22-a147-45d3-a9c7-401adaba1970
📒 Files selected for processing (2)
lib/src/sdk/python/mod.rslib/src/spec/config.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- lib/src/sdk/python/mod.rs
Four defects in `config { prop … }`, all of them in the way v2 has to build on. Nothing
consumes the block for docs yet, but the SDK generators do, and their workarounds are the
evidence.
**`data_type` was parsed and never written.** So it survived exactly one hop: a spec
declaring `data_type="integer"` came back `Null` after a round trip. `SpecDataTypes` gains
a `Display` (the KDL spelling) and the writer emits it.
**`default` stored KDL source text rather than a value.** It kept
`KdlValue::to_string()`, so `default=#true` was read as the five characters `#true`,
`default="4"` as `"4"` *with* its quotes — and writing it out added another pair, one
layer per round trip. It is now a typed `SpecConfigValue` (bool/int/float/string), which
is also what the v2 vocabulary needs. The Python SDK generator had been undoing the
damage by hand — matching `"#true" | "true"` and calling `trim_matches('"')` on numbers —
and now reads the value, rendering by the declared `data_type` where there is one.
**A nested `prop` block parsed and lost its children.** `prop "status" { prop
"missing_tools" }` kept `status` and dropped the rest without a word. Refused now, naming
the dotted spelling to use instead.
**`merge` was first-wins per prop** while `Spec::merge` is other-wins everywhere else, so
an included spec could add a prop but never correct one. Last-wins now.
Each fix has a test, and I checked each test fails when its fix is reverted — including
the one that catches the quoting, which is the reason the old snapshot recorded
`default="#true"`.
|
No, I had not seen that one — it landed on the post-rebase commit, after my last sweep. It was right, and so were the two other findings I had missed on this PR. All three are fixed in the amended head. String defaults skipped the round-trip helper. Exactly as described, and I reproduced it before fixing: a default containing a colour escape wrote a spec that no longer parses.
Non-finite float defaults. Out-of-range string defaults, and the wider case behind it. Greptile's example was a 20-digit number in quotes on an integer-typed prop, which slipped past the range check the same number unquoted would have hit. The narrow fix would have been a range check on the string; instead a string the declared type cannot read is now refused outright. That reverses a decision I made deliberately last time — keeping it as a string, so nothing would treat its text as a number — so it is worth saying why. Keeping it left the spec asserting two contradictory things, and the generator then wrote an The security test moves rather than disappears. Its original case no longer parses, but a string-typed default legitimately holds arbitrary text, so the generator must still quote — two defences, and that is what it now tests. One note on method: AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c712e17. Configure here.
Review found six real problems in the typed config default, one a security bug
in generated code and one that made the writer's own output unparseable.
**A string default could become a Python expression.** The generator rendered by
the declared `data_type`, so a `data_type=integer` whose default was arbitrary
text wrote that text into `types.py` unquoted — and `types.py` gets imported. A
default of `__import__("os").system(…)` would have run. Fixed at both ends, and
both defences are still needed: a string-typed default legitimately holds
arbitrary text, so the generator emits a bare literal only for a value that is
one and quotes and escapes everything else.
**A default the declared type cannot read is refused.** It used to stay a
string, which kept it away from anything treating its text as a number but left
the spec asserting two contradictory things — the generator then wrote an `int`
field defaulting to a quoted sentence, and a 20-digit number in quotes bypassed
the range check the same number unquoted would have hit. Across mise's 280
settings, the largest registry in the fleet, not one string default fails to
read as its declared type, so this refuses nothing anybody has written.
**An integer beyond i64 silently disappeared.** KDL parses integers as `i128`,
and `try_from(...).ok()` turned an out-of-range one into "no default", with
every consumer downstream reporting the property as having none.
**`#inf` and `#nan` are refused.** KDL accepts them and nothing downstream can
carry one: `serde_json` writes a non-finite float as `null`, so `usage g json`
reported no default at all, and the Python generator emitted a bare `inf` —
a `NameError` rather than a number. Both measured before the check went in.
**A string default skipped the round-trip helper.** `string_entry` exists
because the kdl crate writes a control character literally and the result cannot
be read back; the typed-default writer built its entry by hand and skipped it. A
default with a colour escape in it therefore wrote a spec that failed to parse —
the exact failure this change is about, for one shape of value. The prop's key
goes through the same helper now.
**A whole float was reported as a risk and is not one.** The concern was that
`Float(1.0)` would render as `1` and reparse as an integer. Measured: kdl writes
`1.0` and reads it back as a float, and `1e3` normalizes to `1000.0`. My first
fix was unnecessary, so it is gone; the test that would have caught it stays as
a pin.
Each fix has a test, and each test is checked against a reverted fix — which is
how I found the float one needed no fix: the mutation passed. The error-message
assertions read the diagnostic's label rather than `Display`, which for
`InvalidInput` is the same fixed string whatever went wrong, so a test asserting
only `is_err()` would pass on a spec refused for an unrelated reason.
|
Two more, both real. Control characters broke the generated Python. A native default under a declared AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
Stacked on jdx#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 ```kdl 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_file` — *none 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.* <!-- CURSOR_SUMMARY --> --- > [!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`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4ae32fe. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->

First of the config v2 stack. Four defects in
config { prop … }, all in the ground v2 builds on.Nothing renders the block into docs yet (
lib/src/docs/models.rs:12has// pub config: SpecConfig,commented out), but the SDK generators do consume it — and their workarounds are the evidence for two of these.data_typewas parsed and never writtenIt survived exactly one hop:
SpecDataTypesgains aDisplay(the KDL spelling) and the writer emits it.defaultstored KDL source text, not a valueIt kept
KdlValue::to_string(), sodefault=#truewas read as the five characters#trueanddefault="4"as"4"with its quotes — and writing it out added another pair, one layer per round trip. The old snapshot in this file recorded exactly that (default="#true",default="4"), which is how the bug stayed.It's a typed
SpecConfigValuenow (bool/int/float/string), which is also what the v2 vocabulary needs for typed defaults. The Python SDK generator had been undoing the damage by hand:Both gone; it reads the value and renders by the declared
data_typewhere there is one — which keeps thedata_type=float default="1.5"case rendering as1.5rather than"1.5"(my first attempt at this regressed that, caught by its snapshot).A nested
propblock parsed and lost its childrenprop "status" { prop "missing_tools" }keptstatusand silently dropped the rest — the property loop reads properties only. Refused now, naming the dotted spelling to use instead.mergewas first-wins per propEverything else in
Spec::mergeis other-wins (merge_opt!); config was the one place anincludecould add a prop but never correct one. Last-wins now, whole prop at a time.Verification
A test per defect, and I checked each one fails when its fix is reverted (four mutations, four failures). Full workspace green, clippy clean,
mise run renderproduces no diff.AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.
Note
Medium Risk
Changes spec parsing, serialization, and generated SDK code paths where bad defaults could previously break imports or silently corrupt values; new validation may reject previously tolerated specs.
Overview
Config
propdefaults are typed values now, not KDL source strings, so parse → write → parse keeps booleans, numbers, and strings without extra quoting layers. The writer also emitsdata_typeagain (viaDisplayonSpecDataTypes), and defaults go out through typed KDL entries withstring_entryfor strings so control characters still reparse.Parsing is stricter: nested
prop { … }is rejected, defaults must fit the declared type (and finite floats / i64 range), with coercion after all props on the node are read.SpecConfig::mergeis last-wins per prop, matching otherSpec::mergebehavior.SDK output: shared
escape_string_literalescapes control characters for Python/TS; the Python config dataclass renders defaults fromSpecConfigValue(quoted strings only) instead of#true/quote-stripping hacks, with a test that malicious string defaults cannot become import-time code.Reviewed by Cursor Bugbot for commit 706e618. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit