Skip to content

feat: mds lint — static analysis and diagnostics (#61)#171

Open
dean0x wants to merge 46 commits into
mainfrom
feat/mds-lint-61
Open

feat: mds lint — static analysis and diagnostics (#61)#171
dean0x wants to merge 46 commits into
mainfrom
feat/mds-lint-61

Conversation

@dean0x

@dean0x dean0x commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Implements mds lint per the approved design: a 9-rule static analyzer (6 warnings + 3 errors) with severity-aware exit codes (0/1/2/3), tiered --fix with reverify safety gate, --format json canonical wire protocol, mds.json lint config, and new info severity with shadow-variable default-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/mds universal 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) --fix safety 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-variable deliberately Tier C never-fixed: frontmatter keys emit into compiled output

Quality-Gate Provenance

Component Coverage Status
Rust tests (cargo) 1574+ ✅ PASS
npm tests (bundler) 371 incl. napi 83 + wrapper 205 ✅ PASS
Python tests (pytest) 182 ✅ PASS
WASM spec tests 42 ✅ PASS
Scrutinizer (self-review) reverify over-refusal P1, non-ASCII frontmatter-key panic P1, redundant-else/@elseif FP, napi lintVirtual input caps ✅ FIXED
Alignment (spec-catalog) unreachable-branch FP ✅ FIXED
Scenario QA 44/44 incl. CRLF --fix byte-cleanliness, CLI↔Python byte parity ✅ PASS

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 @end line, inclusive). Documented as best-effort in spec §8 footnote. Follow-up: #172.

dean0x and others added 23 commits July 10, 2026 22:51
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>
…#61)

