Skip to content

fix(cli): warn on duplicate keys in --vars JSON files at every depth; fix stale lint_str rustdoc (#326, #329) - #374

Merged
dean0x merged 6 commits into
mainfrom
fix/326-vars-file-duplicate-keys
Sep 9, 2026
Merged

fix(cli): warn on duplicate keys in --vars JSON files at every depth; fix stale lint_str rustdoc (#326, #329)#374
dean0x merged 6 commits into
mainfrom
fix/326-vars-file-duplicate-keys

Conversation

@dean0x

@dean0x dean0x commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

A --vars file containing {"x":1,"x":2} compiled silently with x=2 — the repeat was
swallowed 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:

warning: key '<path>' is set more than once in vars file <path-as-typed>; the last value wins

--quiet suppresses it. Reporting is capped at 1 000 paths, followed by a single tail line:

warning: N more duplicate keys in vars file <path> are not listed

Behaviour is identical across build, check, lint and watch. mds watch warns at
startup 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) list
legacy-interpolation under Tier A; a mechanised test pins both tables against rule_tier
so 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 each
key into the map and discards Map::insert's returned previous value. The duplicate
therefore vanished inside serde_json::from_str::<Value> at
crates/mds-core/src/lib.rs:1430 (on main), before any MDS code held the parsed data.
load_vars_str had the same defect.

For #329, lib.rs:1165-1166 claimed "the 9 lint rules … (empty in S1 — rules arrive in S2)",
and both tier.rs:8-13 and fix.rs:5-10 listed 9 rules, while the registry has 10.

Design

D1 — two passes, not a tracking deserializer. The existing from_str line is
byte-identical to main and still runs first, with every existing guard and error in exactly
the same order. A second, value-free pass (crates/mds-core/src/vars_json.rs: a
DeserializeSeed/Visitor pair whose Self::Value = (), recursing through visit_map and
visit_seq, driven by Deserializer::from_str + end()) then walks the same text and
records 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 Value deserializer 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_str become thin wrappers over the new
reporting variants; their signatures, return types and errors are unchanged.

D3 — error variants stay split. File input keeps mds::invalid_vars; string input keeps
mds::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, with
safe_inline(key) / safe_path(path) (and safe_inline on the omitted count), so
print_discipline needs no new allowlist entry and the "exactly three mds-core warning
producers" contract is untouched.

D5 — new public API.

#[non_exhaustive]
pub struct VarsLoad {
    pub vars: HashMap<String, Value>,
    pub duplicate_keys: Vec<String>,
    pub duplicate_keys_omitted: usize,
}

pub fn load_vars_file_reporting_duplicates(path: &Path) -> Result<VarsLoad, MdsError>;
pub fn load_vars_str_reporting_duplicates(json: &str) -> Result<VarsLoad, MdsError>;

Both are #[must_use].

D6 — cap 1 000 (MAX_DUPLICATE_KEY_PATHS), mirroring the existing MAX_WARNINGS /
MAX_DIAGNOSTICS shape.

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 = 64 rejects
deeper 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_warnings gained a file-emitter call first; its five
existing call sites are unchanged. The per-rebuild sites emit gated on the same
content-changed signal that already gates the Recompiled line. This refines the plan's
literal "emit at the per-rebuild sites": at --debounce 0 a single edit yields several raw FS
events 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 --vars path is displayed as
typed (vars_path_raw) while the canonicalised path is retained for FS-event matching, and
the PF-004 symlink check still runs at startup and on every per-rebuild read.

Evidence

1. Zero-diff proof — the parse line is untouched:

$ git diff main -- crates/mds-core/src/lib.rs | grep -c '^[-+].*serde_json::from_str'
0

2. Local gates on HEADcargo fmt --all --check clean; cargo clippy --workspace --all-targets -- -D warnings clean, and clean again with --features startup-race-probe;
cargo nextest run -p mds-core -p mds-cli 2223/2223 passed, 0 skipped; cargo test --doc -p mds-core 53/53; cargo test --workspace exit 0; RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core --no-deps clean (the KNOWN_LINT_RULES intra-doc link resolves); cargo +1.88 check clean (MSRV); node scripts/verify-no-control-bytes.mjs clean;
node scripts/verify-versions.mjs clean; npm run test:gates 212 pass / 0 fail (unchanged).
cli_watch ran 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.

ID Mutation Observed RED
PC1 visit_map dedup condition → if false 7 vars_json tests; nested_duplicate_reports_dotted_path: left: [] / right: ["x.a"]
PC3 Drop the reported set triple_repeat_reports_one_entry: left: ["x", "x"] / right: ["x"]
PC4 key '{}'variable '{}' in the emitter cli_build::vars_file_duplicate_key_warns_and_last_value_wins + 5 more (fail-fast cancelled the rest)
PC5 safe_inline(key) → bare key print_discipline: build.rs:650: eprint_warning(format!) interpolates unsanitized `key` — see note below
PC6 safe_path(path)path.display() print_discipline: build.rs:650: … interpolates unsanitized `path.display()`
PC7 Remove the inner --quiet early-out nothing red at the time — see note below
PC8 Revert rebuild_file's emit to a discard cli_watch::i16 left: 1 / right: 2; i18 left: 0 / right: 1
PC9 Add an emit at the dir-startup baseline second read cli_watch::i17 left: 2 / right: 1
PC10 MAX_DUPLICATE_KEY_PATHSusize::MAX recorded_paths_are_capped_and_the_rest_counted: left: 1003 / right: 18446744073709551615
PC11 MAX_DUPLICATE_KEY_PATHS1 paths_below_the_cap_are_all_kept: left: 1 / right: 999
PC12 Delete the visit_u64 arm every_json_leaf_shape_is_accepted: invalid type: integer …, expected any valid JSON value
PC13 Comment out de.end()? trailing_data_is_an_error: assertion failed: duplicate_json_keys("{} {}").is_err()
PC14 Move the duplicate scan before the size check / parse / object check load_vars_file_reporting_duplicates_rejects_oversized_input: got invalid vars file: …: expected value at line 1 column 1
PC15 Remove legacy-interpolation from fix.rs's Tier table only module_doc_tier_table_matches_rule_tier: left: 9 / right: 10

Three of those need honest qualification:

  • PC5 turned print_discipline red but not i15. eprint_warning sanitises the
    whole composed message in HUMAN mode, and HUMAN and WIRE escaping differ only on \n — so
    i15's ESC/RLO codepoints were escaped by the outer pass either way. i15b (a newline-bearing
    key) was added for exactly this, and it does go red under the same mutation, alongside
    the structural guard.
  • PC7 turned nothing red at the time: build/check/lint reach the emitter through the
    outer guarded wrapper, whose own early-out fires first. The inner guard is load-bearing only
    for the two direct watch.rs call sites, which no test then covered. i20
    (mds watch --quiet on a rebuild) was added and does go red under that mutation.
  • The "revert the liveness_probe_dir emit → i19 RED" control bites reliably on
    Linux/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.
  • PC16 ("the 9 lint rules" prose restored) has no test behind it — it is upheld by review.
  • PC2 has no separate entry in the verification record.

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 sibling
shapes all report correctly; build, check, lint and lint --format json are at parity,
with valid JSON still on stdout; --quiet suppresses; D8 ordering holds; the cap boundaries
at 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-arm64 missing), so no local
snyk_code_scan ran. The security/snyk (dean0x) PR check is the scan of record.

