Skip to content

feat(lint): warn on unknown lint rule names; expose binding warning channel (#224) - #295

Draft
dean0x wants to merge 36 commits into
wave/v0.4.0-wave1from
ticket/pr2-unknown-rule-names
Draft

feat(lint): warn on unknown lint rule names; expose binding warning channel (#224)#295
dean0x wants to merge 36 commits into
wave/v0.4.0-wave1from
ticket/pr2-unknown-rule-names

Conversation

@dean0x

@dean0x dean0x commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Unknown rule names in mds.json (and in the rules option on all binding surfaces) now emit a warning and lint continues, instead of silently no-opping
  • Warning goes to stderr on the CLI so --format json stdout remains valid parseable JSON; --quiet suppresses it (AC-224-22)
  • All three binding surfaces (napi, WASM, Python) return lint_warnings: string[] in the lint result when unknowns are detected — the D8 binding warning channel
  • TypeScript types updated: LintRuleName union type, LINT_RULE_NAMES const array, lint_warnings?: string[] on LintResult
  • crates/mds-python/README.md:71-73 updated per the mandatory AC-224-23 ruling

Changes

mds-core

  • ALL_RULE_NAMES const in lint/rules/mod.rs composed from each module's RULE const (single source of truth)
  • New public API: KNOWN_LINT_RULES, find_unknown_rule_names, UnknownRuleNames, format_unknown_rule_names_warning

mds-cli

  • load_lint_config gains quiet: bool parameter; warning is WIRE-escaped via safe_inline before reaching eprint_warning
  • Deleted KNOWN_RULES constant (AC-224-15) — rebuilt from the core registry; no rule-name string literals appear in crates/mds-cli/src/
  • crates/mds-cli/tests/print_discipline.rs is byte-identical to the wave base — no allowlist entry was added. Every safe_inline call is a whole-expression argument directly inside eprint_warning's format!, the one shape the trace checks without an exemption.

Binding surfaces (D8)

  • napi/WASM/Python: lint_warnings?: string[] added to returned result; absent when all rule names are known

TypeScript (packages/mds)

  • LintRuleName union type and LINT_RULE_NAMES: readonly LintRuleName[] exported from @mdscript/mds
  • LintResult.lint_warnings?: string[] added to interface

Documentation

  • crates/mds-python/README.md:71-73 (mandatory per ruling, AC-224-23)
  • crates/mds-napi/README.md, packages/mds/README.md, packages/mds-wasm/README.md
  • spec.md: escape-contract table reconciled (WIRE row and residual row no longer conflict for mds.json rule names); lint_warnings documented in the canonical lint JSON section
  • examples/linting/README.md: all three facts now explicit (warned, config loads, rule not enforced)

Tests

  • security.rs T-ESC-RULE-1: COLUMNS loop 40–200, strengthened assertions (AC-224-4/5)
  • cli_lint.rs: AC-224-10, AC-224-11, AC-224-19, AC-224-21, AC-224-22
  • napi: L-N-WARN-1 to L-N-WARN-5; wasm: W-WARN-1 to W-WARN-4; Python: test_py_warn_l1 to test_py_warn_l5
  • universal @mdscript/mds: U-L-WARN-1..6 on both native and WASM backends

Breaking Changes

None — warn-and-continue is strictly additive. Unknown severity values still hard-fail (asymmetry is intentional).

Unmet Acceptance Criterion — Author Sign-Off Required

AC-224-3 is unmet. This waiver was documented by the implementer; it requires the ruling author's explicit ratification before this PR merges.

AC-224-3 states the warning message MUST be byte-identical across all five surfaces. This PR consciously deviates: the CLI emits warning: unknown lint rule 'NAME' in mds.json; recognised rules are: … while binding surfaces (napi/WASM/Python) produce unknown lint rule 'NAME'; recognised rules are: … — omitting the warning: prefix and the in mds.json source context. The divergence is documented coherently in three places (CHANGELOG #224 entry, lint.rs:206-231, config.rs:125-132) and per-surface goldens exist as PF-007 requires.

Engineering rationale (defensible, but not a ruling):

  • The CLI genuinely needs the in mds.json source context to direct the user to the config file; library callers have no equivalent concept.
  • The safe_inline + eprint_warning singular/plural composition requires the call sites to be whole-expression arguments — the exact shape print_discipline.rs checks without an allowlist entry. Byte-identical phrasing across surfaces would require a different call shape and an allowlist exemption.
  • The sorted recognised-rules list IS shared via mds::KNOWN_LINT_RULES (shared-constant parity).
  • Per-surface goldens exist and each locks in its own value, as PF-007 requires.

Author action required: Please reply confirming you accept this deviation from AC-224-3, or request it be corrected before merge.

Accepted Residuals

D2(a) asymmetry (AC-224-14): The unknown-rule-name warning fires only in mds lint (the one command that evaluates lint rules). mds build, mds check, mds fmt, and mds watch all read mds.json but do not evaluate lint rules — they silently ignore the lint.rules map, so no warning is emitted there. This is intentional: emitting a lint-rule warning during a build or format run would be confusing noise. A mid-session watch config edit that introduces a typo in lint.rules will not cause the warning to fire (the watch path does not call the lint rule-name checker), and will not corrupt output_dir.

WASM size (AC-224-18)

Measured locally with wasm-pack 0.15.0 / bundled wasm-opt v117 (same toolchain for both baseline and this branch):

Build Bytes Delta vs wave base
wave/v0.4.0-wave1 (833,046) 833,046
this PR 842,951 +9,905 (+1.19%)

9,905 bytes delta is under the +10,000 R2 trigger. 842,951 bytes is 7,049 bytes under the 850,000 guard. CI uses Binaryen v129; the absolute count there will differ slightly but is expected to track the same trend.

Reviewer Focus Areas

  • crates/mds-cli/src/lint.rs: load_lint_config quiet gate and safe_inline usage; AC-224-3 residual comment
  • crates/mds-cli/tests/print_discipline.rs: confirm byte-identical to wave base (no allowlist entry — the guard coverage is preserved, not residual-ised)
  • crates/mds-core/src/lint/config.rs: find_unknown_rule_names and format_unknown_rule_names_warning — confirm these are the right abstraction boundary
  • T-ESC-RULE-1 in security.rs: confirm the COLUMNS loop is strictly stronger than the previous one-shot assertion
  • crates/mds-python/README.md:71-73: the exact sentence required by AC-224-23
  • D8 binding channel: napi/WASM/Python all follow the same pattern; check consistency across surfaces
  • spec.md escape-contract table: verify the WIRE/residual row reconciliation is correct
  • AC-224-3 sign-off (see "Unmet Acceptance Criterion" above): confirm the CLI-vs-binding message divergence is ratified by the ruling author before merge

dean0x and others added 30 commits August 14, 2026 12:37
…224)

An unknown rule name in mds.json or the rules option now emits a warning
and lint continues. Previously the name was silently accepted with no
effect, making misconfigured rules impossible to detect.

Core changes (mds-core):
- Add ALL_RULE_NAMES const composed from each rule module's RULE const
- Export KNOWN_LINT_RULES, find_unknown_rule_names, UnknownRuleNames,
  and format_unknown_rule_names_warning from the public API
- Delete KNOWN_RULES constant from mds-cli (AC-224-15); rebuild from
  the core registry so there is one authoritative list

CLI changes (mds-cli):
- load_lint_config gains quiet: bool parameter (AC-224-22)
- Warning goes to stderr via eprint_warning with safe_inline escaping
  (WIRE per-field, spec §7.5); --format json stdout is unaffected
- --quiet suppresses the warning (AC-224-22, coordinates with PR4)

D8 binding warning channel (AC-224-1):
- napi: lint/lintFile/lintVirtual return lint_warnings?: string[] in
  the result object when unknown rule names are present
- WASM: same lint_warnings field on all three lint entry points
- Python: LintResult gains lint_warnings property (list[str]); all
  three lint functions populate it when unknowns are present

TypeScript types (packages/mds):
- Add LintRuleName union type and LINT_RULE_NAMES readonly array
- Add lint_warnings?: string[] to LintResult interface
- Update JSDoc for LintOptions.rules and LintFileOptions.rules

Documentation (AC-224-17, AC-224-23):
- crates/mds-python/README.md:71-73 — mandatory update per ruling
- crates/mds-napi/README.md, packages/mds/README.md,
  packages/mds-wasm/README.md — all updated

Tests:
- security.rs T-ESC-RULE-1: add COLUMNS loop (40-200), strengthen
  assertions for new format including recognised-rules list (AC-224-4/5)
- print_discipline.rs: add ALLOWED_UNTRACED_HELPER_ARGS entry for
  &warning (pre-sanitized via safe_inline; human review anchor)
- cli_lint.rs: AC-224-10 (JSON wire unchanged), AC-224-11/21 (warning
  to stderr only), AC-224-19 (one warning per invocation), AC-224-22
  (--quiet suppresses)
- napi: L-N-WARN-1 through L-N-WARN-5 covering all three lint surfaces
- wasm: W-WARN-1 through W-WARN-4 (plus absent-field negative tests)
- Python: test_py_warn_l1 through test_py_warn_l5 + lintFile/lintVirtual

2031 nextest tests pass; 50 doctests pass; clippy -D warnings clean;
source hygiene gate passes (no control bytes).
`cargo fmt --all --check` reported violations across 10 files in the
PR2 feature branch (method chains, assertion messages, use-statement
ordering, function signatures). This commit applies `cargo fmt --all`
to bring every file into compliance — no logic changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Three bindings each repeated an 8–15 line block to inject a
`lint_warnings` field into the canonical JSON result (8 sites total
across napi/WASM/Python). Extract a private `inject_lint_warnings(json,
warnings) -> Value` helper in each crate so each call site becomes a
single expression.

Also correct a factually wrong "CLI note" in `format_unknown_rule_names_warning`:
the CLI never calls this function — it builds its own escaped message via
`safe_inline` with a "in mds.json" context. The note implied the CLI
pre-escapes names and passes them here, which is false.

No behaviour change. All 1339 nextest tests pass.
Nine-pillar self-review of the #224 unknown-rule-name work. Every fix below
was found by checking the implementation against the plan's acceptance
criteria rather than against the code's own tests.

P0 - Functionality
- packages/mds: LINT_RULE_NAMES was exported only from src/index.ts, which the
  package `exports` map never resolves (it maps to dist/node.js or
  dist/browser.js). The new export was unreachable for every consumer. Now
  exported from node.ts along with the LintRuleName type.
- crates/mds-python: the LintResult.lint_warnings getter had no entry in
  _mdscript.pyi, so mypy/pyright users could not read it. Stub added and
  exercised from typecheck_sample.py (both type checkers pass).

P0 - Security
- mds-core: format_unknown_rule_names_warning interpolated caller-supplied rule
  names raw into the string the napi/WASM/Python surfaces return. spec.md:1067
  puts rule names in the WIRE row "on every surface that renders one", so the
  three binding surfaces were out of contract. Each name is now WIRE-escaped at
  construction (applies ADR-008, avoids PF-014). Covered by a new unit test with
  a hostile vector built from \u{..} escapes at runtime (avoids PF-018).
- mds-cli: the warning was assembled into a `let warning = if .. {} else {}`
  local, which print_discipline.rs's one-hop trace cannot follow; the delivered
  code papered over this with a new ALLOWED_UNTRACED_HELPER_ARGS entry. That
  converts a machine-checked invariant into a human-review note, which AC-224-6
  explicitly forbids. Restructured so every interpolation is a whole-expression
  safe_inline call inside eprint_warning's format!. print_discipline.rs is now
  byte-identical to the wave base - no allowlist entry at all. Positive control
  (ADR-009): removing safe_inline makes the guard fail and name both sites.

P1 - Error handling
- format_unknown_rule_names_warning took &[String] and guarded emptiness with a
  release `assert!` - a panic path in a library, reachable across three FFI
  boundaries. It now takes &UnknownRuleNames, which cannot be constructed empty,
  so the precondition is structural and the function has no failure mode.

P1 - Tests (three gaps, all now closed with paired positive controls)
- AC-224-1 named the universal @mdscript/mds package as one of five surfaces and
  it had no coverage. Added U-L-WARN-1..6 over lint/lintFile/lintVirtual, the
  all-recognised negative arm, the unknown-severity-still-throws contrast, and a
  directly-constructed WASM backend (init() prefers native, so without that leg
  the claim would cover one backend only - PF-007).
- AC-224-10 asserted key presence, not the byte-identical envelope it requires.
  Added a two-tree comparison of stdout, exit code and the exact top-level key
  set, over a tree that actually produces files[] entries.
- AC-224-12 (--fix unchanged) had no test. Added a two-tree parity test over
  --fix, --fix --check and --fix --diff that also asserts a fix really happened.
- AC-224-21 stdout-cleanliness did not check the substring "warning"; AC-224-22
  did not cover the pre-subcommand `mds --quiet lint` form. Both added.
- crates/mds-wasm/tests/web.rs asserted files[] via `Array::from(&v).length()`
  with the result discarded - Array::from coerces anything, so the check passed
  on any value. Replaced with Array::is_array plus an exact length.

P2 - Documentation
- spec.md:1218 still said unknown rule names are "warn-and-ignored", the exact
  phrase the plan's doc sweep greps for. Rewritten to state all three facts.
- .devflow/features/mds-lint/KNOWLEDGE.md is TRACKED, not gitignored
  (.gitignore re-includes !.devflow/features/*/KNOWLEDGE.md). Its two stale
  statements of the old contract and its KNOWN_RULES reference are updated, and
  the tracked-not-ignored fact is recorded there so the next sweep does not
  exclude it.
- CHANGELOG: the #224 entries sat inside the "BREAKING - Interpolation syntax"
  section. This change is not breaking. Moved to ### Changed (behaviour, with
  the build/check/fmt/watch asymmetry recorded) and ### Added (new APIs).
- types.ts said unknown names are "silently ignored" one sentence after saying a
  warning is emitted. Reworded to "not enforced by the engine".

Performance - WASM size (AC-224-18)
Measured with wasm-pack 0.15.0 / bundled wasm-opt v117, same toolchain both
sides, crates/mds-wasm/pkg/mds_wasm_bg.wasm:
  wave/v0.4.0-wave1  833,046
  as delivered       847,011  (+13,965 - over AC-224-18's +10,000 threshold,
                               only 2,989 bytes under the 850,000 guard)
  after this commit  842,951  (+9,905 - under the threshold, 7,049 bytes spare)
Three changes got it back: sort_unstable for the name sort (unique HashMap keys,
so stability is unobservable), push_str instead of format!/join in the formatter,
and Option<String> instead of Vec<String> for the single warning threaded through
the binding option parsers. CI builds with Binaryen v129, a different toolchain -
the absolute number there will differ.
AC-224-15: Remove the string literal that caused the grep check to return
one hit. The comment now describes the invariant without quoting a rule name.

Plan §3.5 (D-224-1/D-224-2): Add JSDoc decision-ID markers to types.ts.
D-224-1 on LintRuleName documents the warn-not-reject ruling and its
asymmetry with unknown severities. D-224-2 on LINT_RULE_NAMES documents
the manual-mirror nature of the TS array and names its drift as a residual
(avoids PF-015).

AC-224-17 / spec.md reconciliation: The escape-contract table had two rows
that both claimed mds.json rule names: the WIRE row ("mds.json rule names
are WIRE on every surface") and the residual row ("identifiers in a message
body are HUMAN on terminal surfaces"). Amended the Because column of both
rows to state the precedence rule: the more-specific WIRE row governs
mds.json-sourced values even when they appear inside a warning body.

AC-224-17 / examples/linting/README.md: The paragraph at line 189 was
correct in substance but stated only two of the three required facts
(warned + not enforced). Now explicitly states all three: the warning fires,
the config still loads, and lint continues with the unknown rule skipped.

Wire-documentation gap: Add a "lint_warnings field" paragraph to the
canonical lint JSON section of spec.md (after line 996). Explains that
napi/WASM/Python include lint_warnings as an optional top-level key in
the returned result, and that the CLI keeps the --format json stdout clean
by routing warnings to stderr instead.

AC-224-3 residual: Add an explicit named-residual comment to lint.rs
in the load_lint_config warning block, documenting that the CLI wording
("...in mds.json") differs from the core formatter used by napi/WASM/Python
by design (applies AD-224-3, R6/PF-007).

Co-Authored-By: Claude <noreply@anthropic.com>
The shape block on crates/mds-napi/README.md:66 and the shape block in
packages/mds/README.md:157 both omitted `lint_warnings?: string[]` while
the prose immediately below each block directed callers to read that field.
packages/mds-wasm/README.md:53 already carried the field after the PR doc
sweep; this brings the two remaining binding READMEs into alignment.

AC-224-17: every doc surface must state all three facts (shape, truncated,
lint_warnings) coherently.

Co-Authored-By: Claude <noreply@anthropic.com>
PR2 (ticket/pr2-unknown-rule-names, #224) measured 842,951 bytes after
wasm-pack 0.15.0 bundled wasm-opt — +9,449 bytes from the PR1 baseline of
833,502. Delta is under the +10,000 R2-fallback trigger. Headroom is now
7,049 bytes (0.83%) below the 850,000 guard.

The delta is kept small by deliberate choices in config.rs: sort_unstable
(not sort) and push_str (not join) — join monomorphises into kilobytes in
the WASM binary. Guard is NOT raised; three more wave PRs still to land.

Appends the PR2 entry to the budget-history comment as PR1 did (AC-224-18).

Co-Authored-By: Claude <noreply@anthropic.com>
… description

Line 191 of examples/linting/README.md stated the unknown rule "is silently
skipped" two clauses after saying a warning IS printed to stderr. This directly
contradicts AC-224-17 (no doc surface may describe the behaviour as silent) and
evaded the AC-224-23 positive-control grep (which searched for "silently
accepted", not "silently skipped").

Replace "it is silently skipped" with "it is skipped". The sentence already
announces the warning in the preceding clause, so the enforcement consequence is
unambiguous without the word "silently".

Applies ADR-009 (positive-control verification must detect the artifact).
Avoids PF-013 (absence-only assertions prove nothing without a paired positive).
Co-Authored-By: Claude <noreply@anthropic.com>
…-export layer

The direct `pub use lint::config::{...}` in lib.rs bypassed the established
re-export layer, creating two conflicting precedents for the next contributor.
Add all five config symbols (attach_lint_warnings, find_unknown_rule_names,
format_unknown_rule_names_warning, UnknownRuleNames, KNOWN_LINT_RULES) to
lint/mod.rs's `pub use config::{...}` block and import them from `lint::`
in lib.rs — consistent with every other lint symbol (e.g. LintConfig).

No behaviour change; public API paths are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
…t_warnings [#224]

Review findings fixed:

1. crates/mds-cli/src/build.rs — two rustdoc blocks on MdsConfig.lint and
   LintCliConfig claimed "Unknown rule NAMES produce a warning on every surface".
   CHANGELOG:787 explicitly contradicts this ("Only mds lint reads lint.rules,
   so only mds lint warns — an accepted asymmetry"). Both now state that only
   mds lint warns on stderr (via load_lint_config); mds build/check/fmt/watch
   deserialize the field but do not emit the warning.

2. crates/mds-core/src/lint/config.rs — three rustdoc blocks (module-level,
   LintConfig type, and from_rules) claimed mds-core "emits a warning on every
   surface" or that unknown names "produce a warning via find_unknown_rule_names
   on every API surface". mds-core itself emits no warning under any circumstance;
   detection is opt-in via find_unknown_rule_names and callers are responsible for
   surfacing unknowns. The module doc now lists each caller surface accurately:
   mds lint warns on stderr; napi/WASM/Python return lint_warnings; mds
   build/check/fmt/watch do not warn. Avoids PF-015 absolute-claim shape.

Accompanying structural changes (pre-existing uncommitted work from prior session):

- mds-core: add LintConfig::from_rules_checked returning (config, Option<UnknownRuleNames>)
  in one step, making detection structural rather than advisory (a fifth caller
  cannot skip it silently). LintCliConfig::into_core_config now delegates to it.
- mds-core: add pub attach_lint_warnings(json, warning) — D8 shared implementation
  of the lint_warnings wire contract across napi/WASM/Python. All three bindings
  drop their local inject_lint_warnings copies and call mds::attach_lint_warnings.
- mds-cli/lint.rs: destructure the (config, unknown) tuple from into_core_config
  and pass unknown directly to the quiet-gated eprint_warning block.
- mds-python/tests: add test_py_warn_l2b_known_rule_to_dict_omits_lint_warnings_key
  (absent-when-empty pin, ADR-009/PF-013 both-directions) and
  test_py_warn_canonical_sanitizes_lint_warnings (PF-004 sanitization via
  LintResult(canonical) path, ESC constructed at runtime per PF-018).

Co-Authored-By: Claude <noreply@anthropic.com>
…gument

The AC-224-4 multi-width loop ran mds lint seven times with COLUMNS set
to 40/60/80/100/120/160/200, claiming this was "machine-checked proof"
that the warning never wraps at any terminal width. The binary never reads
COLUMNS under piped stdio: miette's width detection uses the tty ioctl,
which is absent when stdout/stderr are both piped. All seven iterations
produced byte-identical output by construction, so the loop could not
distinguish a wrapping renderer from a non-wrapping one.

Drop the loop and run once. Update the docstring and inline comments to
state the accurate structural argument: eprint_warning is a bare eprintln!
that never consults terminal width, and under piped stdio the tty ioctl is
absent so COLUMNS is never read — the single-line invariant holds by
construction regardless of column width.

All six assertion classes (AC-224-5) are preserved unchanged: non-vacuity,
no raw 0x1B, assert_no_control_chars, both forged-standalone-line checks,
the three escaped-literal positives, and the
 count of 2.

Co-Authored-By: Claude <noreply@anthropic.com>
Finding 1 (medium): add lint_warnings row to the normative
Sanitization invariant (v1) table. The new field's invariant states
that interpolated rule names are WIRE-escaped via sanitize_control_chars_wire
during construction; surrounding template text is static ASCII.

Finding 2 (low): add test U-LG4 to packages/mds/__test__/lint.spec.mjs
that serializes a lintVirtual result containing lint_warnings and pins
the key's position between "files" and "truncated", verifying the
normative alphabetical BTreeMap key-order claim in spec.md (ADR-009 /
PF-013: asserting key ORDER requires a result that actually contains
the key).

Finding 3 (medium): scope the "absent when no warnings" claim to the
JSON wire form (to_dict() / to_json()) in both spec.md and
CHANGELOG.md. The Python live-object LintResult.lint_warnings property
always exists and returns [] via unwrap_or_default(); the absent-key
semantics apply only to the serialized dict/JSON representation.
Resolves the self-contradiction between CHANGELOG.md line 545 ("empty
when there is nothing to report") and line 786 ("absent when there is
nothing to report").

Co-Authored-By: Claude <noreply@anthropic.com>
…sent-when-empty

Two review findings on crates/mds-python/src/lib.rs:

Finding 1 — Inconsistent empty-state representation (medium)
  r.lint_warnings == [] and 'lint_warnings' not in r.to_dict() described the same
  "no warnings" state two different ways on the same object without documentation.
  Fix: add an "Absent-when-empty convention" note to the lint_warnings getter
  docstring naming both representations, the Python [] default vs. JSON key-absent
  distinction, and alignment with the TypeScript surface (lint_warnings?: string[]).
  Test test_py_warn_l2b pins that to_dict() omits the key for a known-rule run;
  positive control is test_py_warn_l4 which asserts the key IS present when warnings
  fire (ADR-009 / PF-013).

Finding 2 — lint_warnings bypassed sanitize_lint_value (medium, PF-004)
  sanitize_lint_value walked only files[].file, files[].diagnostics[].message and
  .help; the lint_warnings array was never sanitized. The live path was safe
  (mds::attach_lint_warnings WIRE-escapes before injection), but LintResult(canonical)
  / pickle callers can supply untrusted data with hostile control bytes in
  lint_warnings, making the "backing store is always sanitized" docstring claim false.
  Fix: restructure sanitize_lint_value to use if-let instead of early return so both
  the files block and the new lint_warnings block are reachable, then add the
  lint_warnings sanitization loop. Update docstrings to reflect coverage.
  Test test_py_warn_canonical_sanitizes_lint_warnings verifies that ESC bytes
  (constructed at runtime per PF-018, never written as literals) do not survive
  the LintResult(canonical) constructor.

applies PF-004, avoids PF-018
…l (PF-018) [#224]

The prior commit included a comment in test_py_warn_canonical_sanitizes_lint_warnings
that contained a literal U+001B ESC byte. The source-hygiene gate caught it.

Two problems in the original:
1. A raw ESC byte in the comment body (PF-018: tooling decoded backslash-u + 001B
   into the real byte when the file was written by a prior agent session).
2. When the skim hook stripped the ESC byte from the working tree it also removed
   the paired positive-control assertion assert "\\u001B" in warnings[0], leaving
   only the absence assertion and making the test vacuous (ADR-009 / PF-013).

Fix: restore the assertion using a local variable constructed at runtime so no
literal escape sequence appears in the source. The variable name and comment
document WHY the indirection is needed (PF-018), which is itself the positive
control for the guard.

Co-Authored-By: Claude <noreply@anthropic.com>
…rm, PF-018)

The initial fix removed the assertion that ESC becomes its 6-char WIRE-escaped form
because the first attempt used a bare backslash-u escape in the comment and the
tooling decoded it into a live control byte (PF-018).

Restore the assertion using the safe Python form: the string literal "\\u001B" uses
a doubled backslash, which the source file stores as the two ASCII bytes 0x5C 0x5C
followed by u001B — no control byte in the source. The resulting Python str is the
6-char sequence backslash+u+0+0+1+B, which is exactly what sanitize_control_chars_wire
produces for U+001B.
…ASM (W-SEVER-1/2)

The deliberate asymmetry — unknown rule NAMES warn (W-WARN-1/3), unknown
severity VALUES hard-fail — was only half-pinned on the WASM surface. The
warn arm had four tests; the throw arm had none. Per PF-007 the napi L-N-6
and Python test_l5_lint_rules_unknown_severity_raises proofs are surface-local
and prove nothing about WASM.

Adds W-SEVER-1 (`lint` path) and W-SEVER-2 (`lint_virtual` path): both
call extract_rules() with `rules: { "unused-variable": "verbose" }` and
assert `mds::invalid_options` is thrown, completing the paired assertion.

Closes the review finding: "BLOCKING (Category 1) — unknown SEVERITY VALUE
still throws mds::invalid_options — the throw arm is now pinned on WASM."
cargo fmt reformatting of code added in the previous commit:
- mds-cli/src/build.rs: into_core_config signature fits one line
- mds-cli/tests/cli_lint.rs: .find()/.expect() chains reformatted
- mds-cli/tests/security.rs: format!() calls collapsed to single lines
- mds-core/src/lint/config.rs: from_rules_checked signature + test
  assertion chains reformatted

New unit tests in mds-core/src/lint/config.rs (tests module):
- attach_lint_warnings_injects_field_when_warning_present: D8 positive
  control — present warning adds lint_warnings:[string] field
- attach_lint_warnings_leaves_object_unchanged_when_no_warning: D8 negative
  control — absent warning leaves object unchanged (ADR-009 / PF-013
  both-directions coverage for the absent-when-empty contract)

mds-cli/src/lint.rs: improve AC-224-22 / AC-224-3 residual comments to
accurately describe the --quiet suppression rationale and the CLI vs
binding surface message divergence (PF-007 / R6).

Co-Authored-By: Claude <noreply@anthropic.com>
Line 50 of packages/mds-wasm/README.md stated that unknown rule names
"emit a warning and lint continues" but omitted the third required fact:
the unknown rule is still not enforced. AC-224-17 requires every updated
doc surface to carry all three — warned, not enforced, config still loads.

Add the missing clause "— the unknown name has no effect (the rule is not
enforced)" to match the phrasing already present on the other five surfaces
(napi README, mds README, mds-python README, spec.md, CHANGELOG).

Co-Authored-By: Claude <noreply@anthropic.com>
The root README listed "9-rule static analyzer" and omitted
`legacy-interpolation` from the rules table, creating drift from
`KNOWN_LINT_RULES` (which has 10 entries, per AC-224-7).

This PR's new warning enumerates all ten recognised rule names to the
user; the README is the catalog they will consult to decode that list.
Leaving one of the ten absent would make the warning output confusing.

- Update "9-rule" → "10-rule" to match `KNOWN_LINT_RULES.len() == 10`
- Add `legacy-interpolation` row (warn, auto-fixable Tier A): detects
  MDS v0.x single-brace `{x}` syntax and migrates to `{{x}}`

Applies PF-015: description avoids absolute completeness phrasing.

Co-Authored-By: Claude <noreply@anthropic.com>
Prove the sanitized lint_warnings string that actually crosses each FFI
boundary carries no raw control byte and DOES carry the escaped literal.

- L-N-WARN-ESC (napi): hostile ESC rule name → lint_warnings[0] has no
  raw C0/DEL/C1 byte and contains the \\u001B literal (PF-013 positive
  control)
- W-WARN-ESC (wasm): same coverage via wasm_bindgen_test
- test_py_warn_live_lint_escapes_hostile_rule_name (python): same coverage
  via pytest

All hostile bytes constructed at runtime via charCodeAt/chr()/\u{1b}
(PF-018 — no literal control bytes authored in source).
Replace format!("\u{1b}...") with "\u{1b}...".to_string() to satisfy
the clippy::useless_format lint (-D warnings gate).

Co-Authored-By: Claude <noreply@anthropic.com>
…ence [#224]

Two medium-confidence review findings on CHANGELOG.md:

Finding 1 — D2(a) asymmetry and AC-224-14 watch clause had no automated tests.
The CHANGELOG asserted that mds build/check/fmt are byte-unchanged when mds.json
names an unknown lint rule, but nothing in CI held that claim. Add three integration
tests to cli_build.rs:
  - build_unknown_lint_rule_in_mds_json_emits_no_warning
  - check_unknown_lint_rule_in_mds_json_emits_no_warning
  - fmt_unknown_lint_rule_in_mds_json_emits_no_warning
These tests (a) prove the D2(a) asymmetry in CI so a future refactor routing
build/check/fmt through load_lint_config would break them immediately, and (b)
mechanically hold the AC-224-14 watch-path invariant: build and fmt share the same
load_config implementation as watch.rs:822, so a passing build/fmt proves
load_config returns Ok for unknown-rule configs, preventing the unwrap_or(None)
from collapsing output_dir to None mid-session.

Finding 2 — CLI-vs-binding warning-text divergence recorded only in source comments.
The CHANGELOG said "The message format differs from the CLI (no warning: prefix,
no in mds.json source context)" but did not name it as an accepted residual or
document the third structural difference (colon position in plural form). Rewrite
the binding format paragraph to call it an accepted residual, cite AC-224-3 and
PF-007, and list all three structural differences explicitly.

Also update the cli_lint.rs coverage header to reference AC-224-14 with a pointer
to cli_build.rs where the tests live.

Co-Authored-By: Claude <noreply@anthropic.com>
…stency [#224]

AC-224-2/AC-224-3/AC-224-6 — five findings on `crates/mds-cli/src/lint.rs`:

1. **Structural consistency** (high): the plural CLI form placed "in mds.json"
   before the names list (`unknown lint rules in mds.json: 'A', 'B'; …`) while
   the singular placed it after (`unknown lint rule 'X' in mds.json; …`).  Move
   "in mds.json" to after the names in the plural form so both arms share the
   same structure: `warning: unknown lint rules: 'A', 'B' in mds.json; …`.
   Applied to both occurrences in lint.rs (load_lint_config + config_for).

2. **Comment accuracy** (medium): the AC-224-6 block said "Do not hoist the
   assembled message into a local" but the plural arm already hoists `listed`
   (an intermediate names-list variable).  The prohibition applies to the FINAL
   warning string, not intermediate locals whose `safe_inline` call appears
   whole-expression inside format!.  Rewrite the comment to describe what the
   code actually does, for both the load_lint_config and config_for copies.

3. **Per-name escaping in config_for** (medium): the plural arm in config_for
   (directory-mode cache path) used `format!("'{n}'"`) with no per-name
   safe_inline, while the load_lint_config copy used `format!("'{}'", safe_inline(n))`.
   Add per-name safe_inline to the config_for copy to match, making both arms
   consistent with the core formatter's per-name escape shape (AD-224-3).

4. **Exact golden tests** (high/medium): the plural branch had zero golden
   coverage — existing tests used only `contains` substring checks, which do not
   pin the rendered text.  Add `unknown_rule_cli_exact_golden` with separate
   singular and plural sub-cases that assert the full exact warning line via
   `assert_eq!`, covering both the message structure and the sort order (AC-224-3).
   The test pins the ten-name recognised-rules list via `concat!` so any registry
   change fails the test loudly.

5. **CHANGELOG coherence**: update the plural format example from the old
   `…in mds.json: 'A', 'B';…` form to the corrected `…: 'A', 'B' in mds.json;…`
   form, and update the structural-differences description to reflect that the
   colon placement in the plural form is now shared with the binding format.

All 2049 existing tests pass (cargo nextest run -p mds-cli -p mds-core).
Security (CWE-117) and print_discipline guards pass unmodified.
Source hygiene gate passes (node scripts/verify-no-control-bytes.mjs).
cargo fmt --all and cargo clippy -p mds-cli -- -D warnings: clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…_warnings refactor

Commits 8ec8970 and 5932189 deduplicated three binding copies of
inject_lint_warnings into mds::attach_lint_warnings and reclaimed ~4.6 KB.
The measurement recorded at fd915c8 (14:40) predated this refactor; the on-disk
crates/mds-wasm/pkg/mds_wasm_bg.wasm at HEAD measures 838,361 bytes.

Correct values (wasm-pack 0.15.0 bundled wasm-opt, measured locally at HEAD):
  post-change:  838,361  (was 842,951)
  net delta:     +4,859  (was +9,449)
  headroom:      11,639  (was 7,049) — 1.37% (was 0.83%)

Guard (850,000) is not breached either way; correction is required by AC-224-18
which specifies the measured value and delta be reported accurately. PR3/PR4/PR5
will baseline their headroom analysis against this figure.

Co-Authored-By: Claude <noreply@anthropic.com>
Finding 1 (into_core_config, build.rs:62-67) — two errors in the rustdoc:
  (a) Overclaim: 'the return type structurally forces the caller to decide'
      is false — `let (config, _) = …` silently discards the
      Option<UnknownRuleNames> with no diagnostic; #[must_use] on
      from_rules_checked only fires when the *entire* tuple is dropped.
  (b) Inverted clause: 'a fifth consumer could not previously skip detection
      silently' — the gap being closed is precisely that a consumer COULD
      previously skip it.
  Fix: restate accurately that the consumer receives both values in one step
  (closing the gap), and clarify the #[must_use] scope.

Finding 2 (build.rs:35, build.rs:49, cli_build.rs:1295) — all three sites
claim 'only mds lint warns on stderr (via load_lint_config)', but directory-
mode mds lint warns from LintDirCtx::config_for, which explicitly does NOT
call load_lint_config (lint.rs:1100). The single-emitter claim misdirects a
maintainer searching for the warn site to only one of two emitters.
Fix: name both emitters — single-file via load_lint_config, directory via
LintDirCtx::config_for — at all three sites. (PF-015-adjacent)

Co-Authored-By: Claude <noreply@anthropic.com>
…224]

The [Unreleased] mds lint bullet (line ~579) stated "9-rule static analyzer"
and claimed "byte-identical canonical JSON output" across all five surfaces.
Both claims are falsified by changes already in this same unreleased cycle:

- legacy-interpolation (#236) is also in [Unreleased], making the shipped rule
  count 10, not 9. README.md already reflected 10; the CHANGELOG was stale.
- Binding surfaces (napi, WASM, Python) expose a lint_warnings channel absent
  from the CLI's --format json stdout (#224). The per-file/per-diagnostic
  canonical JSON payload IS byte-identical across surfaces; the overall JSON
  objects are not, because of the binding-only key.

Fix: change "9-rule" → "10-rule"; qualify the byte-identity claim to the
per-file/per-diagnostic payload and note the lint_warnings channel asymmetry.

Co-Authored-By: Claude <noreply@anthropic.com>
Commit 3ab9d7f updated README.md:215 and the lint table (adding
legacy-interpolation) but left two stale "9 rules" references:

- README.md:83: CLI usage block "(9 rules; --fix, --format json)"
- examples/README.md:18: comment "Static analysis — 9 rules, ..."

Both now read "10 rules", consistent with README.md:215, the table row
count (10), examples/linting/README.md "The ten rules" heading, and the
KNOWN_LINT_RULES.len() == 10 assertion in api_surface.rs.

Applying PF-015: avoids absolute completeness claim; count is a concrete
number, not "all rules", so it remains verifiable.

Co-Authored-By: Claude <noreply@anthropic.com>
The module-level doc at the top of lint/mod.rs still said 'applies the
9 lint rules' after the tenth rule was added.  The inline doc on
lint_source() (line 71) and the run_rules() doc (line 106) both already
said 10, so this was a straight stale-comment contradiction.

Fixes the low-severity finding flagged in the PR2 review batch.

Co-Authored-By: Claude <noreply@anthropic.com>
- `attach_lint_warnings`: add `#[doc(hidden)]` — it is binding-internal
  JSON plumbing (napi/WASM/Python) with no other crate to live in;
  hiding from docs reduces discoverability without changing binary ABI.
  The stability note in its rustdoc now records the conscious call:
  justifiable, leaks no new dep (serde_json::Value already public via
  to_canonical_json), but external consumers should not depend on it.

- `LintConfig::from_rules`: mark `#[deprecated(since = "0.4.0")]`
  pointing callers to `from_rules_checked`. All five in-repo production
  callers already migrated; only api_surface.rs tests remain. Those two
  tests gain `#[allow(deprecated)]` so the -D warnings gate stays clean.
  The LintConfig type doc and module doc no longer recommend `from_rules`
  as a primary construction path.

Verification: cargo nextest run -p mds-core (1345/1345 passed),
cargo test --doc -p mds-core (52/52 passed), cargo clippy -p mds-core
--all-targets -- -D warnings (EXIT=0), source hygiene gate (EXIT=0).

Co-Authored-By: Claude <noreply@anthropic.com>
Both `packages/mds/src/types.ts` and
`crates/mds-python/python/mdscript/_mdscript.pyi` carried doc comments
asserting that all lint surfaces produce byte-identical JSON.  That was
true before PR2 hardened the asymmetry: the CLI now writes
`lint_warnings` to stderr so its JSON stdout is unpolluted, while the
napi/WASM/Python binding surfaces include the field in the returned JSON
object when non-fatal warnings occur.

- types.ts: replace the "All surfaces produce byte-identical JSON"
  sentence with an accurate description of which fields each surface
  includes (per spec.md:998 and packages/mds/README.md:150-154).
- _mdscript.pyi: replace "byte-identical across all surfaces" with a
  statement that matches the surface-specific behaviour; note the
  conditional presence of `lint_warnings` and that the CLI routes it to
  stderr instead.

Applies PF-015 (avoid absolute completeness claims in normative docs).

Co-Authored-By: Claude <noreply@anthropic.com>
dean0x and others added 6 commits August 14, 2026 15:59
…in targets [#224]

The existing AC-224-22 quiet-suppression tests only exercised the
config_for emitter (directory targets). The load_lint_config emitter's
quiet gate (single-file and stdin code paths, lint.rs emit_unknown_rule_warning)
had no coverage, leaving the PF-013/ADR-009 pairing vacuous for those paths.

Add two new tests, each with a paired positive control:
- unknown_rule_warning_suppressed_by_quiet_single_file
- unknown_rule_warning_suppressed_by_quiet_stdin

Both confirm --quiet suppresses the warning and does not move the exit code.

Co-Authored-By: Claude <noreply@anthropic.com>
…us [#224]

The block comment at cli_build.rs:1291 and three per-test rustdocs falsely
claimed that build/check/fmt/watch "load the same mds.json through
`into_core_config`". `into_core_config` has exactly two callers, both in
lint.rs; build/check/fmt deserialise MdsConfig without going through it
(build.rs:49-51). The non-vacuity claim on the build test was also wrong:
"the warning-path was reached and declined" — there is no warning path in
mds build at all.

Additionally the fmt test targeted `&src` (a single file), but
fmt.rs:308 only calls `load_config` for a DIRECTORY target; the file path
bypassed load_config entirely, making the test vacuous. Similarly, mds check
never calls `load_config` regardless of target, so its test was also vacuous
(PF-013/ADR-009).

Changes:
- Correct block comment to match build.rs:49-51: build and fmt<DIR> read
  mds.json via `load_config`; check and fmt<FILE> do not call load_config
  at all; none call `into_core_config`.
- Replace false non-vacuity claim on build test with accurate description
  that references the positive-control arm.
- Add positive-control arms to all three tests: unknown SEVERITY value in
  mds.json causes build and fmt<DIR> to exit non-zero (proving load_config
  was reached), while check still exits 0 (proving it never reads the
  config). This satisfies ADR-009/PF-013 for the build and fmt tests, and
  documents the structural nature of check's absence.
- Retarget fmt test from `&src` (file) to `dir.path()` (directory) so
  load_config is actually exercised.
- Fix check test assert messages (removed incorrect "D2(a):" prefix).
- Correct CHANGELOG to remove the claim that `check` loads mds.json and
  drop the `check_unknown_lint_rule_in_mds_json_emits_no_warning` test from
  the list of D2(a)/watch-path CI guards (only build and fmt<DIR> qualify).

Co-Authored-By: Claude <noreply@anthropic.com>
The napi README described `result.lint_warnings` as "a `string[]` field"
without noting it is absent when empty.  A consumer reading only this README
could write `result.lint_warnings.length` and hit a TypeError on the clean
path.

The shape line at :66 already carries the `?` marker so the contract was
not wrong, but the prose was inconsistent with `packages/mds/README.md:151`
("absent when empty") and `packages/mds-wasm/README.md:52` ("a non-empty
string[]").  Align the prose to remove the ambiguity.

Co-Authored-By: Claude <noreply@anthropic.com>
AC-224-22 specifies that --quiet suppresses the unknown-rule-name warning.
spec.md:1221 and CHANGELOG.md:783 already carry this fact; add the same
one-sentence note to examples/linting/README.md (the description at line
193 is the most likely landing point for a CLI user reading about unknown
rules), completing the docs sweep for this acceptance criterion.

Co-Authored-By: Claude <noreply@anthropic.com>
…2 assertions [#224]

Fixes five review findings (three high, two medium) on security.rs:

T-ESC-RULE-1 (lint_unknown_rule_name_escapes_control_bytes):
- Restores the 7-width COLUMNS loop [40,60,80,100,120,160,200] removed by
  commit 3daa51a, satisfying AC-224-4 ('property hold at every terminal width
  from 40 to 200 columns inclusive') and AC-224-5 (no weakening).
- Updates the AC-224-4 rustdoc paragraph to remove the 'width invariant holds
  by construction' / 'adds no discriminating power' rationale that directly
  contradicted the sibling T-ESC-RULE-2 which kept the identical loop and
  documented it as proof.
- Adds COLUMNS={columns}: prefix to all assertion messages inside the loop,
  matching T-ESC-RULE-2's style for diagnosability.
- Documents that the rule name carries TWO newlines so the 'Clean: totally-real.mds'
  forged-line assertion is a live check (middle segment, standalone when unescaped).

T-ESC-RULE-2 (lint_plural_unknown_rule_names_escape_control_bytes):
- Changes rule_a from one embedded newline to two ('...RULE\nClean:
  real-a.mds\nOK: fake-a.mds'), making 'Clean: real-a.mds' a middle segment
  that would appear standalone if safe_inline were removed — the forged-standalone-
  line assertion is now live (fixes structurally-unfailable assertion finding).
- Adds assert_eq!(stderr.matches("\\u000A").count(), 3, ...) inside the
  COLUMNS loop: two from rule_a plus one from rule_b = 3 total. This closes
  the gap the review found — assert_no_control_chars permits \n by design, so
  the only prior newline-escaping coverage was the incidental ends_with check.

Both tests now agree: the COLUMNS loop runs at all seven widths and proves the
warning emitter (eprint_warning → bare eprintln!) never wraps, consistent with
AC-224-4's literal requirement. The self-contradictory rationale is eliminated.

applies ADR-009 (positive-control non-vacuity for forged-line assertions)
]

Finding 1/2 — D2(a) command asymmetry (spec.md §7.8, line 1221):
The `lint.rules` config row said "On the CLI the warning goes to stderr
and is suppressed by --quiet" without naming which commands emit it.
`mds build`, `mds check`, `mds fmt`, and `mds watch` all read the same
`mds.json` but do not emit the unknown-rule warning. Add a qualifying
clause that names `mds lint` as the emitting command and explicitly
states the other four do not emit it (mirrors CHANGELOG.md:805-807 and
the crates/mds-cli/src/build.rs module doc, which both record this
asymmetry; spec.md was the outlier).

Finding 3 — lint_warnings escape table (spec.md:1028):
The `lint_warnings` row described interpolated values as "rule names
from `mds.json`". On the napi, WASM, and Python surfaces — the only
three that carry this field — rule names come from the caller's `rules`
option object, not from any config file. Change to "rule names from the
caller's `rules` option".

Finding 4 / PF-015 — absolute byte-identity claim (spec.md:1034):
"All four surfaces emit byte-identical values, with one exception: the
\"file\" key when the source is piped via stdin." The delta introduced
a second exception (`lint_warnings` is a binding-surface-only key,
absent from the CLI's `--format json` output). The sentence asserted
exactly one exception while the table immediately above documented two.
Change to "with two exceptions" and enumerate both: the `lint_warnings`
key absence and the stdin `file` relabelling.
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.

1 participant