Local wasm-opt -Oz measurement: 712,419 bytes (node + web targets).
Guard raised in 9a65849; handoff updated for S3.

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)
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>
@dean0x dean0x changed the title feat: mds lint — 9-rule static analyzer across all surfaces (#61) feat: mds lint — static analysis and diagnostics (#61) Jul 11, 2026
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.
@dean0x

dean0x commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

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 --fix safety paths.
Verdict: APPROVED_WITH_CONDITIONS (all 6 reviewers recommend merge after fixes)


Critical Issues Requiring Fixes

1. Documentation: "all rules warn by default" is factually false (README.md:210, CHANGELOG.md:80)

Severity: CRITICAL (98% confidence)
Scope: README and CHANGELOG contradict the spec and the code

The text claims "all warn by default", but the code defaults 4 of 9 rules differently:

  • duplicate-import, duplicate-export, unreachable-branchError (not warn)
  • shadow-variableOff (not warn)
  • ✓ The remaining 5 rules → warn (as claimed)

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 warn").

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 --set-rules flag (README.md:210)

Severity: HIGH (97% confidence)

The text mentions "or --set-rules", but this flag does not exist in the CLI. Grep finds it only in README.md. Per-rule configuration is via mds.json lint.rules (or library API), never a command-line flag.

Fix: Delete or --set-rules from the line.

3. redundant-else Misdescribed and Wrongly Marked Fixable (README.md:219, CHANGELOG.md:86, spec.md:1049)

Severity: HIGH (94% confidence)

Two errors:

  1. Wrong description: "After an always-returning branch" — MDS has no "return" concept. The rule fires when @else body is structurally identical to the @if body.
  2. Wrongly tagged auto-fixable: README and CHANGELOG mark it "(auto-fixable)", but redundant-else is Tier C — never fixed. The --fix CLI skips it.

Fix:

  • Reword: "An @else branch whose body is identical to the @if/preceding branch body (structurally redundant)"
  • Remove "(auto-fixable)" tags from README and CHANGELOG

4. Auto-fixable Annotations Scrambled (README.md:214/lines 218–222, CHANGELOG.md:87)

Severity: MEDIUM (90% confidence)

Tier A rules (auto-fixable): duplicate-import, duplicate-export, unreachable-branch, empty-block.
README/CHANGELOG mark only empty-block and duplicate-import as "(auto-fixable)"; duplicate-export and unreachable-branch are missing the tag, and redundant-else is wrongly marked (Tier C, never fixed).

Fix: Add "(auto-fixable)" to duplicate-export and unreachable-branch rows; remove from redundant-else.

5. Unchecked Byte Slice Can Panic on Out-of-Range Offset (crates/mds-core/src/lint/fix.rs:184)

Severity: MEDIUM (85% confidence), Reachability: Public API

diag_to_edit does source[..offset].rfind('\n') without bounds-checking offset. On the CLI's own pipeline, offset originates from the AST (safe), but mds::fix::plan_fixes is shipped public API. A downstream consumer (e.g., future LSP integration) that hand-builds a LintDiagnostic with an out-of-range or non-char-boundary offset crashes instead of returning None.

Failure scenario: mds::fix::plan_fixes(&result, "é") with span.offset == 1 (mid-byte of 2-byte é) → panic.

Fix: Use source.get(..offset)? instead of direct slicing:

let offset = span.offset;
let prefix = source.get(..offset)?;  // Returns None for out-of-range or non-boundary
let line_start = prefix.rfind('\n').map(|p| p + 1).unwrap_or(0);

6. write_stdout Diverges from Sibling fmt.rs — No Flush Before Exit (crates/mds-cli/src/lint.rs:214)

Severity: MEDIUM (85% confidence), Impact: Silent stdout truncation

The fmt.rs sibling explicitly flushes stdout and handles BrokenPipe. The lint.rs reimplementation does neither. In the --fix stdin filter path, Rust's LineWriter keeps a trailing partial line buffered; std::process::exit skips flushing, so fixed output without a trailing newline is silently truncated.

Fix: Reuse or duplicate fmt.rs's write_stdout:

fn write_stdout(s: &str) -> Result<()> {
    let mut stdout = std::io::stdout();
    if let Err(e) = stdout.write_all(s.as_bytes()) {
        if e.kind() == std::io::ErrorKind::BrokenPipe { return Ok(()); }
        return Err(...);
    }
    if let Err(e) = stdout.flush() {
        if e.kind() != std::io::ErrorKind::BrokenPipe {
            return Err(...);
        }
    }
    Ok(())
}

7. Committed Internal Handoff Doc Should Be Deleted (.devflow/docs/handoff-feat-mds-lint-61.md)

Severity: MEDIUM (90% confidence)

.gitignore marks .devflow/ as "local-only, not shared via git", but the 567-line handoff doc is tracked in HEAD and added by this PR. It's not referenced by spec.md/README.md/CHANGELOG, so removal is safe.

Fix: git rm .devflow/docs/handoff-feat-mds-lint-61.md


High-Confidence Issues Affecting Testing & Refactoring

8. Test Gaps in --fix Refusal Paths (testing.rs)

Severity: HIGH coverage gap

Three fail-closed safety mechanisms exist but lack affirmative tests:

  • Overlap rejection: apply_fixes rejects same-line overlaps, but the test is guarded by if plan.overlap_rejected { ... } — if overlap detection regresses, test passes vacuously.
  • Block-spanning orphaned @end refusal: --fix on a multi-line empty-block removes only the opening directive, orphaning @end. Reverify gate should refuse this; never tested in the real pipeline.
  • CLI exit-3 (ResourceLimit): Never asserted; exit codes 0/1/2 are tested but not 3.

Fix Priorities:

  1. Assert overlap rejection unconditionally: assert!(plan.overlap_rejected) + test the apply_fixes refusal path
  2. Add CLI integration test: --fix a multi-line empty-block, assert file unchanged + "fix rejected" on stderr
  3. Add nesting-depth test: >64 nested @if blocks → exit 3

9. Complexity: CLI lint_one_file_* Functions Are Near-Identical 80-Line Twins (crates/mds-cli/src/lint.rs:738, 822)

Severity: HIGH duplication (88% confidence)

lint_one_file_accumulating and lint_one_file_human are structurally identical except for emit strategy. The 3-arm match fix_outcome appears 4 times across the module. Any fix-outcome logic change requires shotgun surgery.

Fix: Extract to one function parameterized by emit sink; collapse duplicates.

10. Deeply Nested Rule Checks (unreachable_branch.rs:95, duplicate_*.rs)

Severity: HIGH nesting (85–88% confidence)

check_if_block reaches 6 levels of indentation; duplicate_export/duplicate_import repeat the None → insert, Some → push pattern 3 times each. All mechanically refactorable.

Fix: Split concerns, extract helpers for duplicate logic.

11. 7× Copy-Pasted Exit-Code Blocks (crates/mds-cli/src/lint.rs)

Severity: MEDIUM duplication (92% confidence)

The let exit = result_exit_code(&X); if exit != 0 { process::exit(exit); } idiom is repeated 7 times. Invisible opportunity for referencing the wrong result.

Fix: Add helper fn finish(result: &LintResult) -> ! or Result<()> variant.


Type & Interface Issues

12. TypeScript: LintSpan Missing line? / column? Fields (packages/mds/src/types.ts:66)

Severity: MEDIUM (90% confidence)

The Rust wire format emits span: { offset, length, line?, column? }, but the TS LintSpan interface declares only offset and length. Runtime data is present (unmarked extra fields tolerated by shallow validator), but TypeScript consumers get a compile error accessing .line / .column. The sibling MdsErrorSpan already declares these fields for the identical shape.

Fix: Add optional fields:

export interface LintSpan {
  offset: number;
  length: number;
  line?: number;      // 1-based line number, if available
  column?: number;    // 1-based column number, if available
}

13. TypeScript: severity Should Be Closed Union, Not string (packages/mds/src/types.ts:78)

Severity: MEDIUM (85% confidence)

severity is typed as string, but Rust only serializes "error" | "warn" | "info" (Off diagnostics are filtered). Unknown severities fail loudly. Typing it as string defeats exhaustive switch checks and allows typos to pass type-check.

Fix:

export type LintSeverity = 'error' | 'warn' | 'info';
export interface LintDiagnostic {
  severity: LintSeverity;  // Closed set
  // ...
}

14. Python: typecheck_sample.py Not Extended to Lint Surface (crates/mds-python/tests/typecheck_sample.py)

Severity: MEDIUM (80% confidence)

The AC-C6 strict type-check tests (mypy --strict, pyright) don't exercise the new lint, lint_file, lint_virtual, or LintResult stubs. Stubtest verifies parity but not usability; a stub type error manifesting only in real usage would ship undetected.

Fix: Add lint usage samples to typecheck_sample.py:

from mdscript import LintResult
def lint_sample(source: str) -> int:
    r: LintResult = mdscript.lint(source, rules={"unused-variable": "off"})
    total = len(r.files[0]["diagnostics"]) if r.files else 0
    return -1 if r.truncated else total

Lower-Confidence Suggestions (60–79%)

Architecture: Backend file-key divergence (WASM "input.mds" vs native "") is intentional per KNOWLEDGE.md but not documented in LintResult TSDoc — add a note that only lintVirtual guarantees backend-stable keys.

Rust:

  • Severity enum should derive Copy (fieldless enum, used with .clone() 20+ times)
  • compute_line_column widened visibility but never called — either revert or backfill line/column in lint spans
  • check_for_var_shadow double-allocates String to search Vec — use iter().any(|v| v == var)
  • apply_plan clones and re-sorts already-sorted edits — iterate reversed instead

Performance:

  • collect_facts documented as "pre-sized" but uses zero-capacity collections — drop "pre-sized" wording or pre-size against AST size
  • 3 local-AST rules re-walk the AST (4 traversals total, not 1) — optional fold into facts walk; soften docs if not done

Complexity: 7× repeated result_exit_code + process::exit idiom invites bugs; add centralized helper.


Clean Reviews ✅

Regression & Dependencies: ✅ Clean (verified no breaking exports changes; no new WASM deps; lock file safe)

Security: No findings (input validation at boundaries; no injection points; secrets clean)

Architecture: Lint module is a clean leaf (no resolver/validator reentry); single serializer invariant holds; no binding internals leakage (per verification checks)

Reliability: Engine is bounded (64-depth recursion, 1000-diagnostic cap, reverify gate fail-closed); no production unwrap in core/CLI


Summary Table

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.

dean0x and others added 3 commits July 11, 2026 11:31
…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>
Comment thread spec.md Outdated
}
```

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread spec.md
"file": "template.mds",
"diagnostics": [
{
"rule": "unused-variable",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread spec.md Outdated
| `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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread crates/mds-python/tests/test_lint.py Outdated
assert "rule" in d
assert "severity" in d
assert "message" in d
assert "help" in d or d.get("help") is None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread crates/mds-cli/src/lint.rs Outdated
fix, check, diff, ..
} = flags;

let source = match read_source_file(file) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. FS-abstraction bypass (DIP). This read uses raw std::fs::read and re-implements the MAX_FILE_SIZE guard inline (:1011-1017), bypassing the NativeFs/FileSystem abstraction and its symlink re-check (entry is symlink-validated on the gate read, but not on this one).
  2. Latent source-of-truth split. Spans are computed against the copy mds::lint read; the CLI's --fix byte edits and named_source caret rendering operate on the separately-read read_source_file copy — 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

Comment thread crates/mds-core/src/lint/diagnostic.rs Outdated
/// }
/// ```
///
/// Per-file grouping: diagnostics without a `file` are grouped under `null` key.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread crates/mds-cli/Cargo.toml Outdated
similar = { workspace = true }
tempfile = { workspace = true }

[dev-dependencies]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@dean0x

dean0x commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

Code Review Summary — Cycle 2 · PR #171 mds lint (#61) · 13 reviewers

Aggregate merge recommendation: ✅ APPROVE WITH CONDITIONS

A strong, almost entirely additive implementation. No CRITICAL or HIGH code defects across any lens. The only true blockers are two factual contradictions in the newly added spec §7.5 plus one dead (tautological) Python test assertion — all three are cheap, high-certainty edits. Everything else is LOW/MEDIUM cleanup or an already-tracked follow-up.

Conditions before merge (all posted as inline comments):

  1. spec.md:971 — truncation limit doc says "default 500"; the constant is a fixed 1000 (97%).
  2. spec.md:956 — JSON example key order contradicts the alphabetical/byte-parity wire format (85%).
  3. crates/mds-python/tests/test_lint.py:207 — tautological help assertion never fails; restores no coverage (92%).

Recommend also landing the two other ≥80% doc/consistency fixes (unused-function footnote reason spec.md:1054; WASM lintVirtual options.modulesmodules error text) and the one HIGH should-fix below in the same pass; the LOW/MEDIUM items can be batched or deferred.

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-endcrates/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 --fix behavior never asserted — fix.rs:8 (85%): declared Tier A/auto-fixable but no test pins apply-vs-fail-closed-refuse.
  • "truncated": true never asserted in serialized output — diagnostic.rs builder_truncates_at_max_diagnostics (82%): cap test checks the in-struct bool, not the wire field; all goldens hardcode truncated:false.
  • shadow-variable Info-severity exit-code neutrality untested at the CLI — shadow_variable.rs (72%); no CLI test drives config-based rule enabling at all.

documentation

  • empty-block coverage list omits @elseif/@elsespec.md:1048, README.md:218, CHANGELOG.md:85 (70%): engine also fires on empty @elseif/@else bodies.

architecture

  • LintOptions exposes basePath but CompileOptions hides it — packages/mds/src/types.ts:125 vs :50 (70%): decide parity vs documented divergence.
  • Dir-mode JSON re-serializes the full canonical envelope per file just to extract filescrates/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/Arc borrow avoids N deep clones.
  • unreachable-branch duplicate-@elseif detection is O(k²) — unreachable_branch.rs:146-203 (65%): deliberate simplicity trade-off (Condition has no Hash); a length guard would mitigate if ever needed.

complexity

  • Duplicated untargeted-diagnostic count loops in apply_fixesfix.rs:340 (75%): extract count_untargeted(...).
  • unused_import::check inline diagnostic duplication (Alias/Selective arms) — unused_import.rs:56 (72%).

typescript

  • LintOptions.rules is Record<string, string> — a LintRuleSeverity = 'error'|'warn'|'info'|'off' union would catch typos at the boundary — types.ts:119,:133 (75%).
  • NapiAddon.lintVirtual modules typed Record<string, unknown> vs Record<string, string> everywhere else — native.ts:28 (68%).
  • Repeated inline lint-option shape literal (~4×) — native.ts:26-51 (62%).

python

  • LintResult unhashability/frozen enforcement lacks a behavioral test — test_contract.py:153 (70%): add m.lint("Hi\n") to test_unhashable.
  • extract_rules parses Severity via from_str(&format!("\"{s}\"")) string interpolation — lib.rs:794 (65%): from_value(Value::String(s)) is cleaner (fail-closed, not a bug).
  • lint keyword-arg order (source, *, base_path, vars, rules) diverges from compile's (source, *, vars, base_path)lib.rs:945 (65%).

consistency

  • LintFileResult type name is ambiguous vs the lintFile() function (which returns LintResult) — types.ts:90 (65%).
  • napi/wasm/python lint docstrings cite different sibling for error shape (compile vs check) — napi :787 / wasm :669 / python :939 (62%).

rust

  • unreachable-branch @elseif diagnostics anchor to the @if offset (AST elseif_branches carries no per-branch offset) — unreachable_branch.rs:135,:164,:193 (68%): imprecise caret; fail-closed.
  • fix::plan_fixes/plan_fixes_with_options/apply_fixes lack #[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 to MAX_NESTING_DEPTH=64 pre-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 the notify/ctrlc/similar pattern — Cargo.toml:22 (62%).

regression

  • Native backend construction now hard-fails if the addon lacks lint/lintFilenative.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_chars does not escape DEL (U+007F) — diagnostic.rs:312-325 (62%): minor defense-in-depth completeness gap; ESC is already caught.

Cross-cycle context


🤖 Generated with Claude Code · code-review orchestration, cycle 2

dean0x and others added 9 commits July 12, 2026 01:15
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: mds lint — static analysis and diagnostics

1 participant