Known limitations

  • A key containing a literal ., [ or ] renders ambiguously in its path, and an
    empty-string key renders as an empty segment. Display-only, documented in the rustdoc.
  • The mds::json (string input) vs mds::invalid_vars (file input) error-code asymmetry is
    left as-is; unifying it is a v0.5.0 candidate.
  • crates/mds-python/tests/test_parity.py:201 still says "9 lint rules". Deliberately left
    for the release-surface step — touching crates/mds-python/** would change this PR's check
    shape.
  • The array-root path form [0].a is produced only by the internal scanner (unit-tested); the
    public load API rejects a non-object root before it can surface.
  • PC16 is review-only, as noted above.
  • "Every rebuild" means every rebuild that writes output: a no-op recompile prints neither
    Recompiled nor the warning.
  • Bindings (napi, wasm, python) are untouched by design — they receive already-parsed host
    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.rsVarsLoad; 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 gains legacy-interpolation; new
    mechanised test module_doc_tier_table_matches_rule_tier pins both tables to rule_tier.
  • crates/mds-core/src/lint/fix.rs — Tier table gains legacy-interpolation (mechanised by
    the test above).

mds-core (tests)

  • crates/mds-core/tests/api_surface.rs — 2 new call sites in public_functions_exist; new
    vars_load_fields_are_readable.

mds-cli (src)

  • crates/mds-cli/src/build.rsload_optional_vars_file returns Option<VarsLoad>;
    RuntimeVars gains duplicate_vars_file_keys, duplicate_vars_file_keys_omitted,
    vars_file; new emit_duplicate_vars_file_warnings (quiet early-out, escaping at the
    interpolation site); emit_duplicate_var_warnings calls it first, per D8.
  • crates/mds-cli/src/watch.rsvars_path_raw on FileCompileCtx/DirWatchCtx;
    rebuild_file, handle_fs_event_dir and liveness_probe_dir emit gated on the
    content-changed signal; compile_one_source/process_dir_batch* thread any_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.rsdup_vars_file_warning, dup_vars_file_omitted,
    count_occurrences helpers 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-heal
    rebuild) and i20 (watch --quiet on rebuild).

Docs

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

@dean0x

dean0x commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

PF-013 control PC2 (recorded after the PR body)

Mutation: crates/mds-core/src/vars_json.rs visit_map, dedup condition changed from
if !seen.insert(key.clone()) && reported.insert(key.clone()) to
if !seen.insert(format!("{key}#{}", seen.len())) && reported.insert(key.clone())
— every seen insert is now unique, so no duplicate is ever detected.

Baseline: cargo nextest run -p mds-core vars_json — 17/17 GREEN.

Mutated (RED): 12/17 failed, exactly the duplicate-detecting tests — a_key_containing_a_dot_renders_ambiguously, an_array_root_reports_index_prefixed_paths, duplicates_are_reported_in_encounter_order, flat_duplicate_is_reported_once, nested_duplicate_reports_dotted_path, duplicate_two_levels_under_an_array_element, duplicate_inside_array_element_reports_bracket_index, triple_repeat_reports_one_entry, same_key_in_two_objects_is_two_paths, nesting_at_the_serde_json_limit_is_scanned, paths_below_the_cap_are_all_kept, recorded_paths_are_capped_and_the_rest_counted. The 5 no-duplicate/error-case tests stayed green (malformed_json_is_an_error, clean_document_reports_no_duplicates, every_json_leaf_shape_is_accepted, nesting_beyond_the_serde_json_limit_is_an_error_not_a_panic, trailing_data_is_an_error).

Representative assertion: flat_duplicate_is_reported_onceassertion left == right failed / left: [] / right: ["x"].

Restore: git checkout -- crates/mds-core/src/vars_json.rs, git status --short empty, re-ran suite — 17/17 GREEN again.

Run in a detached worktree at 2a1b996; the PR head is unchanged.

@dean0x
dean0x merged commit 2b91850 into main Sep 9, 2026
46 checks passed
@dean0x
dean0x deleted the fix/326-vars-file-duplicate-keys branch September 9, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant