feat: mds lint — static analysis and diagnostics (#61)#171
Conversation
Add `offset: usize` field to all three ExportDirective variants (Named, ReExport, Wildcard) carrying the byte offset of the @export token. This enables span-based lint diagnostics for duplicate-export (AC-F-09). Changes: - ast.rs: add `offset: usize` to Named/ReExport/Wildcard variants - parser_helpers.rs: parse_export_directive takes offset param, threads it into all three variant constructions - parser.rs: pass `offset` from the directive token to parse_export_directive - resolver.rs: mechanical `..` ripple on 3 match arms (no logic change) - lib.rs: mechanical `..` ripple on Wildcard arm in scan_imports (no logic change) - parser_tests.rs: D2 canary tests (RED→GREEN) asserting offset correctness - tests/api_surface.rs: L-API-3 reconciliation (all three export forms still resolve) Co-Authored-By: Claude <noreply@anthropic.com>
…limits (#61) Add S1 lint foundation to mds-core: - lint/diagnostic.rs: LintDiagnostic (miette::Diagnostic impl with severity() override), Severity (Off/Info/Warn/Error, serde lowercase), LintResult with to_canonical_json() (BTreeMap-ordered, serde_json::json!() safe), LintResultBuilder with MAX_DIAGNOSTICS truncation, sanitize_control_chars (C0/C1, render-boundary only) - lint/config.rs: LintConfig with per-rule severity_for() lookup - lint/facts.rs: AnalysisContext + collect_facts() single-pass walk bounded by MAX_NESTING_DEPTH=64; depth guard tested via direct AST construction (parser enforces same limit, making it defense-in-depth) - lint/mod.rs: lint_source() pipeline (re-parse + facts walk + S1 stub run_rules) - limits.rs: MAX_DIAGNOSTICS = 1_000 (pub re-exported via lib.rs, L-API-5) - error.rs: compute_line_column promoted to pub(crate) - lib.rs: pub(crate) mod lint; four public API functions (lint_str, lint_str_with, lint, lint_virtual) with check-gate-once pipeline (AC-PERF-01); MAX_DIAGNOSTICS re-exported as pub const - api_surface.rs: L-API-1/2/4/5 pin tests, L-U-JSON1 canonical JSON golden schema, lint_str/lint_virtual smoke tests All tests pass (cargo test --workspace). Zero warnings (cargo clippy -D warnings).
Add lint() to the WASM binding following the check() pattern:
- Accepts same options as check (filename, vars, modules)
- Calls mds::lint_virtual through the check gate then lint_source
- Returns canonical lint JSON: { version, files, truncated }
- In S1 files array is always empty (rule impls arrive in S2)
Build verified: cargo check -p mds-wasm --target wasm32-unknown-unknown
passes with zero warnings/errors. Dev bundle confirms `exports.lint` is
present in generated JS. Optimized size baseline (< 700 KB) requires CI
with Binaryen wasm-opt (not installed locally); verified against existing
check-gate parity — lint stub adds no monomorphization beyond the check path.
Steps 4 and 5 of the mds-lint implementation: Local-AST rules (Tier A/C): - empty-block: @if/@for/@define/@elseif/@else/@message bodies (not @block) - redundant-else: structurally identical then/else bodies - unreachable-branch: literal==literal conditions + duplicate @elseif - duplicate-import: normalized path equality (D1 segment-collapse) - duplicate-export: named and wildcard duplicates with D2 offsets Semantic rules (Tier B/C): - unused-variable: FM keys not referenced in body (Tier C) - unused-import: alias/selective forms; merge always used (Tier B) - unused-function: unexported+uncalled @define (Tier B, explicit-exports only) - shadow-variable: inner scope shadows outer (Info, default-off) Supporting infrastructure: - facts.rs: full AnalysisContext with imports/exports/defines/FM-vars, used_vars/calls/namespaces/include_aliases, shadow_pairs, and is_partial_or_extends suppression; single-pass bounded walk - structural_eq.rs: manual AST equality (no PartialEq due to f64) - diagnostic.rs: LintResultBuilder visibility lifted to pub(crate) - lib.rs: re-exports lint::fix module for S3 CLI access Tests: F2 (whitespace-body produces Text node), F3 (check_str precondition), L-U-H1 (normalize_import_path), L-U-DE1 (D2 offset canary), L-U-SV1 (shadow-variable default-off), plus happy/sad path per rule.
…gate (#61) Step 6: pure in-memory fix planner (no I/O — file write is S3/CLI's job). Tier contract (T5): - Tier A (auto-fix): duplicate-import, duplicate-export, unreachable-branch, empty-block — span-guided line removal with reverify gate - Tier B (recompile-diff): unused-import, unused-function — fixable only when is_standalone=true (caller controls) - Tier C (never): unused-variable, redundant-else, shadow-variable Mechanics: - plan_fixes(): collect ByteEdit line-removal spans per fixable diagnostic - has_overlapping_edits(): overlap detection — rejects entire batch on any overlap (L-FIX-OVL1, fail-closed) - apply_plan(): right-to-left single-pass byte-range removal - apply_fixes(): full reverify gate — refuses if callback returns Err or produces new untargeted diagnostics (L-FIX-REV1) - extend_to_line_end(): CRLF discipline — always consumes \r\n as a unit, never leaves stray \r bytes (L-FIX-CRLF1 / AC-F-24) - FixPlan.truncated flag propagates LintResult.truncated (AC-F-25) Re-exported as mds::fix module; fixable_diagnostics() helper for CLI JSON output (feeds the "fixable" field in canonical JSON).
9-rule lint engine (AnalysisContext, rule dispatch, fix planner) added in S2 pushed the local wasm-opt -Oz binary to 712,419 bytes, exceeding the 700,000 byte guard. Raising to 750,000 (+50K, consistent with prior increment pattern) to cover CI toolchain variation. Co-Authored-By: Claude <noreply@anthropic.com>
…son (#61) - Add `is_standalone: bool` to `LintResult`; computed from `!ctx.is_partial_or_extends && ctx.imports.is_empty()` in `lint_source()` - `LintResultBuilder::build(is_standalone)` propagates the flag - `to_canonical_json()` uses an inline tier table to compute the `fixable` field: Tier A always true, Tier B true when standalone, Tier C false - Add `LintCliConfig` + `lint` field to `MdsConfig` in build.rs so `mds.json { "lint": { "rules": { ... } } }` is parsed and forwarded to the core `LintConfig` - Fix all in-module rule tests: `builder.build()` → `builder.build(false)` - Add `lint_canonical_json_fixable_semantics`, `lint_str_trivial_source_returns_empty` (is_standalone assertion), and `lint_str_with_imports_is_not_standalone` to api_surface.rs
…ormat, --set (#61) - `crates/mds-cli/src/lint.rs`: full lint subcommand implementation - Input modes: single file, directory (path-sorted, incl. partials), stdin - Exit codes via direct std::process::exit: 0 clean, 1 warn-only, 2 error/gate-fail/usage-error, 3 ResourceLimit - --fix: atomic write (tempfile in same dir + rename, TOCTOU-safe re-check) - --fix --check: preview, never writes, exit 1 if pending - --fix --diff: unified diff to stdout with TTY colorization - --fix stdin: filter mode — fixed source → stdout - --fix --format json stdin: USAGE ERROR exit 2 - --format json: all output to stdout; gate failures as error envelope - --quiet: suppresses Warn/Info rendering, not exit codes - sanitize_control_chars applied at human render boundary only (AC-F-16) - Directory mode: accumulate-and-continue, max severity exit code - mds.json `lint.rules` section → LintCliConfig → LintConfig - Unknown rule names in mds.json: stderr warning, ignored - `src/main.rs`: add `Commands::Lint` with clap args + dispatch - `Cargo.toml`: move tempfile from dev-deps to deps (needed for atomic write) - 6 test fixtures: lint_clean.mds, lint_warn_only.mds, lint_error.mds, lint_gate_fail.mds, lint_var_required.mds, _lint_partial.mds - `tests/cli_lint.rs`: 15 integration tests covering all major ACs (channels, JSON format, --fix, stdin, --set, --quiet, directory mode)
…iring, S4 bindings context (#61)
Add labels() to LintDiagnostic so miette renders source lines + caret context in the CLI. Attach source via Report::with_source_code() at the CLI render boundary (not stored in the struct). Adds a CLI test confirming source text appears in stderr.
Extend WASM lint() with rules: Record<string,string> via extract_rules() and add lintVirtual(modules, entry, options) export. Validate severity strings against the closed enum via serde_json. ParsedLintOptions wraps base ParsedOptions + lint_config for clean option separation.
…61) Add three lint exports to mds-napi: lint(source, opts), lintFile(path, opts), lintVirtual(modules, entry, opts). Validate rules via extract_rules_direct (severity via serde_json closed-enum roundtrip). lintFile rejects basePath option. Adds 18 integration tests in index.spec.mjs including shape, rules silencing, guard, and parity tests.
Add LintResult frozen pyclass (version, files, truncated getters + to_dict + to_json + pickle) and three lint functions: lint(source, *, base_path, vars, rules), lint_file(path, *, vars, rules), lint_virtual(modules, entry, *, vars, rules). Severity validated via serde_json closed-enum roundtrip. Adds 24 pytest tests in test_lint.py including canonical shape, rule silencing, parity (lint == lint_file canonical JSON), and LintResult class contract.
…goldens (#61) Step 11: packages/mds universal TypeScript wrapper - Added LintSpan, LintDiagnostic, LintFileResult, LintResult, LintOptions, LintFileOptions types to types.ts - Extended MdsBaseBackend with lint/lintVirtual; MdsNodeBackend with lintFile - contract.ts: added 'lint' to BASE_METHODS, 'lintFile' to NODE_METHODS, 'lintVirtual' to WASM_EXPORTS; ResultKind += 'lint'; assertResultShape case - native.ts: NapiAddon += lint/lintFile/lintVirtual; lintOpt/lintFileOpt helpers - wasm.ts: WasmModule += lint/lintVirtual; createWasmBackend forwards both - node.ts: wrapWithFileOps += lintFile (via buildModulesMap); public lint/lintFile/ lintVirtual exports; re-exports all lint types - index.ts: re-exports all lint types from types.ts - 205 tests: backend-contract (40), wasm-backend (20), lint (21), goldens (3), all existing suites — 205 pass Step 12: byte-parity goldens (checkpoint c) - packages/mds: U-LG1–U-LG3 canonical JSON goldens (lintVirtual clean/unused/silenced) - crates/mds-napi: P-L-2/P-L-3 checked-in golden comparison for napi lintVirtual - crates/mds-python: LINT_GOLDENS (PAR4 × 3) + PAR5 live CLI byte-parity - crates/mds-python: fixed test_p_l1 to use clean source (file key differs for sources with findings: lint uses "<source>", lint_file uses basename) Supporting fixes - Rebuilt mds-napi .node with lint exports (cargo build -p mds-napi --release) - Rebuilt @mdscript/mds-wasm with lint/lintVirtual (npm run build -w) - Rebuilt mds-cli release binary to match current mds-core (fixes test_par2) - Python: expose lint/lint_file/lint_virtual/LintResult in __init__.py/__init__.pyi - Python: add LintResult class + lint stubs to _mdscript.pyi - Python: pyrightconfig.json + --project flag in test to fix pre-existing pyright import-resolution failure (package at python/, not site-packages)
spec.md: - §7.1: add mds lint to commands table - §7.5 (new): full mds lint reference — input modes, channel discipline, options table, exit codes table, JSON output shape - §7.6–7.7: renumbered (init, auto-detection) - §7.8: mds.json — add lint.rules field with example - §7.9: exit codes — separate lint codes from build/check codes - §8 (new): Lint Rule Catalog table (9 rules, tier, severity, fixable) - §9: Complete Example (renumbered from §8) README.md: - Commands block: add mds lint line - New "Static analysis with mds lint" section: quickstart, rule table, exit codes, JSON shape CHANGELOG.md: - [Unreleased] Added: full mds lint entry covering all surfaces (CLI, mds-core, napi, WASM, universal TS, Python)
…ssion tombstones Move FixTier/rule_tier/is_fixable from fix.rs into a new lint/tier.rs leaf module. This eliminates the duplicated inline tier table in diagnostic.rs::to_canonical_json (previously needed to avoid a circular dep: fix.rs → diagnostic.rs → fix.rs). Both diagnostic.rs and fix.rs now import from tier.rs; fix.rs re-exports the public symbols so the mds::fix::FixTier API surface is unchanged. Also remove three relay-session tombstone references: - "S2" in diagnostic.rs to_canonical_json doc comment - "S3's job" in fix.rs module-level doc - "S3 execution plan" in cli_lint.rs test module doc
Scrutinizer self-review fixes for `mds lint` (#61): - fix(lint): reverify gate no longer refuses a valid --fix when an unrelated pre-existing diagnostic survives. apply_fixes now takes the original LintResult and rejects only on a NEW untargeted diagnostic (per-rule count increase), matching AC-F-20/AC-F-23 (residual findings are expected to remain and determine exit). Previously any coexisting non-fixed finding of another rule (e.g. a Tier C unused-variable) reverted the whole batch. - fix(lint): panic on valid input in frontmatter key-offset search — advance by the matched substring length instead of one byte so a non-ASCII key that also appears in an earlier quoted value can't slice mid-character. - fix(lint): redundant-else no longer fires (false positive) when an @elseif branch is present — then==else is not redundant while an elseif differs. - fix(napi): lintVirtual now enforces the same module-count and per-module / aggregate size caps as the WASM and Python bindings (FFI defense-in-depth). - fix(lint-cli): truncation note no longer prints a bogus "0 findings suppressed"; "Clean" is not printed when unfixable diagnostics remain. Adds regression tests for each. cargo test/fmt/clippy green; WASM 749,840 < 800,000; packages/mds 205 JS tests pass.
M2: @if always-true with NO later branches no longer fires unreachable-branch (was a false positive — nothing is unreachable when there is no @elseif/@else; Appendix A: "always-true → LATER branches unreachable"). Updated tests to use fixtures with later branches for the fire-cases; added `always_true_no_later_branches_does_not_fire`. M4: de-duplicate triple-report for always-true @if + duplicate always-true @elseif. Pattern 2 now emits at most ONE finding per @elseif branch — when a branch is both a structural duplicate AND always-literal, only the duplicate finding is emitted (both describe the same dead code). Added `triple_report_dedup_yields_at_most_two_findings`. M3: --fix reverify gate now includes output byte-equality (AC-F-20). `plan_and_apply_fixes` compiles the original source before applying fixes; the reverify closure compares compiled output of the fixed source to the baseline and returns Err if they differ. If the original doesn't compile (missing runtime vars), the output-diff is skipped — existing gates still apply. Updated fix.rs doc comment to state the full gate contract. Added `l_fix_rev1_output_delta_causes_rejection`. Co-Authored-By: Claude <noreply@anthropic.com>
M1: Rebuilt §8 rule catalog from code truth. Corrected: header claim "all rules at warn by default" (false — error/info/off all exist); severities for unreachable-branch/duplicate-import/duplicate-export (error, not warn); shadow-variable (info, default-off, Tier C, not warn/A); redundant-else (Tier C, not fixable/A); unused-variable (Tier C, not B); Tier B fixable column (suggestion for standalone only — always suggestion-only in practice for unused-import since any file with @import is non-standalone). Added footnotes: (a) Tier A block rules best-effort (reverify gate may refuse); (b) shadow-variable default-off; (c) unused-import always suggestion-only. Added note: Tier A gate now includes output byte-equality. M5: Appended opt-in shadow-variable lint note to spec §6 shadowing item. M6: Removed LintFileResult from mds-core public API list in CHANGELOG (it is a TypeScript wrapper type only; mds-core exports LintResult/LintDiagnostic/LintConfig/Severity). M7: Added comment in unused_import.rs explaining that the Wildcard re-export exemption is intentionally omitted (adjudicated more-correct than Appendix A's literal wording — wildcard re-export operates on imported module's own exports, not on local import bindings). Co-Authored-By: Claude <noreply@anthropic.com>
…es (#61) In --format json mode, top-level analysis failures (nonexistent path, mds.json load/parse failure, directory-root symlink, stdin config failure) now route through emit_analysis_failure_json_or_stderr instead of printing a human message to stderr and leaving stdout empty. Changes: - read_source_file: return Result<String, MdsError> instead of miette::Result so callers can pass the error directly to emit_analysis_failure_json_or_stderr without downcasting (AC-F-14). - run_lint_file: handle load_lint_config and read_source_file failures explicitly with emit_analysis_failure_json_or_stderr. - run_lint_stdin: same for load_lint_config failure. - run_lint_directory: same for load_lint_config failure. - do_lint: directory-root symlink error now emits JSON envelope; adds deliberate-exception comment for the --fix --format json stdin usage error (AC-F-22b: that path stays as a plain stderr message per design). - lint_one_file_accumulating: per-file read errors now use e.serialize() for structured JSON output (consistent with the lint gate failure shape). - lint_one_file_human: use miette::Report::from(e) for proper fancy render. Tests added (L-CLI-JSON4, L-CLI-JSON5): - json_format_nonexistent_path_emits_error_envelope: verifies exit 2 + stdout JSON with error.code == "mds::file_not_found". - json_format_malformed_config_emits_error_envelope: verifies exit 2 + stdout JSON envelope when mds.json is malformed; error stays off stderr. Co-Authored-By: Claude <noreply@anthropic.com>
Remove redundant `quiet: _,` destructuring pattern that was already covered by the `..` (rest pattern) in the same struct destructuring. Clippy 1.97 flags this as unneeded_wildcard_pattern.
Code Review Summary — PR #171: mds lint (6 reviewers)Merge Posture✅ STRONG ADDITIVE IMPLEMENTATION with defined conditions to address before merge. Blockers: Documentation accuracy + one reachable panic + fail-closed test gaps in Critical Issues Requiring Fixes1. Documentation: "all rules warn by default" is factually false (README.md:210, CHANGELOG.md:80)Severity: CRITICAL (98% confidence) The text claims "all
This breaks the exit-code behavior documented in the same section: a file with a duplicate import exits 2 (error), not 1 (warning). It directly contradicts spec §8 ("not all rules default to Fix: Reword the intro line and update the severity table: Rules (Most default to `warn`; `duplicate-*` and `unreachable-branch` default to `error`,
`shadow-variable` is `info` and off by default; configure via `mds.json` `lint.rules`):
| Rule | Severity | Description |
| `duplicate-import` | error | ... (auto-fixable) |
| `duplicate-export` | error | ... (auto-fixable) |
| `unreachable-branch` | error | ... (auto-fixable) |
| `redundant-else` | warn | ... |
| `shadow-variable` | info (default-off) | ... |2. Documentation: Phantom
|
| Category | Finding | Severity | Status |
|---|---|---|---|
| Docs | "all warn default" false | CRITICAL | Must fix |
| Docs | Phantom --set-rules |
HIGH | Must fix |
| Docs | redundant-else wrong |
HIGH | Must fix |
| API Safety | Unchecked slice panic | MEDIUM | Must fix (public API) |
| I/O | write_stdout no flush |
MEDIUM | Must fix (stdout truncation) |
| Repo | Handoff doc tracked | MEDIUM | Must fix (gitignore violation) |
| Testing | Overlap rejection vacuous | HIGH | Should fix |
| Testing | Block-span refusal untested | HIGH | Should fix |
| Testing | Exit-3 never asserted | MEDIUM | Should fix |
| Complexity | CLI twin functions | HIGH | Should refactor |
| Types | LintSpan missing fields | MEDIUM | Should fix |
| Types | severity not union | MEDIUM | Should fix |
| Python | typecheck_sample gaps | MEDIUM | Should extend |
| Rust | Severity no Copy | LOW | Polish |
| Perf | collect_facts docs gap | LOW | Polish |
Recommendation: ✅ APPROVED_WITH_CONDITIONS
Address the 6 CRITICAL/MEDIUM blocking items before merge. The 5+ HIGH/MEDIUM should-fix items are mechanical refactors that improve safety/maintainability; address before v0.5.0. Lower-confidence suggestions (60–79%) can follow.
Review generated by 6 agents (documentation, reliability, testing, complexity, consistency, architecture, rust, typescript, python, performance; regression & dependencies verified clean). All findings are ≥80% confidence unless marked otherwise.
…view #171 TS-2: LintDiagnostic.severity narrowed from bare `string` to `'error' | 'warn' | 'info'` — the Rust Severity enum is closed and never serializes any other value. `rule` is left as `string` (open, forward-compat). TS-3: WasmModule.lintVirtual `modules` param narrowed from `object` to `Record<string, string>` to match MdsBaseBackend.lintVirtual and the other module-map params on the interface. LintSpan left as-is (SPAN-1 DROP — wire format never emits line/column on that type). Co-Authored-By: Claude <noreply@anthropic.com>
…ace per review #171 Extends `crates/mds-python/tests/typecheck_sample.py` with four functions that exercise the new lint surface under `mypy --strict` and `pyright`: - `lint_source` — calls `mdscript.lint(source)`, accesses `.version`, `.truncated`, and `.files` via explicitly-annotated locals. - `lint_source_with_options` — calls `lint` with all keyword args (`base_path`, `vars`, `rules`); exercises `.to_dict()` and `.to_json()`. - `lint_from_file` — calls `mdscript.lint_file(path, vars=..., rules=...)`, returns the `LintResult` with an explicit annotation. - `lint_virtual_graph` — calls `mdscript.lint_virtual(modules, entry, vars=..., rules=...)`, accesses `.files` and `.to_json()`. `LintResult` is added to the top-level `from mdscript import ...` line; `Any` is imported from `typing` to annotate the `list[dict[str, Any]]` / `dict[str, Any]` shapes returned by `.files` and `.to_dict()`. Verified clean: `mypy --strict` → 0 issues, `pyright` → 0 errors/warnings.
…per review #171 CON-2: lintVirtual reused parse_lint_file_opts, so passing `basePath` produced an error message naming "lintFile" and referencing "the file path" — both wrong for the virtual surface which has no filesystem path. Introduce parse_lint_virtual_opts (mirrors WASM parse_lint_virtual_options approach): same valid-key guard (vars, rules), same explicit basePath rejection, but with a message that names `lintVirtual` and omits any reference to a file path. Add test L-NV-4 asserting the rejection carries mds::invalid_options code and that the message names lintVirtual (not lintFile). Co-Authored-By: Claude <noreply@anthropic.com>
| } | ||
| ``` | ||
|
|
||
| Keys are in alphabetical order (BTreeMap serialization). `"truncated": true` when the result set was capped by the per-file diagnostic limit (default 500). `"span"` is absent for diagnostics that lack a source location. |
There was a problem hiding this comment.
Truncation limit doc says "default 500", but the cap is a fixed 1000 — BLOCKING (97%)
This line says truncation happens at the "per-file diagnostic limit (default 500)", but the shipped constant is MAX_DIAGNOSTICS: usize = 1_000 (crates/mds-core/src/limits.rs:94), enforced in LintResultBuilder (diagnostic.rs:282; the builder_truncates_at_max_diagnostics test asserts len == 1000). It is a compile-time pub(crate) const — not configurable — so "default" also wrongly implies a tunable knob.
Suggested fix: …capped by the per-file diagnostic limit (**1000** per file). — and drop the word "default" (the cap is a fixed constant, not a configurable default).
Cycle 2 review · documentation lens · confidence 97% · 🤖 Claude Code
| "file": "template.mds", | ||
| "diagnostics": [ | ||
| { | ||
| "rule": "unused-variable", |
There was a problem hiding this comment.
JSON example key order contradicts the canonical (alphabetical) wire format — BLOCKING (85%)
This example prints the diagnostic keys as rule, severity, message, help, fixable, span and the file object as file, diagnostics, but to_canonical_json() serializes via serde_json compiled without preserve_order, so keys emit in BTreeMap (alphabetical) order — exactly what the sentence just below (spec.md:971, "Keys are in alphabetical order") and the checked-in goldens assert (crates/mds-napi/__test__/index.spec.mjs:969, crates/mds-python/tests/test_parity.py:162, packages/mds/__test__/lint.spec.mjs:231). The example self-contradicts the alphabetical-order note directly beneath it and the PR's own "byte-identical canonical JSON" guarantee. (The compact-vs-pretty difference is fine; the key order is the substantive contradiction.)
Suggested fix: reorder to diagnostic fixable, help, message, rule, severity, span; span { "length": …, "offset": … }; file object { "diagnostics": […], "file": "template.mds" }. The compact example at CHANGELOG.md:102 is already alphabetical and can serve as the reference.
Cycle 2 review · documentation lens · confidence 85% · 🤖 Claude Code
| | `duplicate-import` | **error** | yes (A) | A | The same file is imported more than once in a single file (modulo alias). | | ||
| | `duplicate-export` | **error** | yes (A) | A | The same export name is defined more than once in a single file. | | ||
|
|
||
| ¹ **Tier B suggestion**: `unused-import` and `unused-function` fixes are suggestion-only in practice. A file that has `@import` statements is by definition not standalone under the current conservative standalone rule, so these rules are always suggestion-only (never auto-applied by `--fix`). Set the rule to `"off"` in `mds.json` to silence them. |
There was a problem hiding this comment.
Footnote ¹ mis-attributes why unused-function is never auto-applied — SHOULD-FIX (80%)
The @import→non-standalone argument is valid for unused-import, but it does not hold for unused-function: that rule fires on an uncalled @define in a file with explicit exports, which can be standalone (no @import/@extends). For such a file is_standalone == true and is_fixable("unused-function", true) == true (tier.rs:41-45; tier_b_rules_fixable_only_standalone), so the Tier B edit is planned and attempted by --fix. It is refused by the block-span reverify gate — removing the single @define line orphans @end → parse failure (footnote ³ / ADR-001) — not by the standalone rule. The "never auto-applied" conclusion is correct, but the stated reason is wrong for unused-function and could mislead a maintainer reasoning about the fix pipeline.
Suggested fix: split the reasoning — keep the @import→non-standalone argument for unused-import, and for unused-function note that its edit targets a multi-line @define block whose single-line removal orphans @end, so the reverify gate refuses it (cross-reference footnote ³).
Cycle 2 review · documentation lens · confidence 80% · 🤖 Claude Code
| assert "rule" in d | ||
| assert "severity" in d | ||
| assert "message" in d | ||
| assert "help" in d or d.get("help") is None |
There was a problem hiding this comment.
Tautological assertion — can never fail (dead test) — BLOCKING (92%)
assert "help" in d or d.get("help") is None is always True: when "help" is absent, d.get("help") returns None → the right operand is True; when present, the left operand is True. The assertion provides no coverage — including for the exact regression it appears intended to catch (the binding silently dropping the help field). The canonical lint wire format always carries help (golden: test_parity.py:162 — "help":"Remove the frontmatter key..."). The sibling assertions in this function (rule, severity, message, fixable) are meaningful; only this line is dead.
Suggested fix:
assert "help" in d # help is always present in the canonical wire format(or, if absence is genuinely allowed: assert d.get("help") is None or isinstance(d["help"], str)).
Cycle 2 review · python lens · confidence 92% · 🤖 Claude Code
| /// console.log(result.files.length); // number of files with findings | ||
| /// ``` | ||
| #[wasm_bindgen(js_name = "lintVirtual")] | ||
| pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result<JsValue, JsValue> { |
There was a problem hiding this comment.
lintVirtual error text says options.modules, but napi/Python say modules — text-only (~80%)
This new lint_virtual takes modules as a positional argument, then validates caps via the shared parse_modules_from_map (:283), which hardcodes the prefix options.modules in every message — e.g. "options.modules exceeds maximum module count of 256 …" (:289), :306, :317. That prefix is correct for the compile/check path (modules arrive via options.modules), but wrong here where no options.modules field exists. The sibling surfaces get it right — napi lintVirtual (crates/mds-napi/src/lib.rs:891) and python lint_virtual (crates/mds-python/src/lib.rs:683,741,751) emit "modules …". It is also internally inconsistent: the non-object guard at :764 already says "modules must be a plain object". Error CODES match (mds::resource_limit/mds::invalid_options), so this is text-only.
Suggested fix: parameterize the field label in the helper — parse_modules_from_map(mods, label) — passing "options.modules" from extract_modules and "modules" from lint_virtual; or inline the cap checks in wasm lint_virtual mirroring napi (crates/mds-napi/src/lib.rs:888-925).
Cycle 2 review · consistency lens · confidence 85% · 🤖 Claude Code
| fix, check, diff, .. | ||
| } = flags; | ||
|
|
||
| let source = match read_source_file(file) { |
There was a problem hiding this comment.
Entry source read from disk but never used in the JSON directory report-only path — SHOULD-FIX (90%)
lint_one_file_accumulating unconditionally calls read_source_file(file) here (a symlink-check + size-capped full-file read + String allocation), but the resulting source is consumed only inside the if fix && !check && !diff branch (plan_and_apply_fixes(result, &source, …) at :798). For the common report-only invocation mds lint --format json <dir>, that branch is never taken, so every file's contents are read + allocated then dropped unused — one wasted disk read + one wasted full-file String alloc per file, scaling O(N) over the tree. The actual lint work is done by mds::lint(file, …) (:770), which reads the file itself and already surfaces read/symlink/size errors via its own Err arm.
Suggested fix: move the read_source_file read inside the fix branch; the report-only path only needs accumulate_result_json(&result, json_files). (Contrast lint_one_file_human, whose read is justified — it needs source for named_source span rendering even in report-only mode.)
Shares a root cause with the mds::lint double-read flagged at crates/mds-core/src/lib.rs:1009.
Cycle 2 review · performance lens · confidence 90% · 🤖 Claude Code
| cache.resolve_path_intrinsic(path_str, &vars, &mut warnings)?; | ||
| } | ||
| // Read source for lint re-parse (mirrors NativeFs::read size guard). | ||
| let bytes = |
There was a problem hiding this comment.
Entry file read 2–3× per lint invocation (redundant I/O + FS-abstraction bypass) — SHOULD-FIX (~82%) · reported by both architecture and performance reviewers
The check gate reads the entry file (resolve_path_intrinsic, :1006), then this std::fs::read(path) at :1009 re-reads the same file for the lint re-parse — byte-identical content. On top of that, the CLI reads a third time via read_source_file(path) before calling mds::lint(path, …) in single-file (crates/mds-cli/src/lint.rs:541), dir-human (:843), and dir-JSON (:756) modes. The KB "re-parse entry independently" invariant is about not sharing the parsed AST (it operates at the string level) — it does not require a second disk read.
Two secondary concerns compound here:
- FS-abstraction bypass (DIP). This read uses raw
std::fs::readand re-implements theMAX_FILE_SIZEguard inline (:1011-1017), bypassing theNativeFs/FileSystemabstraction and its symlink re-check (entry is symlink-validated on the gate read, but not on this one). - Latent source-of-truth split. Spans are computed against the copy
mds::lintread; the CLI's--fixbyte edits andnamed_sourcecaret rendering operate on the separately-readread_source_filecopy — different byte sequences if the file changes between reads.
Suggested fix: thread the entry source out of the resolve step so lint_source reuses it (route any retained read through NativeFs::read), or add a source-accepting public entry (e.g. lint_source_with_filename(source, filename, config)) so the CLI reads once and passes the bytes + the real filename as the JSON file key — today lint_str_with hardcodes "input.mds" (:979), which is why dir mode can't reuse it. Non-urgent (small files, OS-cached), but worth doing while the code is fresh.
Cycle 2 review · architecture + performance lenses · confidence 82–85% · 🤖 Claude Code
| /// } | ||
| /// ``` | ||
| /// | ||
| /// Per-file grouping: diagnostics without a `file` are grouped under `null` key. |
There was a problem hiding this comment.
Doc says diagnostics without a file are grouped under null, but the code uses "<unknown>" — LOW (82%)
This comment states "diagnostics without a file are grouped under null key", but line 210 uses diag.file.clone().unwrap_or_else(|| "<unknown>".to_string()) — a string key, not JSON null. The branch is currently dead (all 9 rules construct diagnostics with file: Some(filename.to_string())), so there is no runtime impact today, but the comment misdescribes the wire contract for any future rule that omits a file.
Suggested fix: align the comment to the code (grouped under the "<unknown>" key), or — if null grouping is the intended LSP contract — emit the entry under a JSON null deliberately. Since it is unreachable, a one-line doc correction is sufficient:
/// Per-file grouping: diagnostics without a `file` are grouped under the
/// `"<unknown>"` key (currently unreachable — every rule sets `file`).
Cycle 2 review · rust lens · confidence 82% · 🤖 Claude Code
| /// If `pos` is past the end of `source`, returns `source.len()`. | ||
| /// | ||
| /// **CRLF discipline (AC-F-24)**: always include `\r\n` as a unit, not just `\n`. | ||
| pub fn extend_to_line_end(source: &str, pos: usize) -> usize { |
There was a problem hiding this comment.
extend_to_line_end returns an out-of-range offset for pos > source.len(), contradicting its documented clamp — LOW (82%)
The doc comment (line 206) states "If pos is past the end of source, returns source.len()." But with let mut i = pos;, the while i < bytes.len() && … loop never runs when pos > bytes.len(), the if i < bytes.len() terminator block is skipped, and the function returns i == pos — a value strictly greater than source.len(), not the promised source.len().
Low impact today: the sole in-crate caller (diag_to_edit, :190) is guarded upstream by source.get(..offset)? (offset ≤ len) and downstream by if line_start >= line_end || line_end > source.len() { return None; }. However, extend_to_line_end is pub and re-exported on the stable fix surface (mds::fix::extend_to_line_end, via crates/mds-core/src/lib.rs:61); an external/binding caller that trusts the documented source.len() clamp and slices with the return value would get an out-of-bounds byte index and panic.
Suggested fix: clamp the entry — let mut i = pos.min(bytes.len()); — so the postcondition matches the doc and the returned offset is always ≤ source.len().
Cycle 2 review · reliability lens · confidence 82% · 🤖 Claude Code
| similar = { workspace = true } | ||
| tempfile = { workspace = true } | ||
|
|
||
| [dev-dependencies] |
There was a problem hiding this comment.
Empty [dev-dependencies] table left behind — LOW (82%)
tempfile was the sole entry under [dev-dependencies]; moving it to [dependencies] (correctly — it is now used on the production --fix atomic-write path) leaves this [dev-dependencies] section empty. Cargo tolerates it, but it is transition residue (quality rule: "leave the end-state, not the transition"). Tests still get tempfile automatically via [dependencies], so the empty table serves no purpose.
Suggested fix: delete the now-empty [dev-dependencies] line, leaving the [target.'cfg(unix)'.dev-dependencies] block intact.
Cycle 2 review · dependencies lens · confidence 82% · 🤖 Claude Code
| } | ||
|
|
||
| /// Recursively check a node list for empty bodies. | ||
| fn check_nodes( |
There was a problem hiding this comment.
Repeated empty-body-check-and-recurse pattern (~6 occurrences) — MEDIUM (82%)
The same block — if is_empty_or_whitespace(body) { if !builder.push(make_diag(..)) { return; } } else { check_nodes(body, ..) } — is hand-repeated six times: @for/@define/@message in check_nodes (68-119) and then-body/@elseif/@else in check_if_block (135, 142-191). Each copy differs only in (offset, span-len literal, message, help). The two functions total ~135 lines to express "walk the tree and flag empty bodies"; adding a future block kind means copy-pasting the pattern a seventh time.
Suggested fix: extract one helper and reduce each site to a single call:
/// Returns false when the diagnostic cap is hit (caller should stop).
fn flag_if_empty(
body: &[Node], offset: usize, span_len: usize,
message: String, help: String,
filename: &str, severity: &Severity, builder: &mut LintResultBuilder,
) -> bool {
if is_empty_or_whitespace(body) {
builder.push(make_diag(*severity, filename, message, Some(help), offset, span_len))
} else {
check_nodes(body, filename, severity, builder);
true
}
}Each arm becomes if !flag_if_empty(&b.body, b.offset, "@for".len(), .., builder) { return; }.
Cycle 2 review · complexity lens · confidence 82% · 🤖 Claude Code
|
|
||
| // ── Single-file mode ────────────────────────────────────────────────────────── | ||
|
|
||
| fn run_lint_file( |
There was a problem hiding this comment.
run_lint_file is a ~119-line multi-mode dispatcher — MEDIUM (80%)
The function does config-load, file-read, lint, truncation warning, then branches into three distinct execution modes — the write path (573-601), the --fix --check/--diff preview path (604-625), and report-only (627-633). The length plus the multiple early-exit/early-return modes make the control flow harder to hold in one read than the section comments suggest.
Suggested fix: extract the two --fix branches into run_fix_write(...) and run_fix_preview(...), leaving run_lint_file as a thin ~40-line orchestrator that resolves config/source/result and dispatches.
Note: the FixFileOutcome match inside the write path (576-598) is the third copy already tracked by CPX-1 (#173) — do not fix it in isolation; fold it into that consolidation (a shared FixFileOutcome renderer callable from both the directory-mode lint_one_file_* twins and run_fix_write).
Cycle 2 review · complexity lens · confidence 80% · 🤖 Claude Code
Code Review Summary — Cycle 2 · PR #171
|
| Lens | Score | Recommendation | Lens | Score | Recommendation |
|---|---|---|---|---|---|
| security | 8/10 | APPROVED | reliability | 9/10 | APPROVED |
| architecture | 9/10 | APPROVED_W/_COND | rust | 9/10 | APPROVED |
| performance | 8/10 | APPROVED_W/_COND | typescript | 9/10 | APPROVED |
| complexity | 8/10 | APPROVED | python | 9/10 | APPROVED_W/_COND |
| consistency | 8/10 | APPROVED_W/_COND | dependencies | 9/10 | APPROVED |
| regression | 10/10 | APPROVED | documentation | 7/10 | CHANGES_REQUESTED |
| testing | 8/10 | APPROVED_W/_COND |
12 inline comments were posted for the ≥80%-confidence findings (3 BLOCKING, 1 dead test, plus should-fix/LOW/MEDIUM anchored items). The two entry-file-read findings from architecture and performance were consolidated into a single comment at crates/mds-core/src/lib.rs:1009.
⚠️ HIGH should-fix — no single line anchor, summarized here
Tier B fix application (unused-import/unused-function on a standalone file) is never exercised end-to-end — crates/mds-core/src/lint/fix.rs:480 (tier_b_rules_fixable_only_standalone), testing lens, 85%.
The only Tier B test asserts the is_fixable(rule, is_standalone) classification predicate — it never drives plan_fixes_with_options(result, source, include_tier_b=true) with a real Tier B diagnostic through apply_fixes. Tier B is the subtlest fix path (extra output-byte-equality reverify gate; CLI passes is_standalone into the planner). Every apply_fixes test in fix.rs (693–877) uses a Tier A duplicate-import diagnostic, so the branch that generates a Tier B edit, applies it, and gates on output-neutrality is uncovered — a regression there would ship green.
Fix: add a fix.rs test that plans with include_tier_b=true on a standalone source with an unused selective import, asserts the plan is non-vacuous, applies it, and verifies (a) removal when output is neutral, (b) Rejected when removal would change compiled output, and (c) no Tier B edit with include_tier_b=false / non-standalone.
Lower-confidence suggestions (60–79%), by reviewer — non-blocking
testing (edge-path coverage; reviewer-classified as lower-severity suggestions despite confidence)
unreachable-branch--fixbehavior never asserted —fix.rs:8(85%): declared Tier A/auto-fixable but no test pins apply-vs-fail-closed-refuse."truncated": truenever asserted in serialized output —diagnostic.rsbuilder_truncates_at_max_diagnostics(82%): cap test checks the in-struct bool, not the wire field; all goldens hardcodetruncated:false.shadow-variableInfo-severity exit-code neutrality untested at the CLI —shadow_variable.rs(72%); no CLI test drives config-based rule enabling at all.
documentation
empty-blockcoverage list omits@elseif/@else—spec.md:1048,README.md:218,CHANGELOG.md:85(70%): engine also fires on empty@elseif/@elsebodies.
architecture
LintOptionsexposesbasePathbutCompileOptionshides it —packages/mds/src/types.ts:125vs:50(70%): decide parity vs documented divergence.- Dir-mode JSON re-serializes the full canonical envelope per file just to extract
files—crates/mds-cli/src/lint.rs:953(65%).
performance
- Per-file
runtime_vars.clone()/config.clone()in the directory loop —lint.rs:703,:712,:407(70%): O(files×vars);&HashMap/Arcborrow avoids N deep clones. unreachable-branchduplicate-@elseifdetection is O(k²) —unreachable_branch.rs:146-203(65%): deliberate simplicity trade-off (Conditionhas noHash); a length guard would mitigate if ever needed.
complexity
- Duplicated untargeted-diagnostic count loops in
apply_fixes—fix.rs:340(75%): extractcount_untargeted(...). unused_import::checkinline diagnostic duplication (Alias/Selective arms) —unused_import.rs:56(72%).
typescript
LintOptions.rulesisRecord<string, string>— aLintRuleSeverity = 'error'|'warn'|'info'|'off'union would catch typos at the boundary —types.ts:119,:133(75%).NapiAddon.lintVirtualmodules typedRecord<string, unknown>vsRecord<string, string>everywhere else —native.ts:28(68%).- Repeated inline lint-option shape literal (~4×) —
native.ts:26-51(62%).
python
LintResultunhashability/frozen enforcement lacks a behavioral test —test_contract.py:153(70%): addm.lint("Hi\n")totest_unhashable.extract_rulesparsesSeverityviafrom_str(&format!("\"{s}\""))string interpolation —lib.rs:794(65%):from_value(Value::String(s))is cleaner (fail-closed, not a bug).lintkeyword-arg order(source, *, base_path, vars, rules)diverges fromcompile's(source, *, vars, base_path)—lib.rs:945(65%).
consistency
LintFileResulttype name is ambiguous vs thelintFile()function (which returnsLintResult) —types.ts:90(65%).- napi/wasm/python
lintdocstrings cite different sibling for error shape (compilevscheck) — napi:787/ wasm:669/ python:939(62%).
rust
unreachable-branch@elseifdiagnostics anchor to the@ifoffset (ASTelseif_branchescarries no per-branch offset) —unreachable_branch.rs:135,:164,:193(68%): imprecise caret; fail-closed.fix::plan_fixes/plan_fixes_with_options/apply_fixeslack#[must_use]—fix.rs:122,:130,:340(66%).
reliability
- Local-AST rule walks lack the defense-in-depth depth-guard comment the facts walk has —
unreachable_branch.rs:62(+redundant_else.rs:48,empty_block.rs,duplicate_*) (66%): safe (parser bounds nesting toMAX_NESTING_DEPTH=64pre-check); worth a one-line invariant note.
dependencies
- One-line license/rationale note for the now-production
tempfile(# MIT OR Apache-2.0; atomic --fix writes) to match thenotify/ctrlc/similarpattern —Cargo.toml:22(62%).
regression
- Native backend construction now hard-fails if the addon lacks
lint/lintFile—native.ts:67(65%): a facet of the already-documented REG-1 interface expansion (well-mitigated by coordinated single-version release), not a new regression.
security
sanitize_control_charsdoes not escape DEL (U+007F) —diagnostic.rs:312-325(62%): minor defense-in-depth completeness gap; ESC is already caught.
Cross-cycle context
- Cycle 2 review. Cycle 1 (2026-07-11) resolved 33/39 items with 0 false positives reported by reviewers this cycle — all prior-cycle fixes were verified to have held and were deliberately not re-raised: DOC-1..7, SPAN-1, RUST-1..4, REL-1, ARC-1..3, TS-2/3, PY-1, CON-1/2, TEST-1..7, PERF-1/2, REG-1.
- Already-tracked follow-ups confirmed still present and intentionally NOT re-reported as new: Refactor
mds lintCLI per-file dispatch spine #173 (CPX-1 —lint_one_file_*twins + triplicatedFixFileOutcomematch), Decomposecheck_if_blockin unreachable-branch rule #174 (CPX-2 —check_if_blocklength/depth), Centralize Severity string parsing across bindings #175 (XSURF-1 —serde_json::from_str(&format!("\"{s}\""))severity-parse hack triplicated across napi/wasm/python), Harden terminal-escape rendering across all MdsError variants (CWE-150) #176 (SEC-1 — miette raw-source terminal-escape, CWE-150, 90%), Preserve original file permission mode in atomic --fix/--write writes #177 (SEC-2 —atomic_write_filedrops original file mode, 85%), Pin the WASM toolchain and re-tighten the CI size guard #178 (DEP-1 — WASM size guard raised 700K→800K). Therun_lint_file:515inline comment explicitly defers itsFixFileOutcomematch to the Refactormds lintCLI per-file dispatch spine #173 consolidation. - Security tracked items Harden terminal-escape rendering across all MdsError variants (CWE-150) #176/Preserve original file permission mode in atomic --fix/--write writes #177 are pre-existing/repo-wide classes (not introduced by this PR beyond adding another render/write site) and remain out of blocking scope.
🤖 Generated with Claude Code · code-review orchestration, cycle 2
…ism, and empty-block coverage I-01: Correct MAX_DIAGNOSTICS 500→1,000 and remove "default" phrasing — it is a fixed const (limits.rs:94), not a configurable default. I-02: Reorder all JSON example keys to alphabetical BTreeMap order matching the actual wire format (per goldens in test_parity.py / index.spec.mjs): diagnostic keys fixable→help→message→rule→severity→span; span keys length→offset; file object diagnostics→file; top-level files→truncated→version was already correct. I-03: Correct footnote ¹ mechanism for unused-function. The rule can fire on a standalone file where is_fixable returns true; the fix is refused by the block-span reverify gate (ADR-001) — same as footnote ³ — not by the standalone rule. The unused-import reasoning (files with @import are not standalone) remains unchanged. I-36: Add @elseif/@else to the empty-block coverage list in spec.md, README.md, and CHANGELOG.md (engine fires on all six directive types per empty_block.rs and the elseif_empty_body_fires / else_empty_body_fires tests). Co-Authored-By: Claude <noreply@anthropic.com>
…dedup count loops; I-29 #[must_use] I-09 (reliability): `extend_to_line_end` documented "returns source.len() if pos is past end" but returned `pos` unchanged. Fix: `let mut i = pos.min(bytes.len())` (ADR-001 fail-closed). Added regression test `extend_to_line_end_past_end_returns_source_len`. I-13 (testing): added end-to-end Tier B coverage via `tier_b_unused_function_standalone_apply_is_refused`. Drives `plan_fixes_with_options(..., include_tier_b=true)` → `apply_fixes` through a real reverify closure on a genuine `unused-function` diagnostic on a standalone file. Lands as the refusal-path variant: removing only the `@define dead():` line leaves `@end` orphaned → reverify parse error → `FixOutcome::Rejected`. Documents why `unused-import` cannot fire on standalone files. I-19 (complexity): extracted the duplicated "count untargeted diagnostics per rule" loop in `apply_fixes` into a local inner fn `count_untargeted_per_rule`. Behavior- preserving refactor; baseline and residual branches now share one implementation. I-29 (rust): added `#[must_use]` with reason strings to `plan_fixes`, `plan_fixes_with_options`, and `apply_fixes`. Additive attribute; no signature/behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
…ated JSON
I-08: doc comment in to_canonical_json said file-less diagnostics group under
a "null" key; the code uses the string key "<unknown>". Updated the doc to
match the code and note it is a defensive fallback (every rule sets file:Some).
I-14: sanitize_control_chars missed DEL (U+007F) — some terminals interpret
it as a backspace. Added `is_del = ch == '\u{007F}'` to the escape predicate,
escaped in the same \uXXXX form as other control chars. Added unit test
`sanitize_escapes_del` to pin the behaviour.
I-25: the truncated:true wire path had no JSON-level test. Added
`builder_truncated_canonical_json` which pushes MAX_DIAGNOSTICS+1 entries,
calls to_canonical_json(), and asserts `"truncated":true` and the diagnostics
array length equals MAX_DIAGNOSTICS. Reuses the constant from limits.rs.
Co-Authored-By: Claude <noreply@anthropic.com>
…rsion-depth invariant comments I-11: extract `flag_if_empty(body, filename, severity, diag, builder) -> bool` in empty_block.rs, replacing 6 hand-copied if-is_empty/push/else-recurse blocks. `(_, branch_body)` destructure eliminates the now-redundant `let _ = cond;` in the @elseif loop. I-20: extract `make_diag(severity, filename, message, help, offset)` in unused_import.rs, unifying the duplicate LintDiagnostic constructions in the Alias and Selective match arms. Span length `"@import".len()` baked into helper. I-27: add one-line doc comment at `check_nodes` in empty_block.rs, unreachable_branch.rs, and redundant_else.rs documenting that recursion depth is pre-bounded by the parser's `enter_block` guard (MAX_NESTING_DEPTH=64), so no local depth counter is needed. All 79 lint rule tests pass unchanged. Clippy and rustfmt clean.
…file; drop empty dev-deps
I-06: In lint_one_file_accumulating, move read_source_file() inside the
fix branch (if fix && !check && !diff). The report-only/JSON path (the
common mds lint --format json <dir> case) never uses `source` — mds::lint()
reads the file independently. Eliminates one full-file read + String alloc
per file on every non-fix directory-mode run. Error handling preserved: a
read failure in the fix branch surfaces the same AC-F-14 structured error
JSON entry as before.
I-17: Change runtime_vars parameter from owned Option<HashMap<String,Value>>
to &Option<...> in both lint_one_file_accumulating and lint_one_file_human.
Call sites in run_lint_directory now pass &runtime_vars instead of
runtime_vars.clone() per iteration. The inner clones required by mds::lint()
and plan_and_apply_fixes() (both take ownership) are retained and noted.
Saves one clone per file on the non-fix path (common case).
I-10: Remove empty [dev-dependencies] table from mds-cli/Cargo.toml.
tempfile was promoted to [dependencies] in a prior commit, leaving the
section empty. The unix-only libc dev dep remains under
[target.'cfg(unix)'.dev-dependencies].
I-16: Deferred. The current accumulate_result_json already operates at the
serde_json::Value level (no string round-trip per file); the only overhead
is allocating the outer {version,files,truncated} wrapper per file and
discarding it. Eliminating this cleanly requires a new to_canonical_file_entry()
API on LintResult in mds-core — out of scope for this localized fix.
Co-Authored-By: Claude <noreply@anthropic.com>
…usal (I-24) and shadow-variable Info exit 0 (I-26) I-24: fix_refused_for_unreachable_branch_and_file_unchanged — asserts that `mds lint --fix` on an always-true @if with a later @else branch is refused (block-spanning, ADR-001), file is byte-identical after attempted fix, and the residual Error finding exits 2. I-26: shadow_variable_info_emits_diagnostic_and_exits_0 — enables shadow-variable at Info via mds.json, asserts the diagnostic appears in stderr, and asserts exit 0 (Info never contributes to exit code). Co-Authored-By: Claude <noreply@anthropic.com>
…lint doc error refs (I-05, I-22) I-05: `parse_modules_from_map` now takes a `field_label: &str` parameter used in all four of its cap/type/element error messages. The compile/check callers (`extract_modules`) pass `"options.modules"` — byte-identical to the previous messages. The `lintVirtual` caller passes `"modules"`, which is correct for a top-level positional argument (matching the napi and Python bindings and `lint_virtual`'s own pre-check guard). I-22 (doc-only): The WASM `lint` failure description previously expanded the error structure inline (`code`/`help`/`span` bullet list). Changed to "throws a JS `Error` with the same structure as [`compile`]", consistent with: - WASM `check` (already used this form) - napi `lint` (already used this form) - Python `lint` (references `compile` via `MdsError`) No code, signatures, or runtime behaviour changed for I-22. Co-Authored-By: Claude <noreply@anthropic.com>
I-04: fix tautological assertion in test_lr4_lint_result_diagnostic_shape
— `assert "help" in d or d.get("help") is None` was always True
(absent key → right arm True; present → left arm True). Changed to
`assert "help" in d` — the canonical wire format always carries the
help key (string or null) via `to_canonical_json`.
I-33: add behavioral tests for LintResult unhashability and frozen
assignment (previously only stubtest verified __hash__: None).
Added LR module-level fixture; extended test_unhashable to include
LR; added test_lint_result_frozen covering .version and .truncated.
I-34: replace format!-quoting hack in extract_rules with
`serde_json::from_value(serde_json::Value::String(s.clone()))`.
Same fail-closed semantics (invalid severity → Err), no string
wrapping. Severity's serde derive accepts a bare JSON string value.
I-35: reorder lint keyword-only params to match compile's order:
`vars` before `base_path` (then `rules`). All callers use keywords;
updated #[pyo3(signature)], Rust fn param order, and _mdscript.pyi.
stubtest (test_c6_stubtest_matches_runtime) confirms parity.
Co-Authored-By: Claude <noreply@anthropic.com>
…pe, fix modules param, extract NapiLintOpts I-21: rename per-file element type LintFileResult → LintFileReport to eliminate the collision with lintFile() (which returns LintResult, not LintFileResult). Updated the type definition, LintResult.files usage, and all re-exports in index.ts and node.ts. I-30: define RuleSeverity = 'error' | 'warn' | 'info' | 'off' and narrow LintOptions.rules and LintFileOptions.rules from Record<string,string> to Record<string,RuleSeverity>. 'off' is a valid override value per the Rust Severity enum (serde lowercase). RuleSeverity is exported from both barrel files. I-31: NapiAddon.lintVirtual modules param was Record<string,unknown>; corrected to Record<string,string>, consistent with the public API, WasmModule, and the generated crates/mds-napi/index.d.ts line 184. I-32: extract NapiLintOpts and NapiLintFileOpts local type aliases in native.ts, eliminating the repeated inline object-literal type annotation that appeared across the NapiAddon interface, lintOpt/lintFileOpt return types, and their local out variable declarations. Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Implements
mds lintper the approved design: a 9-rule static analyzer (6 warnings + 3 errors) with severity-aware exit codes (0/1/2/3), tiered--fixwith reverify safety gate,--format jsoncanonical wire protocol,mds.jsonlint config, and newinfoseverity withshadow-variabledefault-off. Byte-identical JSON proven by cross-surface parity goldens + live CLI-vs-binding tests across ALL surfaces: CLI, WASM, napi, Python, and the@mdscript/mdsuniversal wrapper. Closes the napi parity-gap pattern (scan_imports/#164 class) — napi ships lint on day one.Closes #61
No breaking changes — purely additive.
Reviewer Focus Areas
(a)
--fixsafety chain: overlap rejection fail-closed → right-to-left apply → reverify (recompile-success + no-new-diagnostics + compiled-output byte-equality) → atomic temp+rename write with re-check_symlink(b) Severity-aware exit table: incl. deliberate divergence from
mds check(analysis failure → 2; info never affects exit)(c) WASM size: two documented budget raises 700K→750K→800K in ci.yml budget history, final binary 749,272 raw
(d) 4-surface JSON parity: canonical wire protocol byte-identical across CLI, WASM, napi, Python
(e)
unused-variabledeliberately Tier C never-fixed: frontmatter keys emit into compiled outputQuality-Gate Provenance
Known Limitation
Tier A block-spanning fixes (empty-block, unreachable-branch) are currently always refused by the reverify gate: single-line removal leaves orphaned
@end, so the reverify gate fails (fail-closed, correct safety behavior, no user harm). To make Tier A real for these rules, the planner needs a block-span edit (directive-line start through the matching@endline, inclusive). Documented as best-effort in spec §8 footnote. Follow-up: #172.