fix(cli): warn on duplicate keys in --vars JSON files at every depth; fix stale lint_str rustdoc (#326, #329) - #374
Conversation
PF-013 control PC2 (recorded after the PR body)Mutation: Baseline: Mutated (RED): 12/17 failed, exactly the duplicate-detecting tests — Representative assertion: Restore: Run in a detached worktree at |
Summary
A
--varsfile containing{"x":1,"x":2}compiled silently withx=2— the repeat wasswallowed before the CLI could ever see it. Now every key repeated at any depth warns
with its path, exit code stays
0, and the last value still wins:--quietsuppresses it. Reporting is capped at 1 000 paths, followed by a single tail line:Behaviour is identical across
build,check,lintandwatch.mds watchwarns atstartup and on every rebuild that writes output — file mode, dir mode via the FS-event path,
and dir mode via the liveness self-heal path.
Also closes #329:
lint_str's rustdoc is rewritten count-free and now links[
KNOWN_LINT_RULES]; both lint Tier tables (lint/tier.rs,lint/fix.rs) listlegacy-interpolationunder Tier A; a mechanised test pins both tables againstrule_tierso they cannot drift again.
What was wrong
serde_json's
Value::visit_map(serde_json-1.0.151/src/value/de.rs:136-145) inserts eachkey into the map and discards
Map::insert's returned previous value. The duplicatetherefore vanished inside
serde_json::from_str::<Value>atcrates/mds-core/src/lib.rs:1430(onmain), before any MDS code held the parsed data.load_vars_strhad the same defect.For #329,
lib.rs:1165-1166claimed "the 9 lint rules … (empty in S1 — rules arrive in S2)",and both
tier.rs:8-13andfix.rs:5-10listed 9 rules, while the registry has 10.Design
D1 — two passes, not a tracking deserializer. The existing
from_strline isbyte-identical to
mainand still runs first, with every existing guard and error in exactlythe same order. A second, value-free pass (
crates/mds-core/src/vars_json.rs: aDeserializeSeed/Visitorpair whoseSelf::Value = (), recursing throughvisit_mapandvisit_seq, driven byDeserializer::from_str+end()) then walks the same text andrecords duplicate key paths. It runs last, after the vars map is fully built.
This is a deliberate deviation from #326's proposal ("replace the deserializer with a tracking
one"). A tracking
Valuedeserializer would have to re-derive serde_json's number, float,string and i128 handling; fidelity of the parsed value would then rest on that re-derivation.
The two-pass design keeps that fidelity by construction — the value the compiler sees is
still produced by stock serde_json.
D2 — no
#[deprecated]. There are 14 in-repo call sites and the workspace builds under-D warnings.load_vars_file/load_vars_strbecome thin wrappers over the newreporting variants; their signatures, return types and errors are unchanged.
D3 — error variants stay split. File input keeps
mds::invalid_vars; string input keepsmds::json. No unification.D4 — escaping happens at the interpolation site. mds-core returns raw, untrusted
structured paths in
VarsLoad. The CLI escapes them where they are printed, withsafe_inline(key)/safe_path(path)(andsafe_inlineon the omitted count), soprint_disciplineneeds no new allowlist entry and the "exactly three mds-core warningproducers" contract is untouched.
D5 — new public API.
Both are
#[must_use].D6 — cap 1 000 (
MAX_DUPLICATE_KEY_PATHS), mirroring the existingMAX_WARNINGS/MAX_DIAGNOSTICSshape.D7 — no new depth cap. serde_json's 128-level
check_recursion!already bounds the scan(a 129-deep document returns an error, never a panic), and
MAX_VALUE_DEPTH = 64rejectsdeeper values in pass 1 regardless.
D8 — one message format at every depth, using "key" rather than "variable", emitted in
the order file →
--set→--set-string.D9 — watch.
emit_duplicate_var_warningsgained a file-emitter call first; its fiveexisting call sites are unchanged. The per-rebuild sites emit gated on the same
content-changed signal that already gates the
Recompiledline. This refines the plan'sliteral "emit at the per-rebuild sites": at
--debounce 0a single edit yields several raw FSevents and the liveness probe can race them, which printed the warning 2–4 times per edit.
Gating on an observable output write yields exactly one. The
--varspath is displayed astyped (
vars_path_raw) while the canonicalised path is retained for FS-event matching, andthe PF-004 symlink check still runs at startup and on every per-rebuild read.
Evidence
1. Zero-diff proof — the parse line is untouched:
2. Local gates on HEAD —
cargo fmt --all --checkclean;cargo clippy --workspace --all-targets -- -D warningsclean, and clean again with--features startup-race-probe;cargo nextest run -p mds-core -p mds-cli2223/2223 passed, 0 skipped;cargo test --doc -p mds-core53/53;cargo test --workspaceexit 0;RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core --no-depsclean (theKNOWN_LINT_RULESintra-doc link resolves);cargo +1.88 checkclean (MSRV);node scripts/verify-no-control-bytes.mjsclean;node scripts/verify-versions.mjsclean;npm run test:gates212 pass / 0 fail (unchanged).cli_watchran 72/72 across three consecutive runs with zero flakes.3. PF-013 mutation controls. Each mutation was applied to the live file, observed, then
restored from a pre-mutation backup and confirmed byte-identical.
visit_mapdedup condition →if falsevars_jsontests;nested_duplicate_reports_dotted_path:left: []/right: ["x.a"]reportedsettriple_repeat_reports_one_entry:left: ["x", "x"]/right: ["x"]key '{}'→variable '{}'in the emittercli_build::vars_file_duplicate_key_warns_and_last_value_wins+ 5 more (fail-fast cancelled the rest)safe_inline(key)→ barekeyprint_discipline:build.rs:650: eprint_warning(format!) interpolates unsanitized `key`— see note belowsafe_path(path)→path.display()print_discipline:build.rs:650: … interpolates unsanitized `path.display()`--quietearly-outrebuild_file's emit to a discardcli_watch::i16left: 1/right: 2;i18left: 0/right: 1cli_watch::i17left: 2/right: 1MAX_DUPLICATE_KEY_PATHS→usize::MAXrecorded_paths_are_capped_and_the_rest_counted:left: 1003/right: 18446744073709551615MAX_DUPLICATE_KEY_PATHS→1paths_below_the_cap_are_all_kept:left: 1/right: 999visit_u64armevery_json_leaf_shape_is_accepted:invalid type: integer …, expected any valid JSON valuede.end()?trailing_data_is_an_error:assertion failed: duplicate_json_keys("{} {}").is_err()load_vars_file_reporting_duplicates_rejects_oversized_input: gotinvalid vars file: …: expected value at line 1 column 1legacy-interpolationfromfix.rs's Tier table onlymodule_doc_tier_table_matches_rule_tier:left: 9/right: 10Three of those need honest qualification:
print_disciplinered but noti15.eprint_warningsanitises thewhole composed message in HUMAN mode, and HUMAN and WIRE escaping differ only on
\n— soi15's ESC/RLO codepoints were escaped by the outer pass either way.
i15b(a newline-bearingkey) was added for exactly this, and it does go red under the same mutation, alongside
the structural guard.
build/check/lintreach the emitter through theouter guarded wrapper, whose own early-out fires first. The inner guard is load-bearing only
for the two direct
watch.rscall sites, which no test then covered.i20(
mds watch --quieton a rebuild) was added and does go red under that mutation.liveness_probe_diremit →i19RED" control bites reliably onLinux/inotify, but only under load on macOS, where FSEvents may still deliver the create
event and drive the rebuild through the FS-event path instead.
4. Black-box QA on the built binary. S1–S10 all PASS: the path is echoed exactly as typed
(relative,
./sub/, absolute); nested, array, encounter-order, triple-repeat and siblingshapes all report correctly;
build,check,lintandlint --format jsonare at parity,with valid JSON still on stdout;
--quietsuppresses; D8 ordering holds; the cap boundariesat 999 / 1000 / 1003 behave; the four error cases (malformed, array root, missing file,
symlink) produce byte-identical stderr and exit codes to the binary built from
main;hostile ESC / RLO / LF keys render escaped with no raw control bytes reaching the terminal;
watch file-mode and dir-mode counts are exactly as pinned; and help text, README, spec and
CHANGELOG are mutually consistent.
5. Snyk. The Snyk MCP server was ENOENT locally (
snyk-macos-arm64missing), so no localsnyk_code_scanran. Thesecurity/snyk (dean0x)PR check is the scan of record.Known limitations
.,[or]renders ambiguously in its path, and anempty-string key renders as an empty segment. Display-only, documented in the rustdoc.
mds::json(string input) vsmds::invalid_vars(file input) error-code asymmetry isleft as-is; unifying it is a v0.5.0 candidate.
crates/mds-python/tests/test_parity.py:201still says "9 lint rules". Deliberately leftfor the release-surface step — touching
crates/mds-python/**would change this PR's checkshape.
[0].ais produced only by the internal scanner (unit-tested); thepublic load API rejects a non-object root before it can surface.
Recompilednor the warning.objects, so no duplicate key can survive to reach them.
Changes
mds-core (src)
crates/mds-core/src/vars_json.rs(new, 514 lines) — the value-free duplicate scanner:MAX_DUPLICATE_KEY_PATHS,DuplicateKeys,Seg/Scan/DupScan,duplicate_json_keys,plus 17 unit tests.
crates/mds-core/src/lib.rs—VarsLoad;load_vars_{file,str}_reporting_duplicates;the two old fns become thin wrappers; Stale rustdoc in lib.rs: "empty in S1 — rules arrive in S2", and "9 lint rules" (there are 10) #329 rustdoc rewrite; 17 new tests.
crates/mds-core/src/lint/tier.rs— Tier table gainslegacy-interpolation; newmechanised test
module_doc_tier_table_matches_rule_tierpins both tables torule_tier.crates/mds-core/src/lint/fix.rs— Tier table gainslegacy-interpolation(mechanised bythe test above).
mds-core (tests)
crates/mds-core/tests/api_surface.rs— 2 new call sites inpublic_functions_exist; newvars_load_fields_are_readable.mds-cli (src)
crates/mds-cli/src/build.rs—load_optional_vars_filereturnsOption<VarsLoad>;RuntimeVarsgainsduplicate_vars_file_keys,duplicate_vars_file_keys_omitted,vars_file; newemit_duplicate_vars_file_warnings(quiet early-out, escaping at theinterpolation site);
emit_duplicate_var_warningscalls it first, per D8.crates/mds-cli/src/watch.rs—vars_path_rawonFileCompileCtx/DirWatchCtx;rebuild_file,handle_fs_event_dirandliveness_probe_diremit gated on thecontent-changed signal;
compile_one_source/process_dir_batch*threadany_changed;the per-rebuild map clone removed on both dir-mode paths.
crates/mds-cli/src/main.rs— 4 help-text edits (Build/Check/Lint--vars, Watch--vars).mds-cli (tests)
crates/mds-cli/tests/common/mod.rs—dup_vars_file_warning,dup_vars_file_omitted,count_occurrenceshelpers plus the two format consts (no#[test]fns).crates/mds-cli/tests/cli_build.rs— 5 tests: flat, nested-dotted, array-bracket, no-false-positive, and the >1000 tail line.
crates/mds-cli/tests/warnings.rs— 7 tests: i10–i15 plus i15b (newline key, WIRE-escaped).crates/mds-cli/tests/cli_watch.rs— 5 tests: i16–i18 plus i19 (dir-mode liveness self-healrebuild) and i20 (
watch --quieton rebuild).Docs
README.md—--varsgains "A key repeated at any depth warns with its path; the lastvalue wins."
spec.md— the--varsrows in both the build-options and lint-options tables gain thedepth / path / last-wins clause.
CHANGELOG.md— new### Fixedsection under## [Unreleased]with one bullet each for--vars JSON file duplicate keys silently last-win (same class as #200, different mechanism) #326 and Stale rustdoc in lib.rs: "empty in S1 — rules arrive in S2", and "9 lint rules" (there are 10) #329.
Test counts: 58 new
#[test]fns (55 from the RED phase, plus i19, i20 and i15b),0 removed.
Related Issues
Closes #326
Closes #329
Refs #200