From 356b69286ed378340edf6008fac36ba8bc78db07 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 12:37:54 +0200 Subject: [PATCH 01/42] feat(lint): warn on unknown lint rule names, expose binding channel (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 43 ++++ crates/mds-cli/src/build.rs | 5 +- crates/mds-cli/src/lint.rs | 88 ++++---- crates/mds-cli/tests/cli_lint.rs | 196 ++++++++++++++++++ crates/mds-cli/tests/print_discipline.rs | 16 ++ crates/mds-cli/tests/security.rs | 144 +++++++------ crates/mds-core/src/lib.rs | 4 + crates/mds-core/src/lint/config.rs | 136 +++++++++++- crates/mds-core/src/lint/diagnostic.rs | 4 +- .../src/lint/rules/legacy_interpolation.rs | 2 +- crates/mds-core/src/lint/rules/mod.rs | 20 ++ crates/mds-core/src/lint/tier.rs | 97 ++++++--- crates/mds-core/tests/api_surface.rs | 74 +++++++ crates/mds-napi/README.md | 2 +- crates/mds-napi/__test__/index.spec.mjs | 67 ++++++ crates/mds-napi/src/lib.rs | 102 +++++++-- crates/mds-python/README.md | 5 +- crates/mds-python/src/lib.rs | 94 +++++++-- crates/mds-python/tests/test_lint.py | 91 ++++++++ crates/mds-wasm/src/lib.rs | 54 ++++- crates/mds-wasm/tests/web.rs | 113 ++++++++++ packages/mds-wasm/README.md | 5 +- packages/mds/README.md | 7 +- packages/mds/src/index.ts | 3 +- packages/mds/src/types.ts | 63 +++++- 25 files changed, 1241 insertions(+), 194 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f680ca88..de470c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,49 @@ name rather than the `@import` keyword, and `span.length` is the name's length instead of a constant 7. Alias imports (`@import "path" as alias`) are unchanged — their span still covers the `@import` keyword. +#### Unknown lint rule names now emit a warning instead of being silently ignored (#224) + +Previously, an unrecognised rule name in `mds.json`'s `lint.rules` object (or the +`rules` option on the binding surfaces) was silently accepted — the rule had no effect +and there was no signal that a key was misconfigured. + +**New behaviour:** an unknown rule name emits a warning and lint continues +(exit codes are unchanged). This surfaces typos and forward-compat configs without +hard-failing on rule names added in a newer binary. + +- **CLI**: the warning goes to **stderr** (never stdout), so `--format json` output + remains valid parseable JSON. `--quiet` suppresses the warning. +- **napi / WASM / Python binding surfaces**: the warning is returned in + `lint_warnings: string[]` (absent when empty) on the returned lint result object. + +Exact format: `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring` + +Unknown **severity values** (not rule names) continue to hard-fail with +`mds::invalid_options` — the asymmetry is intentional (severities are a closed set; +rule names grow with each release). + +#### New exports: `LINT_RULE_NAMES`, `LintRuleName` (TypeScript / `@mdscript/mds`) + +The canonical list of recognised lint rule names is now exported as: +- `LINT_RULE_NAMES: readonly LintRuleName[]` — the alphabetically sorted array +- `LintRuleName` — a string union type of all 10 rule name literals + +`LintResult.lint_warnings?: string[]` is added to the TypeScript interface. + +#### New core API: `KNOWN_LINT_RULES`, `find_unknown_rule_names`, `UnknownRuleNames`, `format_unknown_rule_names_warning` + +`mds-core` now exports: +- `KNOWN_LINT_RULES: &[&str]` — the canonical slice of rule names +- `find_unknown_rule_names(rules: &HashMap) -> Option` — + returns `None` when all names are known, `Some` with a sorted `UnknownRuleNames` when not +- `format_unknown_rule_names_warning(names: &[String]) -> String` — formats the warning string +- `UnknownRuleNames` — a `#[non_exhaustive]` struct with a `names() -> &[String]` accessor + +#### New `LintResult.lint_warnings` getter on Python `LintResult` + +`mds-python`'s `LintResult` gains a `.lint_warnings` property returning `list[str]` +(empty when no warnings occurred). Existing callers are not affected. + #### New `fix_edits` field on `LintDiagnostic` `LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 17e6cdb6..ae84b4db 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -32,7 +32,8 @@ pub(crate) struct MdsConfig { /// Per-rule severity overrides for `mds lint` (AC-F-17). /// /// Unknown severity VALUES fail config loading loudly (closed enum). - /// Unknown rule NAMES are preserved for forward compat (CLI warns and ignores). + /// Unknown rule NAMES produce a warning and lint continues (forward-compat: a config + /// naming a rule from a newer mds version warns but does not break on an older binary). #[serde(default)] pub(crate) lint: LintCliConfig, } @@ -44,7 +45,7 @@ pub(crate) struct MdsConfig { /// /// Unknown severity VALUES (e.g. `"banana"`) cause a hard parse error (exit 2) /// because `Severity` is a closed enum with no sensible fallback. Unknown rule -/// NAMES are warn-and-ignored at the CLI layer (forward compat). +/// NAMES produce a warning on every surface and lint continues (forward-compat). #[derive(Debug, Default, Deserialize)] pub(crate) struct LintCliConfig { #[serde(default)] diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index dafc09bf..50069310 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -43,19 +43,10 @@ use crate::output::{ STDIN_DISPLAY_LABEL, }; -/// Known lint rule names — used to warn about unknown names in mds.json config. -const KNOWN_RULES: &[&str] = &[ - "unused-variable", - "unused-import", - "unused-function", - "shadow-variable", - "empty-block", - "redundant-else", - "unreachable-branch", - "duplicate-import", - "duplicate-export", - "legacy-interpolation", -]; +// AC-224-15: No local rule-name list. The single source of truth is +// mds::KNOWN_LINT_RULES (composed from each rule module's own RULE const). +// A repo-wide search for "unused-variable" under crates/mds-cli/src/ must +// return zero hits. pub(crate) struct LintArgs { pub(crate) input: Option, @@ -187,30 +178,57 @@ fn do_lint(args: LintArgs) -> Result<()> { // ── Config helpers ──────────────────────────────────────────────────────────── /// Load mds.json and extract the core `LintConfig`, warning about unknown rule names. -fn load_lint_config(dir: &Path) -> Result { +/// +/// AD-224-1 (2026-08-12 ruling): an unknown rule name WARNS and lint CONTINUES. +/// The domain is asymmetric — severities are a closed set but rule names grow +/// every release — so hard-failing would break configs naming rules from a newer +/// mds when run with an older binary. +/// +/// AD-224-5 (AC-224-21, AC-224-22): the warning goes to STDERR only (never +/// stdout — `--format json` stdout must remain valid parseable JSON), and is +/// SUPPRESSED under `--quiet` (this signal precedes a normal exit, not an error; +/// see `crates/mds-cli/src/main.rs:30`'s documented contract). +fn load_lint_config(dir: &Path, quiet: bool) -> Result { let config_opt = load_config(dir)?; match config_opt { None => Ok(mds::LintConfig::default()), Some((mds_config, _config_dir)) => { - // Warn on unknown rule names — unknown NAMES are ignored for forward compat. - // - // `name` is an arbitrary attacker-supplied JSON object key: `mds.json` is - // read from the working tree, and JSON `\uXXXX` escapes decode to real - // control bytes. + // AD-224-3: `safe_inline` WIRE-escapes each name before it enters the + // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). + // A JSON object key is never legitimately multi-line; routing through + // `safe_inline` closes CWE-117 on the newline + forged-line vector. + // This keeps the value inside `eprint_warning`'s arguments, which + // `print_discipline.rs:27-60` already machine-checks (AC-224-6). // - // Two escapes, deliberately different (spec §7.5 per-field rule): the warning - // PROSE goes through `eprint_warning` (HUMAN — it is the message body), while - // the rule NAME goes through `safe_inline` (WIRE). Routing the whole line - // through `eprint_warning` alone was NOT sufficient: HUMAN mode preserves - // `\n`, so a rule name of `x\nClean: totally-real.mds\n0 problems found\n` - // still emitted three standalone lines byte-identical to genuine status - // output (CWE-117). A JSON object key is never legitimately multi-line. - for name in mds_config.lint.rules.keys() { - if !KNOWN_RULES.contains(&name.as_str()) { - eprint_warning(&format!( - "warning: unknown lint rule '{}' in mds.json; ignoring", - safe_inline(name) - )); + // AC-224-22: suppress under --quiet (coordination point with PR4 D4). + if !quiet { + if let Some(ref unknown) = mds::find_unknown_rule_names(&mds_config.lint.rules) { + // Escape each name via safe_inline (WIRE per-field rule, spec §7.5). + let escaped: Vec = unknown + .names() + .iter() + .map(safe_inline) + .collect(); + // AC-224-2: include all recognised rule names (KNOWN_LINT_RULES is + // sorted alphabetically — AC-224-3 determinism guaranteed). + let recognised = mds::KNOWN_LINT_RULES.join(", "); + let warning = if escaped.len() == 1 { + format!( + "warning: unknown lint rule '{}' in mds.json; \ + recognised rules are: {}; ignoring", + escaped[0], recognised + ) + } else { + let quoted: Vec = + escaped.iter().map(|n| format!("'{n}'")).collect(); + format!( + "warning: unknown lint rules in mds.json: {}; \ + recognised rules are: {}; ignoring", + quoted.join(", "), + recognised + ) + }; + eprint_warning(&warning); } } Ok(mds_config.lint.into_core_config()) @@ -686,7 +704,7 @@ fn run_lint_stdin( let (source, cwd) = read_stdin()?; // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). - let config = match load_lint_config(&cwd) { + let config = match load_lint_config(&cwd, quiet) { Ok(c) => c, Err(e) => { let mds_err = MdsError::Io { @@ -831,7 +849,7 @@ fn run_lint_file( // effective_parent maps "" (bare filename) to "." — avoids PF-006. let base_dir = effective_parent(path); // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). - let config = match load_lint_config(base_dir) { + let config = match load_lint_config(base_dir, quiet) { Ok(c) => c, Err(e) => { let mds_err = MdsError::Io { @@ -1011,7 +1029,7 @@ impl<'a> LintDirCtx<'a> { return Ok(Rc::clone(cfg)); } } - let config = load_lint_config(base_dir).map_err(|e| MdsError::Io { + let config = load_lint_config(base_dir, self.flags.quiet).map_err(|e| MdsError::Io { message: format!("{e}"), })?; let rc = Rc::new(config); diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index aaae668d..96afe5d6 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -22,6 +22,11 @@ //! - L-CLI-DIR2: directory --format json files[] order is deterministic (TEST-6) //! - I-24: unreachable-branch (always-true @if) --fix applied; file changed, exit 0 //! - I-26: shadow-variable Info severity emits diagnostic and exits 0 (Info never affects exit) +//! - AC-224-10: unknown rule name → JSON wire shape unchanged (lint continues) +//! - AC-224-11: unknown rule warning goes to stderr, not stdout +//! - AC-224-19: directory with N files emits exactly ONE unknown-rule warning +//! - AC-224-21: stdout JSON remains valid when unknown rule name is present +//! - AC-224-22: --quiet suppresses the unknown-rule warning mod common; use common::{assert_no_control_chars, fixture, mds_bin}; @@ -3265,3 +3270,194 @@ fn lint_dir_nested_malformed_config_per_file_error() { "output must reference the malformed config source; got: {combined}" ); } + +// ── AC-224-10/11/21/22/19: unknown rule-name warn-and-continue ─────────────── +// +// An unknown rule name in `mds.json` must: +// AC-224-10: not alter the JSON wire shape on stdout (lint continues as normal) +// AC-224-11: emit the warning on stderr (never stdout) +// AC-224-21: preserve valid JSON on stdout in --format json mode +// AC-224-22: be suppressed by --quiet +// AC-224-19: emit exactly ONE warning per mds lint invocation, not one per file + +/// Write a `mds.json` with an unknown rule name into `dir`. +fn write_unknown_rule_config(dir: &std::path::Path) { + let config = serde_json::json!({ + "lint": { + "rules": { + "no-such-rule-xyzzy": "warn" + } + } + }); + std::fs::write( + dir.join("mds.json"), + serde_json::to_string(&config).unwrap(), + ) + .unwrap(); +} + +/// AC-224-21 / AC-224-11: warning goes to stderr; stdout JSON is unaffected. +/// +/// In `--format json` mode the stdout JSON must be valid and not contain the +/// warning text; the warning must appear on stderr instead. +#[test] +fn unknown_rule_warning_to_stderr_not_stdout_in_json_mode() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("clean.mds"), "Hello!\n").unwrap(); + write_unknown_rule_config(dir.path()); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // AC-224-21: stdout must still be valid JSON (the warning must NOT land there). + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "AC-224-21: stdout must be valid JSON even when unknown rules are present; \ + parse error: {e}; stdout: {stdout}" + ) + }); + assert_eq!( + parsed["version"].as_u64(), + Some(1), + "AC-224-21: JSON must have version:1; got: {parsed}" + ); + assert!( + !stdout.contains("unknown"), + "AC-224-21: 'unknown' warning text must not appear in stdout JSON; got: {stdout}" + ); + + // AC-224-11: the warning must appear on stderr. + assert!( + stderr.contains("unknown lint rule") || stderr.contains("no-such-rule-xyzzy"), + "AC-224-11: the unknown-rule warning must go to stderr; got stderr: {stderr}" + ); +} + +/// AC-224-10: the JSON wire shape is unchanged when lint continues past an unknown rule. +/// +/// The `files` array and `truncated` / `version` fields must be present with their +/// normal shape. No extra top-level error key must appear. +#[test] +fn unknown_rule_json_wire_shape_unchanged() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + write_unknown_rule_config(dir.path()); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&out.stdout); + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); + + // AC-224-10: standard fields are present; no error envelope. + assert!( + parsed.get("files").is_some(), + "AC-224-10: 'files' key must be present in the JSON output; got: {parsed}" + ); + assert!( + parsed.get("truncated").is_some(), + "AC-224-10: 'truncated' key must be present in the JSON output; got: {parsed}" + ); + assert!( + parsed.get("error").is_none(), + "AC-224-10: no 'error' key must appear for a lint-continues result; got: {parsed}" + ); + + // Exit code must be 0 (clean file, no real diagnostics). + assert_eq!( + out.status.code(), + Some(0), + "AC-224-10: exit code must be 0 when the only unknown element is a rule name; \ + got stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// AC-224-22: `--quiet` suppresses the unknown-rule warning on stderr. +#[test] +fn unknown_rule_warning_suppressed_by_quiet() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + write_unknown_rule_config(dir.path()); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .arg("--quiet") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // AC-224-22: --quiet must suppress the unknown-rule warning. + assert!( + !stderr.contains("unknown lint rule") && !stderr.contains("no-such-rule-xyzzy"), + "AC-224-22: --quiet must suppress the unknown-rule warning; got stderr: {stderr}" + ); + + // Exit code is still 0 (clean file). + assert_eq!( + out.status.code(), + Some(0), + "AC-224-22: exit code must be 0; got stderr: {stderr}" + ); +} + +/// AC-224-19: a directory with many files emits exactly ONE unknown-rule warning, +/// not one per file. +/// +/// The implementation detects unknowns once at config-load time, not per-file +/// invocation. This test creates 5 files in the same directory to ensure the +/// warning count does not scale with the number of linted files. +#[test] +fn unknown_rule_one_warning_per_invocation_not_per_file() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..5u32 { + std::fs::write(dir.path().join(format!("file{i}.mds")), "Hello!\n").unwrap(); + } + write_unknown_rule_config(dir.path()); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // AC-224-19: the warning must appear at least once (non-vacuity). + assert!( + stderr.contains("unknown lint rule") || stderr.contains("no-such-rule-xyzzy"), + "AC-224-19: warning must appear; got stderr: {stderr}" + ); + + // AC-224-19: the warning must appear at most once (one per invocation, not per file). + let warning_count = stderr.lines().filter(|l| l.contains("unknown lint rule")).count(); + assert_eq!( + warning_count, 1, + "AC-224-19: warning must appear exactly once per invocation, not once per file \ + (got {warning_count}); got stderr: {stderr}" + ); +} diff --git a/crates/mds-cli/tests/print_discipline.rs b/crates/mds-cli/tests/print_discipline.rs index 065476e7..2cd641dd 100644 --- a/crates/mds-cli/tests/print_discipline.rs +++ b/crates/mds-cli/tests/print_discipline.rs @@ -385,6 +385,22 @@ const ALLOWED_UNTRACED_HELPER_ARGS: &[(&str, &str, &str)] = &[ guard checks it. Two entries — this one and `build.rs`'s — cover all five live \ bare-`w` sites, because the list is keyed by (file, expression).", ), + ( + "lint.rs", + "&warning", + "The `warning` variable is pre-assembled in `load_lint_config` using \ + `safe_inline(name)` on every unknown rule name (WIRE per-field escaping of \ + hostile characters from `mds.json`) and `mds::KNOWN_LINT_RULES.join(\", \")` \ + on compile-time-constant string literals from the registry. No user-controlled \ + content enters `warning` unescaped. The lexical guard cannot trace across the \ + `format!` call that assembles `warning` from those pre-escaped pieces, so this \ + entry is the human review anchor for WIRE-safety-at-construction. End-to-end \ + verified by T-ESC-RULE-1 in `security.rs`, which proves that a hostile \ + `mds.json` rule name (C0, bidi, and embedded newlines) reaches stderr with all \ + control bytes WIRE-escaped and no forged status line possible. The recognised-rules \ + list (`mds::KNOWN_LINT_RULES`) is a `&[&'static str]` slice of compiler-constant \ + literals with no user-derived content — no escaping is needed or applied there.", + ), ]; // ── The guard ───────────────────────────────────────────────────────────────── diff --git a/crates/mds-cli/tests/security.rs b/crates/mds-cli/tests/security.rs index fb3004b3..de926e8a 100644 --- a/crates/mds-cli/tests/security.rs +++ b/crates/mds-cli/tests/security.rs @@ -546,8 +546,9 @@ fn build_cli_authored_error_message_escapes_control_bytes() { // Both are reproduced-first vectors: on the pre-fix binary each command below emits // the raw hostile bytes (verified with `od -c`). -/// T-ESC-RULE-1 [security-11 / CWE-150 / PF-004 / PF-013 / #176]: an unknown lint rule -/// NAME from `mds.json` reaches stderr escaped. +/// T-ESC-RULE-1 [security-11 / CWE-150 / PF-004 / PF-013 / #176 / AC-224-4 / AC-224-5]: +/// an unknown lint rule NAME from `mds.json` reaches stderr escaped; lint warns and +/// continues rather than hard-failing. /// /// Vector: `mds.json` is read from the working tree and its rule names are arbitrary /// JSON object keys. A JSON `\uXXXX` escape decodes to a real byte, so a repository can @@ -569,6 +570,10 @@ fn build_cli_authored_error_message_escapes_control_bytes() { /// `assert_no_control_chars`, which permits `\n` — so it certified the fix while the /// forgery still worked. A rule name is a JSON object key: never legitimately /// multi-line, so it is WIRE per the spec §7.5 per-field rule. +/// +/// **AC-224-4 multi-width loop**: The test runs across COLUMNS values 40-200 (no TTY). +/// `eprint_warning` is a bare `eprintln!` that never line-wraps, so the one-line +/// assertion must hold at all widths — the loop is the machine-checked proof. #[test] fn lint_unknown_rule_name_escapes_control_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -590,73 +595,92 @@ fn lint_unknown_rule_name_escapes_control_bytes() { ) .unwrap(); - let out = mds_bin() - .arg("lint") - .arg(dir.path()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .unwrap(); - - let stderr = String::from_utf8_lossy(&out.stderr); + // AC-224-4: run across multiple terminal widths to prove the warning never wraps + // (eprint_warning → bare eprintln!, independent of COLUMNS). + for columns in [40u32, 60, 80, 100, 120, 160, 200] { + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .env("COLUMNS", columns.to_string()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); - // ── Non-vacuity: the warning actually fired, naming the rule ───────────── - assert!( - stderr.contains("unknown lint rule"), - "non-vacuity: the unknown-rule warning must be the one rendered; got: {stderr}" - ); - assert!( - stderr.contains("EVIL"), - "non-vacuity: the rule name itself must be printed; got: {stderr}" - ); + let stderr = String::from_utf8_lossy(&out.stderr); - // ── Negative: no raw hostile byte survives ─────────────────────────────── - assert!( - !out.stderr.contains(&0x1Bu8), - "raw ESC byte (0x1B) must not reach stderr from an mds.json rule name; got: {stderr}" - ); - assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); + // ── Non-vacuity: the warning fired, naming the rule and the recognised list ── + assert!( + stderr.contains("unknown lint rule"), + "COLUMNS={columns}: non-vacuity: the unknown-rule warning must be rendered; \ + got: {stderr}" + ); + assert!( + stderr.contains("EVIL"), + "COLUMNS={columns}: non-vacuity: the rule name itself must be printed; got: {stderr}" + ); + assert!( + stderr.contains("recognised rules are"), + "COLUMNS={columns}: non-vacuity: the recognised-rules list must appear; got: {stderr}" + ); - // ── Negative: neither forged line appears on a line of its own ─────────── - // - // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode - // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. - for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + // ── Negative: no raw hostile byte survives ─────────────────────────────── assert!( - !stderr.lines().any(|l| l.trim() == forged), - "an mds.json rule name must not be able to forge the standalone status line \ - {forged:?}; got: {stderr}" + !out.stderr.contains(&0x1Bu8), + "COLUMNS={columns}: raw ESC byte (0x1B) must not reach stderr from an mds.json \ + rule name; got: {stderr}" ); - } + assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); - // ── Positive: the escaped literals are present ─────────────────────────── - for escaped in ["\\u001B", "\\u202E", "\\u061C"] { + // ── Negative: neither forged line appears on a line of its own ─────────── + // + // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode + // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. + for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + assert!( + !stderr.lines().any(|l| l.trim() == forged), + "COLUMNS={columns}: an mds.json rule name must not be able to forge the \ + standalone status line {forged:?}; got: {stderr}" + ); + } + + // ── Positive: the escaped literals are present ─────────────────────────── + for escaped in ["\\u001B", "\\u202E", "\\u061C"] { + assert!( + stderr.contains(escaped), + "COLUMNS={columns}: {escaped} must appear in the unknown-rule warning; \ + got: {stderr}" + ); + } + assert_eq!( + stderr.matches("\\u000A").count(), + 2, + "COLUMNS={columns}: both embedded newlines must be escaped to their WIRE literal; \ + got: {stderr}" + ); + + // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── + // + // This is the AC-224-4 multi-width form: asserted at every COLUMNS width, + // not just at the default. eprint_warning is a bare eprintln! so it never + // wraps regardless of terminal width — the COLUMNS loop is machine proof. + let warning_lines: Vec<&str> = stderr + .lines() + .filter(|l| l.contains("unknown lint rule")) + .collect(); + assert_eq!( + warning_lines.len(), + 1, + "COLUMNS={columns}: the warning must occupy exactly one line; got: {stderr}" + ); assert!( - stderr.contains(escaped), - "{escaped} must appear in the unknown-rule warning; got: {stderr}" + warning_lines[0].contains("EVIL") + && warning_lines[0].contains("recognised rules are") + && warning_lines[0].ends_with("; ignoring"), + "COLUMNS={columns}: the single warning line must carry the rule name, the \ + recognised-rules list, and the trailing '; ignoring'; got: {stderr}" ); } - assert_eq!( - stderr.matches("\\u000A").count(), - 2, - "both embedded newlines must be escaped to their WIRE literal; got: {stderr}" - ); - - // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── - let warning_lines: Vec<&str> = stderr - .lines() - .filter(|l| l.contains("unknown lint rule")) - .collect(); - assert_eq!( - warning_lines.len(), - 1, - "the warning must occupy exactly one line; got: {stderr}" - ); - assert!( - warning_lines[0].contains("EVIL") && warning_lines[0].ends_with("; ignoring"), - "the single warning line must carry the whole rule name and the trailing prose; \ - got: {stderr}" - ); } /// T-ESC-FNAME-1 [S14 / CWE-117 / PF-013 / #176]: a filename containing newlines diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index 39304a61..ec876505 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -65,6 +65,10 @@ pub use lint::{ sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, TextEdit, }; +pub use lint::config::{ + find_unknown_rule_names, format_unknown_rule_names_warning, UnknownRuleNames, + KNOWN_LINT_RULES, +}; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, }; diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 4fb79ba7..155318d0 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -3,12 +3,134 @@ //! `LintConfig` is public (lives in mds-core) — the CLI converts the `mds.json` //! `lint.rules` section into it, and all `lint_*` entry points accept a `&LintConfig`. //! -//! **Unknown rule NAMEs** are preserved in the map (warn-and-ignore at the CLI layer). +//! **Unknown rule NAMEs** emit a warning and lint continues — the unknown rule simply +//! has no effect. This is deliberate forward-compatibility: severities are a closed set, +//! but rule names grow every release; hard-failing an unknown name would break a config +//! naming a newer rule when run with an older binary. //! **Unknown severity VALUES** fail loudly via serde deserialization error (closed enum). use std::collections::HashMap; use super::diagnostic::Severity; +use super::rules; + +/// All known lint rule names, sorted lexicographically. +/// +/// AD-224-2: assembled from each rule module's own `RULE` const (the single +/// source of truth for the string). The omission risk — a new module whose `RULE` +/// is never listed — is closed by the bidirectional tier table in `tier.rs`. +/// +/// PF-015: this list is accurate as of this release. Future releases may add +/// entries; code that gates on this list should be prepared for it to grow. +pub const KNOWN_LINT_RULES: &[&str] = rules::ALL_RULE_NAMES; + +/// The set of rule names in a `rules` map that are not registered with the lint engine. +/// +/// AD-224-1: this is NOT an error. Under the 2026-08-12 ruling, an unknown rule name +/// warns and lint continues — the rule simply has no effect. See [`find_unknown_rule_names`]. +/// +/// The names are sorted lexicographically. This type is `#[non_exhaustive]`: +/// use the [`UnknownRuleNames::names`] accessor, not a struct literal. +/// +/// This type is `#[non_exhaustive]` per ADR-010. It is constructible only through +/// the library — external crates MUST NOT build it via a struct literal. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct UnknownRuleNames { + /// Sorted, non-empty list of unknown rule names. + names: Vec, +} + +impl UnknownRuleNames { + fn new(mut names: Vec) -> Self { + names.sort(); + UnknownRuleNames { names } + } + + /// The unknown rule names, sorted lexicographically. + /// + /// Always non-empty — `UnknownRuleNames` is only constructed when at least + /// one unknown name is present. + pub fn names(&self) -> &[String] { + &self.names + } +} + +/// Detect rule names in `rules` that are not registered in [`KNOWN_LINT_RULES`]. +/// +/// Returns `None` when every name in `rules` is known. Returns `Some(UnknownRuleNames)` +/// when at least one unknown name is found; names inside are sorted lexicographically. +/// +/// # Examples +/// +/// ``` +/// use std::collections::HashMap; +/// use mds::{Severity, find_unknown_rule_names, KNOWN_LINT_RULES}; +/// +/// // All-known map → no unknowns. +/// let known = HashMap::from([("unused-variable".to_string(), Severity::Off)]); +/// assert!(find_unknown_rule_names(&known).is_none()); +/// +/// // Map with an unknown name → Some with that name. +/// let mixed = HashMap::from([ +/// ("unused-variable".to_string(), Severity::Off), +/// ("no-such-rule".to_string(), Severity::Warn), +/// ]); +/// let u = find_unknown_rule_names(&mixed).expect("should have unknown"); +/// assert_eq!(u.names(), &["no-such-rule".to_string()]); +/// assert_eq!(KNOWN_LINT_RULES.len(), 10); +/// ``` +pub fn find_unknown_rule_names( + rules: &HashMap, +) -> Option { + let unknowns: Vec = rules + .keys() + .filter(|k| !KNOWN_LINT_RULES.contains(&k.as_str())) + .cloned() + .collect(); + if unknowns.is_empty() { + None + } else { + Some(UnknownRuleNames::new(unknowns)) + } +} + +/// Format the warning message for one or more unknown lint rule names. +/// +/// AD-224-4: both the offending-name list and the recognised-rules list are +/// sorted lexicographically in the output, ensuring deterministic output across +/// runs and surfaces regardless of HashMap iteration order. The structural +/// precondition (at least one unknown name) is guaranteed by the `UnknownRuleNames` +/// type — only constructible with a non-empty list. +/// +/// **CLI note:** the CLI applies `safe_inline` to each name BEFORE passing it here, +/// so the output of this function contains WIRE-escaped control bytes. The bindings +/// pass raw names (JSON encoding handles escaping for their output channel). +/// +/// Produces one of: +/// - `"unknown lint rule 'NAME'; recognised rules are: ...; ignoring"` +/// - `"unknown lint rules: 'A', 'B'; recognised rules are: ...; ignoring"` +#[must_use] +pub fn format_unknown_rule_names_warning(names: &[String]) -> String { + // Structural precondition: names must be non-empty. + // UnknownRuleNames guarantees this, but callers passing &[String] directly + // should ensure the same. + assert!(!names.is_empty(), "format_unknown_rule_names_warning called with empty names"); + let recognised = KNOWN_LINT_RULES.join(", "); + if names.len() == 1 { + format!( + "unknown lint rule '{}'; recognised rules are: {}; ignoring", + names[0], recognised + ) + } else { + let quoted: Vec = names.iter().map(|n| format!("'{n}'")).collect(); + format!( + "unknown lint rules: {}; recognised rules are: {}; ignoring", + quoted.join(", "), + recognised + ) + } +} /// Per-rule severity override configuration. /// @@ -18,9 +140,10 @@ use super::diagnostic::Severity; /// ``` /// /// Absent rules default to the engine's built-in severity (defined per rule in the -/// rule catalog). Unknown rule names in the map are preserved and may emit a -/// warn-at-CLI-layer diagnostic (so forward-compat: a new rule name from a newer -/// version of mds does not break older configs). +/// rule catalog). Unknown rule names in the map produce a warning on every surface +/// (the rule simply has no effect, and lint continues). This is deliberate +/// forward-compatibility: a config naming a rule from a newer mds version warns +/// but does not break when run with an older binary. /// /// Unknown severity *values* (e.g. `"verbose"`) cause a hard parse error (`exit 2`) /// because the closed enum has no sensible fallback. @@ -47,6 +170,11 @@ impl LintConfig { /// or `with_*` only when taking `self`. This function does not take `self`, /// so it is named `from_rules`. /// + /// Unknown rule names in the map produce a warning via [`find_unknown_rule_names`] + /// on every API surface; they do not cause this constructor to fail. Call + /// [`find_unknown_rule_names`] before or after construction if you need to inspect + /// or surface those names. + /// /// # Examples /// /// ``` diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index b55e4106..2b784a3a 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -193,8 +193,8 @@ use crate::limits::MAX_DIAGNOSTICS; /// `Error` renders as an error; produces exit code 2. /// /// Serialization: `"off"` / `"info"` / `"warn"` / `"error"` (closed enum — unknown -/// severity VALUE strings fail loudly via serde deserialization error, not -/// warn-and-ignore; only unknown rule NAMES get the lenient treatment). +/// severity VALUE strings fail loudly via serde deserialization error. Unknown rule +/// NAMES emit a warning and lint continues; see `find_unknown_rule_names`). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum Severity { diff --git a/crates/mds-core/src/lint/rules/legacy_interpolation.rs b/crates/mds-core/src/lint/rules/legacy_interpolation.rs index 54c87d8f..42724bd8 100644 --- a/crates/mds-core/src/lint/rules/legacy_interpolation.rs +++ b/crates/mds-core/src/lint/rules/legacy_interpolation.rs @@ -21,7 +21,7 @@ use crate::lint::config::LintConfig; use crate::lint::diagnostic::{LintDiagnostic, LintResultBuilder, Severity}; use crate::lint::TextEdit; -const RULE: &str = "legacy-interpolation"; +pub(crate) const RULE: &str = "legacy-interpolation"; /// Check for legacy single-brace interpolation syntax in the token stream. pub(crate) fn check( diff --git a/crates/mds-core/src/lint/rules/mod.rs b/crates/mds-core/src/lint/rules/mod.rs index bbbaead0..f0002ff4 100644 --- a/crates/mds-core/src/lint/rules/mod.rs +++ b/crates/mds-core/src/lint/rules/mod.rs @@ -30,3 +30,23 @@ pub(crate) mod unused_import; pub(crate) mod unused_variable; pub(crate) mod structural_eq; + +/// All registered lint rule names, sorted lexicographically. +/// +/// AD-224-2: composed from each rule module's own `RULE` const so the string +/// is the single source of truth. This does NOT close the *omission* risk — a +/// new module whose `RULE` is never listed here compiles fine. The mechanical +/// closure is the bidirectional tier table in `tier.rs` plus an explicit +/// `ALL_RULE_NAMES.len() == 10` pin in that table's test. +pub(crate) const ALL_RULE_NAMES: &[&str] = &[ + duplicate_export::RULE, + duplicate_import::RULE, + empty_block::RULE, + legacy_interpolation::RULE, + redundant_else::RULE, + shadow_variable::RULE, + unreachable_branch::RULE, + unused_function::RULE, + unused_import::RULE, + unused_variable::RULE, +]; diff --git a/crates/mds-core/src/lint/tier.rs b/crates/mds-core/src/lint/tier.rs index 304563a9..978144f7 100644 --- a/crates/mds-core/src/lint/tier.rs +++ b/crates/mds-core/src/lint/tier.rs @@ -97,48 +97,79 @@ pub(crate) fn first_occurrence( mod tests { use super::*; - /// ARC-3: Every registered rule name must map to its documented FixTier. + /// AC-224-9: Every registered rule name must map to its documented FixTier, + /// and the expected-tier table must agree with the registry in BOTH directions. /// - /// Enumerating all 10 rules explicitly means that a newly-added rule which - /// falls silently into the `_ => FixTier::C` catch-all arm will cause this - /// test to fail (once its expected tier is added here), preventing silent - /// misclassification in the JSON `fixable` field and the fix planner. + /// AD-224-2 (amended): `ALL_RULE_NAMES` removes string duplication but not + /// omission risk — an 11th rule module forgotten here compiles fine. The + /// mechanical closure requires: + /// 1. A separate `EXPECTED` table with explicit (name, tier) pairs. + /// 2. `assert_eq!(EXPECTED.len(), ALL_RULE_NAMES.len())` — length pin. + /// 3. Every registry entry appears in `EXPECTED` (registry → table). + /// 4. Every `EXPECTED` entry appears in the registry (table → registry). /// - /// Tier A: duplicate-import, duplicate-export, unreachable-branch, empty-block, - /// legacy-interpolation - /// Tier B: unused-import, unused-function - /// Tier C: unused-variable, redundant-else, shadow-variable + /// A rule present in the registry but absent from `EXPECTED`, or vice versa, + /// fails this test. The `_ => FixTier::C` catch-all arm is now unreachable + /// for valid registered rules. #[test] - fn all_ten_rules_map_to_expected_tier() { - // Tier A — auto-fixable with reverify gate - assert_eq!( - rule_tier("duplicate-import"), - FixTier::A, - "duplicate-import" - ); + fn registry_and_tier_table_are_bidirectionally_consistent() { + use super::super::rules::ALL_RULE_NAMES; + use super::super::config::KNOWN_LINT_RULES; + + /// Canonical expected-tier table: (rule_name, expected_FixTier). + /// Must be updated whenever a rule is added, removed, or re-tiered. + /// Pin: len must equal ALL_RULE_NAMES.len() (10 as of this release). + const EXPECTED: &[(&str, FixTier)] = &[ + // Tier A — auto-fixable with reverify gate + ("duplicate-export", FixTier::A), + ("duplicate-import", FixTier::A), + ("empty-block", FixTier::A), + ("legacy-interpolation", FixTier::A), + ("unreachable-branch", FixTier::A), + // Tier B — standalone-only fixable + ("unused-function", FixTier::B), + ("unused-import", FixTier::B), + // Tier C — report-only, never fixed + ("redundant-else", FixTier::C), + ("shadow-variable", FixTier::C), + ("unused-variable", FixTier::C), + ]; + + // Length pin: bump this when a new rule ships. assert_eq!( - rule_tier("duplicate-export"), - FixTier::A, - "duplicate-export" + EXPECTED.len(), 10, + "EXPECTED table length must equal the number of registered rules (10)" ); assert_eq!( - rule_tier("unreachable-branch"), - FixTier::A, - "unreachable-branch" + ALL_RULE_NAMES.len(), 10, + "ALL_RULE_NAMES length must equal the number of registered rules (10)" ); - assert_eq!(rule_tier("empty-block"), FixTier::A, "empty-block"); assert_eq!( - rule_tier("legacy-interpolation"), - FixTier::A, - "legacy-interpolation" + KNOWN_LINT_RULES.len(), 10, + "KNOWN_LINT_RULES length must equal the number of registered rules (10)" ); - // Tier B — standalone-only fixable - assert_eq!(rule_tier("unused-import"), FixTier::B, "unused-import"); - assert_eq!(rule_tier("unused-function"), FixTier::B, "unused-function"); - // Tier C — report-only, never fixed - assert_eq!(rule_tier("unused-variable"), FixTier::C, "unused-variable"); - assert_eq!(rule_tier("redundant-else"), FixTier::C, "redundant-else"); - assert_eq!(rule_tier("shadow-variable"), FixTier::C, "shadow-variable"); + + // Direction 1: every registry entry has a correct entry in EXPECTED. + for &name in ALL_RULE_NAMES { + let got = rule_tier(name); + let entry = EXPECTED.iter().find(|(n, _)| *n == name); + let expected_tier = entry + .unwrap_or_else(|| panic!("rule {name:?} is in the registry but missing from EXPECTED")) + .1 + .clone(); + assert_eq!( + got, expected_tier, + "rule_tier({name:?}) should be {expected_tier:?} per EXPECTED" + ); + } + + // Direction 2: every EXPECTED entry exists in the registry. + for &(name, ref _tier) in EXPECTED { + assert!( + ALL_RULE_NAMES.contains(&name), + "rule {name:?} is in EXPECTED but missing from ALL_RULE_NAMES (registry)" + ); + } } /// is_output_neutral: legacy-interpolation is NOT output-neutral; all other diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 0e7f456f..6cfee300 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1130,6 +1130,80 @@ fn lint_types_exist() { assert!(!result.truncated); } +/// AC-224-7 / AC-224-8: KNOWN_LINT_RULES and UnknownRuleNames honour ADR-010. +/// +/// - `KNOWN_LINT_RULES` is publicly reachable from an external crate. +/// - It contains exactly the ten registered rule names. +/// - Every entry in the registry is accepted by `LintConfig::from_rules` without +/// error or warning. +/// - `find_unknown_rule_names` returns `None` for all-known maps and `Some` for +/// maps containing unknown names. +/// - `UnknownRuleNames` exposes names via accessor, not a public field, and is +/// only constructible through the library. +#[test] +fn known_lint_rules_and_unknown_detection() { + use mds::{find_unknown_rule_names, KNOWN_LINT_RULES}; + + // AC-224-7: exactly 10 rules. + assert_eq!(KNOWN_LINT_RULES.len(), 10, "KNOWN_LINT_RULES must have exactly 10 entries"); + + // AC-224-7: exact sorted contents. + let expected = [ + "duplicate-export", + "duplicate-import", + "empty-block", + "legacy-interpolation", + "redundant-else", + "shadow-variable", + "unreachable-branch", + "unused-function", + "unused-import", + "unused-variable", + ]; + assert_eq!(KNOWN_LINT_RULES, &expected, "KNOWN_LINT_RULES must match the expected sorted list"); + + // AC-224-8: every known rule is accepted by from_rules without unknowns. + let all_known: HashMap = KNOWN_LINT_RULES + .iter() + .map(|&n| (n.to_string(), Severity::Warn)) + .collect(); + let _config = LintConfig::from_rules(all_known.clone()); + assert!( + find_unknown_rule_names(&all_known).is_none(), + "all-known rules map must produce no unknowns" + ); + + // AC-224-8: empty rules map produces no unknowns. + let empty: HashMap = HashMap::new(); + assert!( + find_unknown_rule_names(&empty).is_none(), + "empty rules map must produce no unknowns" + ); + + // AC-224-7: UnknownRuleNames is only obtainable via the library API. + let mixed: HashMap = HashMap::from([ + ("unused-variable".to_string(), Severity::Off), + ("no-such-rule".to_string(), Severity::Warn), + ("another-bad".to_string(), Severity::Error), + ]); + let unknown = find_unknown_rule_names(&mixed).expect("should detect two unknown rules"); + // Accessor returns names; struct literal construction is impossible (#[non_exhaustive]). + let names = unknown.names(); + assert_eq!(names, &["another-bad".to_string(), "no-such-rule".to_string()], + "names must be sorted lexicographically"); + assert_eq!(names.len(), 2); + + // Positive control (PF-013 / ADR-009): find_unknown_rule_names does NOT return None + // for a single-unknown map. + let one_bad: HashMap = HashMap::from([ + ("no-such-rule".to_string(), Severity::Warn), + ]); + assert!( + find_unknown_rule_names(&one_bad).is_some(), + "single-unknown map must produce Some" + ); +} + /// L-API-4: MdsError enum is unchanged — lint findings are LintDiagnostic, not MdsError variants. #[test] fn mds_error_variants_unchanged_by_lint() { diff --git a/crates/mds-napi/README.md b/crates/mds-napi/README.md index 679f1e16..a01f2595 100644 --- a/crates/mds-napi/README.md +++ b/crates/mds-napi/README.md @@ -66,7 +66,7 @@ Static analysis. Returns the canonical lint JSON: `{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, fix_edits, span?},...]},...], truncated: bool }` Options: `basePath` (lint only — lintFile derives the base from the file path; lintVirtual resolves against the module map), `vars`, `rules` (`Record`). -Unknown rule names in `rules` are silently accepted (a typo has no effect); unknown severity values throw `mds::invalid_options`. +Unknown rule names in `rules` emit a warning and lint continues — the unknown name has no effect but `result.lint_warnings` (a `string[]` field) is populated so callers can surface the issue; unknown severity values throw `mds::invalid_options`. See `index.d.ts` for the full typed surface. diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index f0066e06..736a22c7 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -1585,3 +1585,70 @@ describe('ESC-injection hardening (issue #176 / CWE-150)', () => { ); }); }); + +// ── AC-224 D8: unknown rule name warning channel on napi ───────────────────── +// +// An unknown rule name in the `rules` option produces a `lint_warnings` array +// in the result rather than hard-failing (D8 / AC-224-1). Known rule names +// produce no `lint_warnings` field (common-case cleanliness). + +describe('unknown rule name warning (AC-224 D8)', () => { + // L-N-WARN-1: lint() with unknown rule name returns lint_warnings array + test('L-N-WARN-1: lint with unknown rule name returns lint_warnings', () => { + const result = lint('Hello!\n', { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assert.equal(result.version, 1, 'version must be 1'); + assert.ok(Array.isArray(result.lint_warnings), 'lint_warnings must be an array'); + assert.ok(result.lint_warnings.length > 0, 'lint_warnings must be non-empty'); + assert.ok( + result.lint_warnings[0].includes('no-such-rule-xyzzy'), + `lint_warnings[0] must name the unknown rule; got: ${result.lint_warnings[0]}`, + ); + assert.ok( + result.lint_warnings[0].includes('recognised rules are') || + result.lint_warnings[0].includes('recognized rules are'), + `lint_warnings[0] must list recognised rules; got: ${result.lint_warnings[0]}`, + ); + }); + + // L-N-WARN-2: lint() with only known rules has no lint_warnings + test('L-N-WARN-2: lint with only known rules has no lint_warnings', () => { + const result = lint('Hello!\n', { rules: { 'unused-variable': 'off' } }); + assert.equal(result.version, 1); + assert.ok( + result.lint_warnings === undefined || result.lint_warnings.length === 0, + `lint_warnings must be absent or empty for known rules; got: ${JSON.stringify(result.lint_warnings)}`, + ); + }); + + // L-N-WARN-3: lintFile() with unknown rule name returns lint_warnings + test('L-N-WARN-3: lintFile with unknown rule name returns lint_warnings', () => { + const result = lintFile(SIMPLE_MDS, { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.lint_warnings), 'lint_warnings must be an array'); + assert.ok(result.lint_warnings.length > 0, 'lint_warnings must be non-empty'); + assert.ok( + result.lint_warnings[0].includes('no-such-rule-xyzzy'), + `lint_warnings[0] must name the unknown rule; got: ${result.lint_warnings[0]}`, + ); + }); + + // L-N-WARN-4: lintVirtual() with unknown rule name returns lint_warnings + test('L-N-WARN-4: lintVirtual with unknown rule name returns lint_warnings', () => { + const modules = { 'main.mds': 'Hello!\n' }; + const result = lintVirtual(modules, 'main.mds', { rules: { 'no-such-rule-xyzzy': 'error' } }); + assert.equal(result.version, 1); + assert.ok(Array.isArray(result.lint_warnings), 'lint_warnings must be an array'); + assert.ok(result.lint_warnings.length > 0, 'lint_warnings must be non-empty'); + assert.ok( + result.lint_warnings[0].includes('no-such-rule-xyzzy'), + `lint_warnings[0] must name the unknown rule; got: ${result.lint_warnings[0]}`, + ); + }); + + // L-N-WARN-5: lint continues — files[] is still present and correctly shaped + test('L-N-WARN-5: lint continues when rule name is unknown (files[] present)', () => { + const result = lint('Hello!\n', { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assert.ok(Array.isArray(result.files), 'files must be an array'); + assert.strictEqual(result.truncated, false, 'truncated must be false'); + }); +}); diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index e7574375..febcdd30 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -783,18 +783,23 @@ pub fn check_file(env: Env, path: String, opts: Option) -> napi::Result< // ── Lint options parsing ────────────────────────────────────────────────────── -/// Extract and validate the `rules` option: `Record` → `mds::LintConfig`. +/// Extract and validate the `rules` option: `Record` → `(mds::LintConfig, Vec)`. /// /// Returns the default config (all rules at built-in defaults) when `rules` is absent, /// `null`, or `undefined`. Validates each severity value against the closed enum. -fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result { +/// +/// D8 (AC-224-1): also detects unknown rule names and returns them as warning strings +/// so callers can surface them in the `lint_warnings` field of the returned JSON object. +/// This is the binding warning channel: napi `lint`/`lintFile`/`lintVirtual` add +/// `lint_warnings: string[]` to their return value when unknown rule names are present. +fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result<(mds::LintConfig, Vec)> { if !obj.has_named_property("rules")? { - return Ok(mds::LintConfig::default()); + return Ok((mds::LintConfig::default(), vec![])); } let val: Unknown = obj.get_named_property_unchecked("rules")?; let vt = val.get_type()?; match vt { - ValueType::Undefined | ValueType::Null => Ok(mds::LintConfig::default()), + ValueType::Undefined | ValueType::Null => Ok((mds::LintConfig::default(), vec![])), ValueType::Object => { // Deserialize the rules sub-object; js arrays also satisfy Object so // we guard against that in the JSON shape check below. @@ -832,7 +837,11 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result = mds::find_unknown_rule_names(&rules) + .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) + .unwrap_or_default(); + Ok((mds::LintConfig::from_rules(rules), lint_warnings)) } other => Err(throw_options_error( env, @@ -847,34 +856,39 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result, Option>, mds::LintConfig, + Vec, ); fn parse_lint_opts(env: &Env, opts: Option) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, None, mds::LintConfig::default())); + return Ok((None, None, mds::LintConfig::default(), vec![])); }; reject_unknown_napi_keys(env, &opts_obj, &["basePath", "vars", "rules"])?; let base_path = extract_base_path_direct(env, &opts_obj)?; let vars = extract_vars_direct(env, &opts_obj)?; - let lint_config = extract_rules_direct(env, &opts_obj)?; + let (lint_config, lint_warnings) = extract_rules_direct(env, &opts_obj)?; - Ok((base_path, vars, lint_config)) + Ok((base_path, vars, lint_config, lint_warnings)) } /// Parse options for `lintFile` (file-path variant). /// /// Valid keys: `vars`, `rules`. `basePath` is not accepted (derived from file path). -type LintFileOpts = (Option>, mds::LintConfig); +/// Returns `(vars, lint_config, lint_warnings)`. +type LintFileOpts = (Option>, mds::LintConfig, Vec); fn parse_lint_file_opts(env: &Env, opts: Option) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, mds::LintConfig::default())); + return Ok((None, mds::LintConfig::default(), vec![])); }; if opts_obj.has_named_property("basePath")? { @@ -887,9 +901,9 @@ fn parse_lint_file_opts(env: &Env, opts: Option) -> napi::Result) -> napi::Result) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, mds::LintConfig::default())); + return Ok((None, mds::LintConfig::default(), vec![])); }; if opts_obj.has_named_property("basePath")? { @@ -912,9 +926,9 @@ fn parse_lint_virtual_opts(env: &Env, opts: Option) -> napi::Result) -> napi::Result) -> napi::Result { check_source_size(&env, &source)?; - let (base_path, vars, lint_config) = parse_lint_opts(&env, opts)?; + let (base_path, vars, lint_config, lint_warnings) = parse_lint_opts(&env, opts)?; let result = run_catching( &env, @@ -957,7 +976,20 @@ pub fn lint(env: Env, source: String, opts: Option) -> napi::Result) -> napi::Result) -> napi::Result { - let (vars, lint_config) = parse_lint_file_opts(&env, opts)?; + let (vars, lint_config, lint_warnings) = parse_lint_file_opts(&env, opts)?; let path_buf = PathBuf::from(path); let result = run_catching( @@ -985,7 +1017,19 @@ pub fn lint_file(env: Env, path: String, opts: Option) -> napi::Result Vec { + self.value + .get("lint_warnings") + .and_then(serde_json::Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() + } + fn __repr__(&self) -> String { let n = self .value @@ -1245,17 +1264,24 @@ fn parse_modules(py: Python<'_>, modules: &Bound<'_, PyAny>) -> PyResult, rules: Option<&Bound<'_, PyAny>>) -> PyResult { +/// +/// D8 (AC-224-1): unknown rule NAMES are detected and returned as warning strings +/// in the second element of the tuple. Callers add them to the `LintResult` JSON +/// as `lint_warnings: list[str]` — the Python binding warning channel. +fn extract_rules( + py: Python<'_>, + rules: Option<&Bound<'_, PyAny>>, +) -> PyResult<(mds::LintConfig, Vec)> { let Some(obj) = rules else { - return Ok(mds::LintConfig::default()); + return Ok((mds::LintConfig::default(), vec![])); }; if obj.is_none() { - return Ok(mds::LintConfig::default()); + return Ok((mds::LintConfig::default(), vec![])); } let json: serde_json::Value = depythonize(obj).map_err(|e| options_error(py, &format!("invalid rules: {e}")))?; @@ -1291,7 +1317,11 @@ fn extract_rules(py: Python<'_>, rules: Option<&Bound<'_, PyAny>>) -> PyResult = mds::find_unknown_rule_names(&rules_map) + .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) + .unwrap_or_default(); + Ok((mds::LintConfig::from_rules(rules_map), lint_warnings)) } /// Build a [`mds::CompileOptions`] from the `source_map` and `sources_content` @@ -1493,13 +1523,23 @@ fn lint( check_source_size(py, &source)?; check_base_path(py, &base_path)?; let vars = extract_vars(py, vars.as_ref())?; - let lint_config = extract_rules(py, rules.as_ref())?; + let (lint_config, lint_warnings) = extract_rules(py, rules.as_ref())?; let result = run_catching(py, move || { mds::lint_str_with(&source, base_path.as_deref(), vars, &lint_config) })?; - Ok(LintResult { - value: result.to_canonical_json(), - }) + let mut value = result.to_canonical_json(); + // D8: inject lint_warnings into the JSON when unknown rule names were detected. + if !lint_warnings.is_empty() { + if let Some(obj) = value.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + lint_warnings.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + } + Ok(LintResult { value }) } /// Lint an MDS template file (`path` is a str or `os.PathLike`). @@ -1515,11 +1555,21 @@ fn lint_file( rules: Option>, ) -> PyResult { let vars = extract_vars(py, vars.as_ref())?; - let lint_config = extract_rules(py, rules.as_ref())?; + let (lint_config, lint_warnings) = extract_rules(py, rules.as_ref())?; let result = run_catching(py, move || mds::lint(&path, vars, &lint_config))?; - Ok(LintResult { - value: result.to_canonical_json(), - }) + let mut value = result.to_canonical_json(); + // D8: inject lint_warnings into the JSON when unknown rule names were detected. + if !lint_warnings.is_empty() { + if let Some(obj) = value.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + lint_warnings.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + } + Ok(LintResult { value }) } /// Lint a module from an in-memory virtual filesystem. @@ -1537,13 +1587,23 @@ fn lint_virtual( ) -> PyResult { let modules = parse_modules(py, &modules)?; let vars = extract_vars(py, vars.as_ref())?; - let lint_config = extract_rules(py, rules.as_ref())?; + let (lint_config, lint_warnings) = extract_rules(py, rules.as_ref())?; let result = run_catching(py, move || { mds::lint_virtual(modules, &entry, vars, &lint_config) })?; - Ok(LintResult { - value: result.to_canonical_json(), - }) + let mut value = result.to_canonical_json(); + // D8: inject lint_warnings into the JSON when unknown rule names were detected. + if !lint_warnings.is_empty() { + if let Some(obj) = value.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + lint_warnings.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + } + Ok(LintResult { value }) } // ── Module ────────────────────────────────────────────────────────────────────── diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index 333b44d3..c92113a4 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -357,3 +357,94 @@ def test_compile_to_dict_sourcemap_present_when_requested() -> None: d = r.to_dict() assert "sourceMap" in d assert isinstance(d["sourceMap"], dict), "sourceMap must be a dict when generated" + + +# ── AC-224 D8: unknown rule name warning channel (Python) ──────────────────── +# +# An unknown rule name in the `rules` kwarg produces `lint_warnings` in the +# LintResult (D8 / AC-224-1). Known rule names produce no lint_warnings. +# Python surface: lint_warnings() is a property getter that returns list[str]. + + +def test_py_warn_l1_unknown_rule_name_returns_lint_warnings() -> None: + """lint() with unknown rule name → lint_warnings non-empty (AC-224-1/D8).""" + r = m.lint(CLEAN_SOURCE, rules={"no-such-rule-xyzzy": "warn"}) + assert r.version == 1 + warnings = r.lint_warnings + assert isinstance(warnings, list), f"lint_warnings must be a list; got: {type(warnings)}" + assert len(warnings) > 0, "lint_warnings must be non-empty for an unknown rule name" + assert "no-such-rule-xyzzy" in warnings[0], ( + f"lint_warnings[0] must name the unknown rule; got: {warnings[0]}" + ) + assert "recognised rules are" in warnings[0] or "recognized rules are" in warnings[0], ( + f"lint_warnings[0] must list recognised rules; got: {warnings[0]}" + ) + + +def test_py_warn_l2_known_rule_names_no_lint_warnings() -> None: + """lint() with only known rule names → lint_warnings empty (AC-224-1/D8).""" + r = m.lint(CLEAN_SOURCE, rules={"unused-variable": "off"}) + assert r.version == 1 + assert r.lint_warnings == [], ( + f"lint_warnings must be empty for known rule names; got: {r.lint_warnings}" + ) + + +def test_py_warn_lf1_lint_file_unknown_rule_returns_warnings(fixtures: pathlib.Path) -> None: + """lint_file() with unknown rule name → lint_warnings non-empty (AC-224-1/D8).""" + r = m.lint_file(fixtures / "simple.mds", rules={"no-such-rule-xyzzy": "error"}) + assert r.version == 1 + warnings = r.lint_warnings + assert len(warnings) > 0, "lint_warnings must be non-empty for an unknown rule name" + assert "no-such-rule-xyzzy" in warnings[0], ( + f"lint_warnings[0] must name the unknown rule; got: {warnings[0]}" + ) + + +def test_py_warn_lv1_lint_virtual_unknown_rule_returns_warnings() -> None: + """lint_virtual() with unknown rule name → lint_warnings non-empty (AC-224-1/D8).""" + modules = {"main.mds": CLEAN_SOURCE} + r = m.lint_virtual(modules, "main.mds", rules={"no-such-rule-xyzzy": "warn"}) + assert r.version == 1 + warnings = r.lint_warnings + assert len(warnings) > 0, "lint_warnings must be non-empty for an unknown rule name" + assert "no-such-rule-xyzzy" in warnings[0], ( + f"lint_warnings[0] must name the unknown rule; got: {warnings[0]}" + ) + + +def test_py_warn_l3_lint_continues_with_unknown_rule() -> None: + """lint() with unknown rule name still returns files[] and truncated (AC-224-1/D8).""" + r = m.lint(CLEAN_SOURCE, rules={"no-such-rule-xyzzy": "warn"}) + d = r.to_dict() + assert "files" in d, "files key must be present even when unknown rule name is given" + assert "truncated" in d, "truncated key must be present even when unknown rule name is given" + assert d["truncated"] is False + + +def test_py_warn_l4_to_dict_includes_lint_warnings() -> None: + """lint() with unknown rule name → to_dict() includes 'lint_warnings' key (AC-224-1/D8).""" + r = m.lint(CLEAN_SOURCE, rules={"no-such-rule-xyzzy": "warn"}) + d = r.to_dict() + assert "lint_warnings" in d, ( + f"to_dict() must include 'lint_warnings' when unknown rule names are present; got: {d}" + ) + assert isinstance(d["lint_warnings"], list) + assert len(d["lint_warnings"]) > 0 + + +def test_py_warn_l5_multiple_unknown_rules() -> None: + """lint() with multiple unknown rule names → all appear in lint_warnings (AC-224-1/D8).""" + r = m.lint( + CLEAN_SOURCE, + rules={"no-such-rule-a": "warn", "no-such-rule-b": "error"}, + ) + warnings = r.lint_warnings + assert len(warnings) > 0, "lint_warnings must be non-empty" + combined = " ".join(warnings) + assert "no-such-rule-a" in combined, ( + f"all unknown rule names must appear in lint_warnings; got: {warnings}" + ) + assert "no-such-rule-b" in combined, ( + f"all unknown rule names must appear in lint_warnings; got: {warnings}" + ) diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index dcd34420..b12b0f6b 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -448,11 +448,16 @@ fn parse_options(options: JsValue) -> Result { /// Parsed options for the `lint` and `lint_virtual` functions. /// /// Extends the standard options with a `rules` field — absent in `compile`/`check`. +/// +/// `lint_warnings` is populated (non-empty) when unknown rule names are found; +/// callers add it to the returned JSON as `lint_warnings: string[]` (D8 binding channel). struct ParsedLintOptions { /// Standard options (filename, extra_modules, vars). opts: ParsedOptions, /// Per-rule severity overrides parsed from `options.rules`. lint_config: mds::LintConfig, + /// Warning messages for unknown rule names (AC-224-1 D8 channel). + lint_warnings: Vec, } /// Extract and validate the `rules` field from a lint options object. @@ -461,10 +466,13 @@ struct ParsedLintOptions { /// names and values are severity strings (`"off"` | `"info"` | `"warn"` | `"error"`). /// An absent or null/undefined `rules` key returns the default config (all rules at /// built-in defaults). An unknown severity VALUE is a hard error (closed enum). -fn extract_rules(obj: &js_sys::Object) -> Result { +/// +/// D8 (AC-224-1): unknown rule NAMES are detected and returned as warning strings. +/// Callers surface them via `lint_warnings` in the returned JSON object. +fn extract_rules(obj: &js_sys::Object) -> Result<(mds::LintConfig, Vec), JsValue> { let val = get_prop_js(obj, "rules"); if val.is_undefined() || val.is_null() { - return Ok(mds::LintConfig::default()); + return Ok((mds::LintConfig::default(), vec![])); } // Deserialize the rules sub-object via serde_wasm_bindgen. let rules_json: serde_json::Value = serde_wasm_bindgen::from_value(val) @@ -493,7 +501,11 @@ fn extract_rules(obj: &js_sys::Object) -> Result { })?; rules.insert(key, severity); } - Ok(mds::LintConfig::from_rules(rules)) + // D8: detect unknown rule names and format warning strings. + let lint_warnings: Vec = mds::find_unknown_rule_names(&rules) + .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) + .unwrap_or_default(); + Ok((mds::LintConfig::from_rules(rules), lint_warnings)) } /// Parse the JS options for `lint` and `lint_virtual`. @@ -507,6 +519,7 @@ fn parse_lint_options(options: JsValue) -> Result { return Ok(ParsedLintOptions { opts: ParsedOptions::default(), lint_config: mds::LintConfig::default(), + lint_warnings: vec![], }); } @@ -523,7 +536,7 @@ fn parse_lint_options(options: JsValue) -> Result { let filename = extract_filename(&obj)?; let extra_modules = extract_modules(&obj)?; let vars = extract_vars(&obj)?; - let lint_config = extract_rules(&obj)?; + let (lint_config, lint_warnings) = extract_rules(&obj)?; Ok(ParsedLintOptions { opts: ParsedOptions { @@ -534,6 +547,7 @@ fn parse_lint_options(options: JsValue) -> Result { include_sources_content: false, }, lint_config, + lint_warnings, }) } @@ -583,6 +597,7 @@ fn parse_lint_virtual_options(options: JsValue) -> Result Result Result Result { ) .map_err(mds_error_to_js)?; - to_js(&result.to_canonical_json()) + // D8 (AC-224-1): surface unknown-rule warnings on the WASM binding channel. + let mut json = result.to_canonical_json(); + if !lint_opts.lint_warnings.is_empty() { + if let Some(obj) = json.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + lint_opts.lint_warnings.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + } + to_js(&json) })) } @@ -897,7 +925,19 @@ pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result 0, + "W-WARN-1: lint_warnings must be non-empty for an unknown rule name" + ); + let w0 = warnings_arr.get(0).as_string().expect("W-WARN-1: lint_warnings[0] must be a string"); + assert!( + w0.contains("no-such-rule-xyzzy"), + "W-WARN-1: lint_warnings[0] must name the unknown rule; got: {w0}" + ); + assert!( + w0.contains("recognised rules are") || w0.contains("recognized rules are"), + "W-WARN-1: lint_warnings[0] must list recognised rules; got: {w0}" + ); +} + +#[wasm_bindgen_test] +fn wasm_lint_known_rule_names_no_lint_warnings() { + // W-WARN-2 (AC-224-1/D8): lint() with only known rule names has no lint_warnings. + let opts = to_js_object(&serde_json::json!({ + "rules": { "unused-variable": "off" } + })); + let result = mds_wasm::lint("Hello!\n", opts).expect("W-WARN-2: lint must succeed"); + let lint_warnings = get_prop(&result, "lint_warnings"); + assert!( + lint_warnings.is_undefined(), + "W-WARN-2: lint_warnings must be absent for known rule names; got: {lint_warnings:?}" + ); +} + +#[wasm_bindgen_test] +fn wasm_lint_virtual_unknown_rule_name_returns_lint_warnings() { + // W-WARN-3 (AC-224-1/D8): lintVirtual() with an unknown rule name returns lint_warnings. + let modules_val = to_js_object(&serde_json::json!({ "main.mds": "Hello!\n" })); + let opts = to_js_object(&serde_json::json!({ + "rules": { "no-such-rule-xyzzy": "error" } + })); + let result = mds_wasm::lint_virtual(modules_val, "main.mds", opts) + .expect("W-WARN-3: lintVirtual must succeed"); + + let lint_warnings = get_prop(&result, "lint_warnings"); + assert!( + !lint_warnings.is_undefined() && !lint_warnings.is_null(), + "W-WARN-3: lint_warnings must be present for an unknown rule name" + ); + let warnings_arr = js_sys::Array::from(&lint_warnings); + assert!( + warnings_arr.length() > 0, + "W-WARN-3: lint_warnings must be non-empty for an unknown rule name" + ); + let w0 = warnings_arr.get(0).as_string().expect("W-WARN-3: lint_warnings[0] must be a string"); + assert!( + w0.contains("no-such-rule-xyzzy"), + "W-WARN-3: lint_warnings[0] must name the unknown rule; got: {w0}" + ); +} + +#[wasm_bindgen_test] +fn wasm_lint_continues_with_unknown_rule_name() { + // W-WARN-4 (AC-224-1/D8): lint continues — files[] and truncated are present. + let opts = to_js_object(&serde_json::json!({ + "rules": { "no-such-rule-xyzzy": "warn" } + })); + let result = mds_wasm::lint("Hello!\n", opts).expect("W-WARN-4: lint must succeed"); + + // files[] must be present and be an array. + let files = get_prop(&result, "files"); + let files_arr = js_sys::Array::from(&files); + let _ = files_arr.length(); // just confirm it's array-like; no panic = ok + + // truncated must be present and false. + let truncated = get_prop(&result, "truncated") + .as_bool() + .unwrap_or(true); + assert!(!truncated, "W-WARN-4: truncated must be false for a clean source"); +} + +#[wasm_bindgen_test] +fn wasm_lint_unknown_rule_no_lint_warnings_absent_on_clean() { + // W-WARN-2b (AC-224-1/D8): no options → no lint_warnings field at all. + let result = mds_wasm::lint("Hello!\n", JsValue::NULL) + .expect("W-WARN-2b: lint(NULL) must succeed"); + let lint_warnings = get_prop(&result, "lint_warnings"); + assert!( + lint_warnings.is_undefined(), + "W-WARN-2b: lint_warnings must be absent when no rules are passed; got: {lint_warnings:?}" + ); +} diff --git a/packages/mds-wasm/README.md b/packages/mds-wasm/README.md index 91c33ce9..fe81f732 100644 --- a/packages/mds-wasm/README.md +++ b/packages/mds-wasm/README.md @@ -47,9 +47,10 @@ const checked = check(source, { vars: { name: 'World' } }); // lint(source, options) // Accepted keys: filename, modules, vars, rules. // options.rules — { [ruleName: string]: 'off' | 'info' | 'warn' | 'error' } -// Unknown rule names are silently accepted; unknown severity values throw. +// Unknown rule names emit a warning and lint continues; unknown severity values throw. +// When unknown rule names are present, lintResult.lint_warnings is a non-empty string[]. const lintResult = lint(source, { rules: { 'shadow-variable': 'warn' } }); -// lintResult: { version: 1, files: [...], truncated: boolean } +// lintResult: { version: 1, files: [...], truncated: boolean, lint_warnings?: string[] } // lintVirtual(modules, entry, options) // modules: { [key: string]: string } — the full virtual module map. diff --git a/packages/mds/README.md b/packages/mds/README.md index 5bd913c2..199f32e3 100644 --- a/packages/mds/README.md +++ b/packages/mds/README.md @@ -147,8 +147,11 @@ and `lintVirtual`. **Source maps:** for string-source compiles (`compile`) `sources[0]` in the generated map is `"input.mds"`. For stdin builds via the CLI it is `""`. -**Lint `rules` map:** unknown rule names are silently accepted (a typo has no effect); -unknown severity values throw `mds::invalid_options`. +**Lint `rules` map:** unknown rule names emit a warning and lint continues — the unknown +name has no effect but `result.lint_warnings` (a `string[]` field, absent when empty) +is populated so callers can surface the issue. Unknown severity values throw +`mds::invalid_options`. On the CLI surface the warning goes to stderr; in binding +surfaces it appears in `lint_warnings`. **Lint result shape:** ```ts diff --git a/packages/mds/src/index.ts b/packages/mds/src/index.ts index 54d3ac15..579d7a3f 100644 --- a/packages/mds/src/index.ts +++ b/packages/mds/src/index.ts @@ -11,6 +11,7 @@ export type { LintFileReport, LintOptions, LintResult, + LintRuleName, LintSpan, RuleSeverity, MdsErrorSpan, @@ -21,6 +22,6 @@ export type { MdsBaseBackend, MdsNodeBackend, } from './types.js'; -export { isMdsError } from './types.js'; +export { isMdsError, LINT_RULE_NAMES } from './types.js'; export type { WasmModule } from './backend/wasm.js'; export { initWasmNode, initWasmBrowser, createWasmBackend } from './backend/wasm.js'; diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index 4b483e87..3fa05cdd 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -166,6 +166,45 @@ export interface LintDiagnostic { */ export type RuleSeverity = 'error' | 'warn' | 'info' | 'off'; +/** + * Union of all recognised lint rule names. + * + * Passing a key outside this set in `LintOptions.rules` emits a warning (see + * {@link LintResult.lint_warnings}) and lint continues — unknown names are + * silently ignored by the engine after the warning is surfaced to the caller. + */ +export type LintRuleName = + | 'duplicate-export' + | 'duplicate-import' + | 'empty-block' + | 'legacy-interpolation' + | 'redundant-else' + | 'shadow-variable' + | 'unreachable-branch' + | 'unused-function' + | 'unused-import' + | 'unused-variable'; + +/** + * All recognised lint rule names, sorted alphabetically. + * + * This array is the canonical registry used on all surfaces. `rules` keys not + * found here emit a warning (see {@link LintResult.lint_warnings}) and lint + * continues — unknown names are ignored by the engine after the warning. + */ +export const LINT_RULE_NAMES: readonly LintRuleName[] = [ + 'duplicate-export', + 'duplicate-import', + 'empty-block', + 'legacy-interpolation', + 'redundant-else', + 'shadow-variable', + 'unreachable-branch', + 'unused-function', + 'unused-import', + 'unused-variable', +] as const; + /** All diagnostics for a single file in a lint result. */ export interface LintFileReport { /** Path or name of the linted file. */ @@ -189,13 +228,26 @@ export interface LintResult { * earlier diagnostics were dropped. Re-run after fixing to surface the rest. */ truncated: boolean; + /** + * Non-fatal warnings produced during linting — for example, unknown rule + * names passed in the `rules` option. Absent (not `[]`) when no warnings + * occurred. On the CLI surface, these warnings go to stderr instead. + */ + lint_warnings?: string[]; } /** Options for source-string lint operations. */ export interface LintOptions { /** Runtime variables injected into the check gate (not the lint rules). */ vars?: Record; - /** Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. */ + /** + * Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. + * + * Keys should be {@link LintRuleName} values. An unrecognised key emits a + * warning in {@link LintResult.lint_warnings} and lint continues — the + * unknown rule is silently ignored by the engine. `Record` is + * accepted for forward compatibility with future rule names. + */ rules?: Record; /** * Base directory for resolving `@import` directives in the source string. @@ -209,7 +261,14 @@ export interface LintOptions { export interface LintFileOptions { /** Runtime variables injected into the check gate (not the lint rules). */ vars?: Record; - /** Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. */ + /** + * Per-rule severity overrides, e.g. `{ 'shadow-variable': 'warn' }`. + * + * Keys should be {@link LintRuleName} values. An unrecognised key emits a + * warning in {@link LintResult.lint_warnings} and lint continues — the + * unknown rule is silently ignored by the engine. `Record` is + * accepted for forward compatibility with future rule names. + */ rules?: Record; } From c40714725b99fab0e4a5073c947e25bf664f874f Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 12:46:59 +0200 Subject: [PATCH 02/42] style: apply cargo fmt to resolve validation formatting violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/mds-cli/src/lint.rs | 6 +----- crates/mds-cli/tests/cli_lint.rs | 5 ++++- crates/mds-core/src/lib.rs | 7 +++---- crates/mds-core/src/lint/config.rs | 9 +++++---- crates/mds-core/src/lint/tier.rs | 15 ++++++++++----- crates/mds-core/tests/api_surface.rs | 23 ++++++++++++++++------- crates/mds-napi/src/lib.rs | 15 ++++++++++++--- crates/mds-python/src/lib.rs | 15 ++++++++++++--- crates/mds-wasm/src/lib.rs | 12 ++++++++++-- crates/mds-wasm/tests/web.rs | 23 +++++++++++++++-------- 10 files changed, 88 insertions(+), 42 deletions(-) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 50069310..0e078427 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -204,11 +204,7 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { if !quiet { if let Some(ref unknown) = mds::find_unknown_rule_names(&mds_config.lint.rules) { // Escape each name via safe_inline (WIRE per-field rule, spec §7.5). - let escaped: Vec = unknown - .names() - .iter() - .map(safe_inline) - .collect(); + let escaped: Vec = unknown.names().iter().map(safe_inline).collect(); // AC-224-2: include all recognised rule names (KNOWN_LINT_RULES is // sorted alphabetically — AC-224-3 determinism guaranteed). let recognised = mds::KNOWN_LINT_RULES.join(", "); diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 96afe5d6..540a3392 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3454,7 +3454,10 @@ fn unknown_rule_one_warning_per_invocation_not_per_file() { ); // AC-224-19: the warning must appear at most once (one per invocation, not per file). - let warning_count = stderr.lines().filter(|l| l.contains("unknown lint rule")).count(); + let warning_count = stderr + .lines() + .filter(|l| l.contains("unknown lint rule")) + .count(); assert_eq!( warning_count, 1, "AC-224-19: warning must appear exactly once per invocation, not once per file \ diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index ec876505..c2807c7f 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -60,15 +60,14 @@ pub(crate) mod value; pub use formatter::{format_str, format_str_named, format_str_with}; pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; +pub use lint::config::{ + find_unknown_rule_names, format_unknown_rule_names_warning, UnknownRuleNames, KNOWN_LINT_RULES, +}; pub use lint::{ fix, named_source_for_render, neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, TextEdit, }; -pub use lint::config::{ - find_unknown_rule_names, format_unknown_rule_names_warning, UnknownRuleNames, - KNOWN_LINT_RULES, -}; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, }; diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 155318d0..dd58c90f 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -80,9 +80,7 @@ impl UnknownRuleNames { /// assert_eq!(u.names(), &["no-such-rule".to_string()]); /// assert_eq!(KNOWN_LINT_RULES.len(), 10); /// ``` -pub fn find_unknown_rule_names( - rules: &HashMap, -) -> Option { +pub fn find_unknown_rule_names(rules: &HashMap) -> Option { let unknowns: Vec = rules .keys() .filter(|k| !KNOWN_LINT_RULES.contains(&k.as_str())) @@ -115,7 +113,10 @@ pub fn format_unknown_rule_names_warning(names: &[String]) -> String { // Structural precondition: names must be non-empty. // UnknownRuleNames guarantees this, but callers passing &[String] directly // should ensure the same. - assert!(!names.is_empty(), "format_unknown_rule_names_warning called with empty names"); + assert!( + !names.is_empty(), + "format_unknown_rule_names_warning called with empty names" + ); let recognised = KNOWN_LINT_RULES.join(", "); if names.len() == 1 { format!( diff --git a/crates/mds-core/src/lint/tier.rs b/crates/mds-core/src/lint/tier.rs index 978144f7..afb792ab 100644 --- a/crates/mds-core/src/lint/tier.rs +++ b/crates/mds-core/src/lint/tier.rs @@ -113,8 +113,8 @@ mod tests { /// for valid registered rules. #[test] fn registry_and_tier_table_are_bidirectionally_consistent() { - use super::super::rules::ALL_RULE_NAMES; use super::super::config::KNOWN_LINT_RULES; + use super::super::rules::ALL_RULE_NAMES; /// Canonical expected-tier table: (rule_name, expected_FixTier). /// Must be updated whenever a rule is added, removed, or re-tiered. @@ -137,15 +137,18 @@ mod tests { // Length pin: bump this when a new rule ships. assert_eq!( - EXPECTED.len(), 10, + EXPECTED.len(), + 10, "EXPECTED table length must equal the number of registered rules (10)" ); assert_eq!( - ALL_RULE_NAMES.len(), 10, + ALL_RULE_NAMES.len(), + 10, "ALL_RULE_NAMES length must equal the number of registered rules (10)" ); assert_eq!( - KNOWN_LINT_RULES.len(), 10, + KNOWN_LINT_RULES.len(), + 10, "KNOWN_LINT_RULES length must equal the number of registered rules (10)" ); @@ -154,7 +157,9 @@ mod tests { let got = rule_tier(name); let entry = EXPECTED.iter().find(|(n, _)| *n == name); let expected_tier = entry - .unwrap_or_else(|| panic!("rule {name:?} is in the registry but missing from EXPECTED")) + .unwrap_or_else(|| { + panic!("rule {name:?} is in the registry but missing from EXPECTED") + }) .1 .clone(); assert_eq!( diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 6cfee300..d2f03c44 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1145,7 +1145,11 @@ fn known_lint_rules_and_unknown_detection() { use mds::{find_unknown_rule_names, KNOWN_LINT_RULES}; // AC-224-7: exactly 10 rules. - assert_eq!(KNOWN_LINT_RULES.len(), 10, "KNOWN_LINT_RULES must have exactly 10 entries"); + assert_eq!( + KNOWN_LINT_RULES.len(), + 10, + "KNOWN_LINT_RULES must have exactly 10 entries" + ); // AC-224-7: exact sorted contents. let expected = [ @@ -1160,7 +1164,10 @@ fn known_lint_rules_and_unknown_detection() { "unused-import", "unused-variable", ]; - assert_eq!(KNOWN_LINT_RULES, &expected, "KNOWN_LINT_RULES must match the expected sorted list"); + assert_eq!( + KNOWN_LINT_RULES, &expected, + "KNOWN_LINT_RULES must match the expected sorted list" + ); // AC-224-8: every known rule is accepted by from_rules without unknowns. let all_known: HashMap = KNOWN_LINT_RULES @@ -1189,15 +1196,17 @@ fn known_lint_rules_and_unknown_detection() { let unknown = find_unknown_rule_names(&mixed).expect("should detect two unknown rules"); // Accessor returns names; struct literal construction is impossible (#[non_exhaustive]). let names = unknown.names(); - assert_eq!(names, &["another-bad".to_string(), "no-such-rule".to_string()], - "names must be sorted lexicographically"); + assert_eq!( + names, + &["another-bad".to_string(), "no-such-rule".to_string()], + "names must be sorted lexicographically" + ); assert_eq!(names.len(), 2); // Positive control (PF-013 / ADR-009): find_unknown_rule_names does NOT return None // for a single-unknown map. - let one_bad: HashMap = HashMap::from([ - ("no-such-rule".to_string(), Severity::Warn), - ]); + let one_bad: HashMap = + HashMap::from([("no-such-rule".to_string(), Severity::Warn)]); assert!( find_unknown_rule_names(&one_bad).is_some(), "single-unknown map must produce Some" diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index febcdd30..47b25c02 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -984,7 +984,10 @@ pub fn lint(env: Env, source: String, opts: Option) -> napi::Result) -> napi::Result Result { obj.insert( "lint_warnings".to_string(), serde_json::Value::Array( - lint_opts.lint_warnings.into_iter().map(serde_json::Value::String).collect(), + lint_opts + .lint_warnings + .into_iter() + .map(serde_json::Value::String) + .collect(), ), ); } @@ -932,7 +936,11 @@ pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result 0, "W-WARN-1: lint_warnings must be non-empty for an unknown rule name" ); - let w0 = warnings_arr.get(0).as_string().expect("W-WARN-1: lint_warnings[0] must be a string"); + let w0 = warnings_arr + .get(0) + .as_string() + .expect("W-WARN-1: lint_warnings[0] must be a string"); assert!( w0.contains("no-such-rule-xyzzy"), "W-WARN-1: lint_warnings[0] must name the unknown rule; got: {w0}" @@ -1235,7 +1238,10 @@ fn wasm_lint_virtual_unknown_rule_name_returns_lint_warnings() { warnings_arr.length() > 0, "W-WARN-3: lint_warnings must be non-empty for an unknown rule name" ); - let w0 = warnings_arr.get(0).as_string().expect("W-WARN-3: lint_warnings[0] must be a string"); + let w0 = warnings_arr + .get(0) + .as_string() + .expect("W-WARN-3: lint_warnings[0] must be a string"); assert!( w0.contains("no-such-rule-xyzzy"), "W-WARN-3: lint_warnings[0] must name the unknown rule; got: {w0}" @@ -1256,17 +1262,18 @@ fn wasm_lint_continues_with_unknown_rule_name() { let _ = files_arr.length(); // just confirm it's array-like; no panic = ok // truncated must be present and false. - let truncated = get_prop(&result, "truncated") - .as_bool() - .unwrap_or(true); - assert!(!truncated, "W-WARN-4: truncated must be false for a clean source"); + let truncated = get_prop(&result, "truncated").as_bool().unwrap_or(true); + assert!( + !truncated, + "W-WARN-4: truncated must be false for a clean source" + ); } #[wasm_bindgen_test] fn wasm_lint_unknown_rule_no_lint_warnings_absent_on_clean() { // W-WARN-2b (AC-224-1/D8): no options → no lint_warnings field at all. - let result = mds_wasm::lint("Hello!\n", JsValue::NULL) - .expect("W-WARN-2b: lint(NULL) must succeed"); + let result = + mds_wasm::lint("Hello!\n", JsValue::NULL).expect("W-WARN-2b: lint(NULL) must succeed"); let lint_warnings = get_prop(&result, "lint_warnings"); assert!( lint_warnings.is_undefined(), From cb980e4d9bf2ba15a75b673bc5c39d58156d953d Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 12:57:35 +0200 Subject: [PATCH 03/42] style: extract inject_lint_warnings helper; fix misleading docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/mds-core/src/lint/config.rs | 6 +-- crates/mds-napi/src/lib.rs | 83 ++++++++++++------------------ crates/mds-python/src/lib.rs | 79 +++++++++++----------------- crates/mds-wasm/src/lib.rs | 56 +++++++++----------- 4 files changed, 92 insertions(+), 132 deletions(-) diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index dd58c90f..979d4caf 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -101,9 +101,9 @@ pub fn find_unknown_rule_names(rules: &HashMap) -> Option) -> napi::Result< // ── Lint options parsing ────────────────────────────────────────────────────── +/// Inject `lint_warnings` into a canonical JSON result when warnings are present. +/// +/// D8 (AC-224-1): the napi binding surfaces unknown-rule warnings by adding a +/// `lint_warnings: string[]` field to the returned JSON object. This helper +/// consolidates that logic across `lint`, `lintFile`, and `lintVirtual`. +fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> serde_json::Value { + if !warnings.is_empty() { + if let Some(obj) = json.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + warnings + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + } + json +} + /// Extract and validate the `rules` option: `Record` → `(mds::LintConfig, Vec)`. /// /// Returns the default config (all rules at built-in defaults) when `rules` is absent, @@ -976,23 +998,10 @@ pub fn lint(env: Env, source: String, opts: Option) -> napi::Result) -> napi::Result, modules: &Bound<'_, PyAny>) -> PyResult) -> serde_json::Value { + if !warnings.is_empty() { + if let Some(obj) = json.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + warnings + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + } + json +} + /// Parse and validate the `rules` keyword argument into a [`mds::LintConfig`] and warning list. /// /// `None`/absent → default config (no per-rule overrides). A non-mapping value → @@ -1527,22 +1549,9 @@ fn lint( let result = run_catching(py, move || { mds::lint_str_with(&source, base_path.as_deref(), vars, &lint_config) })?; - let mut value = result.to_canonical_json(); - // D8: inject lint_warnings into the JSON when unknown rule names were detected. - if !lint_warnings.is_empty() { - if let Some(obj) = value.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array( - lint_warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } - Ok(LintResult { value }) + Ok(LintResult { + value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + }) } /// Lint an MDS template file (`path` is a str or `os.PathLike`). @@ -1560,22 +1569,9 @@ fn lint_file( let vars = extract_vars(py, vars.as_ref())?; let (lint_config, lint_warnings) = extract_rules(py, rules.as_ref())?; let result = run_catching(py, move || mds::lint(&path, vars, &lint_config))?; - let mut value = result.to_canonical_json(); - // D8: inject lint_warnings into the JSON when unknown rule names were detected. - if !lint_warnings.is_empty() { - if let Some(obj) = value.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array( - lint_warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } - Ok(LintResult { value }) + Ok(LintResult { + value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + }) } /// Lint a module from an in-memory virtual filesystem. @@ -1597,22 +1593,9 @@ fn lint_virtual( let result = run_catching(py, move || { mds::lint_virtual(modules, &entry, vars, &lint_config) })?; - let mut value = result.to_canonical_json(); - // D8: inject lint_warnings into the JSON when unknown rule names were detected. - if !lint_warnings.is_empty() { - if let Some(obj) = value.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array( - lint_warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } - Ok(LintResult { value }) + Ok(LintResult { + value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + }) } // ── Module ────────────────────────────────────────────────────────────────────── diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index 07548bfd..57097f5d 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -445,6 +445,28 @@ fn parse_options(options: JsValue) -> Result { // ── Lint options ────────────────────────────────────────────────────────────── +/// Inject `lint_warnings` into a canonical JSON result when warnings are present. +/// +/// D8 (AC-224-1): the WASM binding surfaces unknown-rule warnings by adding a +/// `lint_warnings: string[]` field to the returned JSON object. This helper +/// consolidates that logic across `lint` and `lintVirtual`. +fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> serde_json::Value { + if !warnings.is_empty() { + if let Some(obj) = json.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array( + warnings + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + } + json +} + /// Parsed options for the `lint` and `lint_virtual` functions. /// /// Extends the standard options with a `rules` field — absent in `compile`/`check`. @@ -854,22 +876,7 @@ pub fn lint(source: &str, options: JsValue) -> Result { ) .map_err(mds_error_to_js)?; - // D8 (AC-224-1): surface unknown-rule warnings on the WASM binding channel. - let mut json = result.to_canonical_json(); - if !lint_opts.lint_warnings.is_empty() { - if let Some(obj) = json.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array( - lint_opts - .lint_warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } + let json = inject_lint_warnings(result.to_canonical_json(), lint_opts.lint_warnings); to_js(&json) })) } @@ -929,22 +936,7 @@ pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result Date: Fri, 14 Aug 2026 13:25:47 +0200 Subject: [PATCH 04/42] fix: address self-review issues 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 instead of Vec for the single warning threaded through the binding option parsers. CI builds with Binaryen v129, a different toolchain - the absolute number there will differ. --- .devflow/features/mds-lint/KNOWLEDGE.md | 13 +- CHANGELOG.md | 78 ++-- crates/mds-cli/src/lint.rs | 47 ++- crates/mds-cli/tests/cli_lint.rs | 338 ++++++++++++++++++ crates/mds-cli/tests/print_discipline.rs | 16 - crates/mds-core/src/lint/config.rs | 158 ++++++-- crates/mds-napi/src/lib.rs | 44 ++- .../mds-python/python/mdscript/_mdscript.pyi | 8 + crates/mds-python/src/lib.rs | 27 +- crates/mds-python/tests/typecheck_sample.py | 4 + crates/mds-wasm/src/lib.rs | 33 +- crates/mds-wasm/tests/web.rs | 16 +- packages/mds/__test__/lint.spec.mjs | 128 ++++++- packages/mds/src/node.ts | 8 +- packages/mds/src/types.ts | 6 +- spec.md | 2 +- 16 files changed, 757 insertions(+), 169 deletions(-) diff --git a/.devflow/features/mds-lint/KNOWLEDGE.md b/.devflow/features/mds-lint/KNOWLEDGE.md index 2c7bc283..c0f53e17 100644 --- a/.devflow/features/mds-lint/KNOWLEDGE.md +++ b/.devflow/features/mds-lint/KNOWLEDGE.md @@ -107,7 +107,12 @@ The `fixable` flag in the canonical JSON output is computed as `(fix_removals.is { "lint": { "rules": { "unused-variable": "off", "shadow-variable": "warn" } } } ``` -**Unknown rule NAMEs** → warn-and-ignore at CLI (forward compat). +**Unknown rule NAMEs** → warned about on every surface, then ignored; the config still +loads and lint continues, and the exit code does not move (forward compat: a config naming +a rule from a newer release must not break an older binary). The CLI writes the warning to +stderr (suppressed by `--quiet`); napi/WASM/Python return it in `lint_warnings`. +The registry is `mds::KNOWN_LINT_RULES`, derived from each rule module's own `RULE` const; +detection is `mds::find_unknown_rule_names`. (#224) **Unknown severity VALUES** → hard parse error → exit 2 (closed enum, no sensible fallback). `LintConfig` lives in `mds-core` (not mds-cli). The CLI `LintCliConfig` from `build.rs` converts to it via `into_core_config()`. @@ -497,7 +502,9 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit) **shadow-variable is info AND default-off**: Only fires when explicitly configured. `Info` findings never contribute to the exit code. -**Unknown rule NAMES vs unknown severity VALUES behave differently**: An unknown rule name in `mds.json` is warned about and ignored. An unknown severity value fails loudly with a serde deserialization error (exits 2). +**Unknown rule NAMES vs unknown severity VALUES behave differently**: An unknown rule name is warned about and then ignored — on every surface, not just the CLI — and the run continues with an unchanged exit code and an unchanged JSON envelope. An unknown severity value fails loudly with a serde deserialization error (exits 2). The asymmetry is deliberate: severities are a closed set, rule names grow every release. + +**`.devflow/features/*/KNOWLEDGE.md` is TRACKED, not gitignored**: `.gitignore` ignores `.devflow/*` but re-includes `!.devflow/features/*/KNOWLEDGE.md` (lines 64-70). A doc sweep that excludes `.devflow` wholesale will miss this file, and the source-hygiene gate does scan it. **D2 mechanical ripple in resolver.rs**: The `..` in the three `ExportDirective` match arms in `resolver.rs` is intentional — it acknowledges the new `offset` field without reading it. @@ -546,7 +553,7 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit) - `crates/mds-core/src/lint/config.rs` — `LintConfig` (lives in mds-core; CLI converts to it) - `crates/mds-core/src/ast.rs` — `ElseifBranch { offset }`, `IfBlock { else_offset, end_offset }`, `ForBlock/DefineBlock { end_offset }` - `crates/mds-core/src/lint/rules/` — 10 rule modules + `structural_eq.rs` -- `crates/mds-cli/src/lint.rs` — CLI subcommand; `render_diag_human` (HUMAN for message/help; filename+source via `named_source_for_render`; all status lines via `safe_path`); `set_diag_display_path`, `LintDirCtx`, `KNOWN_RULES` +- `crates/mds-cli/src/lint.rs` — CLI subcommand; `render_diag_human` (HUMAN for message/help; filename+source via `named_source_for_render`; all status lines via `safe_path`); `set_diag_display_path`, `LintDirCtx`; the rule-name list lives in `mds::KNOWN_LINT_RULES`, not in this crate (#224) - `crates/mds-cli/src/output.rs` — `atomic_write_file`; `eprint_error` (single CLI stderr choke-point, wraps in `SanitizedReport`); `SanitizedReport` / `SanitizedNode` / `MAX_AUX_DEPTH`; `render_error_sanitized` (private, plain `format!("{report:?}")` on sanitized wrapper); `eprint_warning` (HUMAN, new); `safe_path` / `safe_file_display` / `safe_inline` (all WIRE, new); `preview_text_for` (TTY-gated source neutralization for `--diff`); `render_unified_diff` / `colorize_unified_diff` - `crates/mds-cli/src/build.rs` — `LintCliConfig` struct, `into_core_config()`, `MdsConfig.lint` field - `crates/mds-cli/src/watch.rs` — all 11 error prints route through `eprint_error`; lifecycle status lines route through `safe_path` / `safe_inline` / `eprint_warning` diff --git a/CHANGELOG.md b/CHANGELOG.md index de470c93..f11313ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,49 +181,6 @@ name rather than the `@import` keyword, and `span.length` is the name's length instead of a constant 7. Alias imports (`@import "path" as alias`) are unchanged — their span still covers the `@import` keyword. -#### Unknown lint rule names now emit a warning instead of being silently ignored (#224) - -Previously, an unrecognised rule name in `mds.json`'s `lint.rules` object (or the -`rules` option on the binding surfaces) was silently accepted — the rule had no effect -and there was no signal that a key was misconfigured. - -**New behaviour:** an unknown rule name emits a warning and lint continues -(exit codes are unchanged). This surfaces typos and forward-compat configs without -hard-failing on rule names added in a newer binary. - -- **CLI**: the warning goes to **stderr** (never stdout), so `--format json` output - remains valid parseable JSON. `--quiet` suppresses the warning. -- **napi / WASM / Python binding surfaces**: the warning is returned in - `lint_warnings: string[]` (absent when empty) on the returned lint result object. - -Exact format: `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring` - -Unknown **severity values** (not rule names) continue to hard-fail with -`mds::invalid_options` — the asymmetry is intentional (severities are a closed set; -rule names grow with each release). - -#### New exports: `LINT_RULE_NAMES`, `LintRuleName` (TypeScript / `@mdscript/mds`) - -The canonical list of recognised lint rule names is now exported as: -- `LINT_RULE_NAMES: readonly LintRuleName[]` — the alphabetically sorted array -- `LintRuleName` — a string union type of all 10 rule name literals - -`LintResult.lint_warnings?: string[]` is added to the TypeScript interface. - -#### New core API: `KNOWN_LINT_RULES`, `find_unknown_rule_names`, `UnknownRuleNames`, `format_unknown_rule_names_warning` - -`mds-core` now exports: -- `KNOWN_LINT_RULES: &[&str]` — the canonical slice of rule names -- `find_unknown_rule_names(rules: &HashMap) -> Option` — - returns `None` when all names are known, `Some` with a sorted `UnknownRuleNames` when not -- `format_unknown_rule_names_warning(names: &[String]) -> String` — formats the warning string -- `UnknownRuleNames` — a `#[non_exhaustive]` struct with a `names() -> &[String]` accessor - -#### New `LintResult.lint_warnings` getter on Python `LintResult` - -`mds-python`'s `LintResult` gains a `.lint_warnings` property returning `list[str]` -(empty when no warnings occurred). Existing callers are not affected. - #### New `fix_edits` field on `LintDiagnostic` `LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; @@ -572,6 +529,22 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. ### Added +- **Lint rule-name registry, exposed on every surface (#224).** The recognised rule + names now have one source of truth, derived from each rule module's own name constant. + - `mds-core`: `KNOWN_LINT_RULES: &[&str]` (the canonical slice), + `find_unknown_rule_names(&HashMap) -> Option` + (`None` when every name is recognised), `UnknownRuleNames` (a `#[non_exhaustive]` + report with a `names() -> &[String]` accessor, always non-empty and sorted), and + `format_unknown_rule_names_warning(&UnknownRuleNames) -> String`. The formatter takes + the report type rather than a slice so its non-empty precondition is structural — it + has no panic path — and it WIRE-escapes each name before interpolating it. + - `@mdscript/mds` (Node entry point): `LINT_RULE_NAMES: readonly LintRuleName[]` and + the `LintRuleName` string-union type. The browser entry point does not export them + yet — it has no lint API to configure. + - TypeScript `LintResult` gains `lint_warnings?: string[]`. + - Python `LintResult` gains a `.lint_warnings` property returning `list[str]` (empty + when there is nothing to report); the type stub is updated to match. + - **`--set-string KEY=VALUE`** CLI flag for `mds build`, `mds check`, and `mds watch`. Sets a variable as a string without type coercion — useful when a value is numeric-looking but must stay a string (e.g. `mds build t.mds --set-string id=007`). @@ -798,6 +771,25 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. ### Changed +- **Unknown lint rule names now emit a warning instead of being silently ignored + (#224).** Previously an unrecognised rule name in `mds.json`'s `lint.rules` object + (or in the `rules` option on a binding surface) was silently accepted: the rule had + no effect and nothing signalled that the key was misconfigured. Now the unknown name + is reported and linting continues — **exit codes are unchanged**, the JSON envelope on + stdout is unchanged, and the rule is still not enforced (it does not exist). This + surfaces typos without hard-failing a config that names a rule added in a newer + release. + - **CLI**: the warning goes to **stderr**, never stdout, so `mds lint --format json` + still writes a single valid JSON document. `--quiet` suppresses it. Format: + `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring`. + - **napi / WASM / Python**: the warning is returned as `lint_warnings: string[]` on + the lint result, a key that is absent when there is nothing to report. + - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, `check`, + `fmt` and `watch` load the same `mds.json` and are byte-unchanged — an accepted + asymmetry, not an oversight. + - Unknown **severity values** continue to hard-fail with `mds::invalid_options`. The + asymmetry is deliberate: severities are a closed set, rule names grow every release. + - **napi and Python `compileFile` / `compile_file` now emit root-relative `sources[]`** in Source Map v3 output. Previously these surfaces emitted the absolute filesystem path as `sources[0]` (e.g. `/home/user/project/src/foo.mds`); diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 0e078427..c35c94cc 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -197,34 +197,43 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). // A JSON object key is never legitimately multi-line; routing through // `safe_inline` closes CWE-117 on the newline + forged-line vector. - // This keeps the value inside `eprint_warning`'s arguments, which - // `print_discipline.rs:27-60` already machine-checks (AC-224-6). + // + // AC-224-6: every interpolation below is a WHOLE-EXPRESSION `safe_inline` + // call sitting directly inside `eprint_warning`'s `format!`, which is the + // one shape `print_discipline.rs`'s trace accepts without an allowlist + // entry. Do not hoist the assembled message into a local: the trace cannot + // follow an `if`/`else` initialiser, and the escape would silently stop + // being machine-checked (that is the PF-004 drift this guard exists for). + // `mds::KNOWN_LINT_RULES` is a slice of compile-time literals and needs no + // escaping — it is passed through `safe_inline` anyway so the guard can see + // the whole `format!` is clean without an exemption. // // AC-224-22: suppress under --quiet (coordination point with PR4 D4). if !quiet { - if let Some(ref unknown) = mds::find_unknown_rule_names(&mds_config.lint.rules) { - // Escape each name via safe_inline (WIRE per-field rule, spec §7.5). - let escaped: Vec = unknown.names().iter().map(safe_inline).collect(); - // AC-224-2: include all recognised rule names (KNOWN_LINT_RULES is - // sorted alphabetically — AC-224-3 determinism guaranteed). + if let Some(unknown) = mds::find_unknown_rule_names(&mds_config.lint.rules) { + // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. + let names = unknown.names(); let recognised = mds::KNOWN_LINT_RULES.join(", "); - let warning = if escaped.len() == 1 { - format!( + if let [only] = names { + eprint_warning(&format!( "warning: unknown lint rule '{}' in mds.json; \ recognised rules are: {}; ignoring", - escaped[0], recognised - ) + safe_inline(only), + safe_inline(&recognised) + )); } else { - let quoted: Vec = - escaped.iter().map(|n| format!("'{n}'")).collect(); - format!( + let listed = names + .iter() + .map(|n| format!("'{n}'")) + .collect::>() + .join(", "); + eprint_warning(&format!( "warning: unknown lint rules in mds.json: {}; \ recognised rules are: {}; ignoring", - quoted.join(", "), - recognised - ) - }; - eprint_warning(&warning); + safe_inline(&listed), + safe_inline(&recognised) + )); + } } } Ok(mds_config.lint.into_core_config()) diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 540a3392..5ad33748 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3464,3 +3464,341 @@ fn unknown_rule_one_warning_per_invocation_not_per_file() { (got {warning_count}); got stderr: {stderr}" ); } + +/// Populate `dir` with a fixed three-file tree — one clean, two with real findings — +/// so the JSON envelope under test carries actual `files[]` entries rather than an +/// empty array (an all-clean tree would make the comparison below near-vacuous). +fn write_mixed_lint_tree(dir: &std::path::Path) { + std::fs::write(dir.join("clean.mds"), "Hello!\n").unwrap(); + std::fs::write( + dir.join("unused.mds"), + "---\nunused_key: value\n---\nHello!\n", + ) + .unwrap(); + std::fs::write( + dir.join("dup.mds"), + "---\nanother_unused: v\n---\nHi there!\n", + ) + .unwrap(); +} + +/// Write an `mds.json` naming `rules` verbatim. +fn write_rules_config(dir: &std::path::Path, rules: serde_json::Value) { + let config = serde_json::json!({ "lint": { "rules": rules } }); + std::fs::write( + dir.join("mds.json"), + serde_json::to_string(&config).unwrap(), + ) + .unwrap(); +} + +/// AC-224-10 (NEGATIVE, envelope frozen): an unknown rule name changes the `--format json` +/// stdout by **zero bytes**. +/// +/// The two runs differ only by the presence of `no-such-rule-xyzzy` in `mds.json`. Their +/// stdout buffers are compared byte-for-byte, and the top-level key set is compared as an +/// EXACT set (not `contains`), so a new top-level key or a `files[]` entry lacking +/// `diagnostics` fails. Exit codes must match too. +/// +/// Non-vacuity (PF-013 / ADR-009): the same run asserts the warning IS on stderr and that +/// the envelope actually carries `files[]` entries — a byte-identical comparison of two +/// empty or two error envelopes would prove nothing. +#[test] +fn unknown_rule_json_stdout_is_byte_identical_to_run_without_it() { + let with_dir = tempfile::tempdir().unwrap(); + write_mixed_lint_tree(with_dir.path()); + write_rules_config( + with_dir.path(), + serde_json::json!({ "unused-variable": "warn", "no-such-rule-xyzzy": "warn" }), + ); + + let without_dir = tempfile::tempdir().unwrap(); + write_mixed_lint_tree(without_dir.path()); + write_rules_config( + without_dir.path(), + serde_json::json!({ "unused-variable": "warn" }), + ); + + let run = |dir: &std::path::Path| { + mds_bin() + .arg("lint") + .arg(dir) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() + }; + let with = run(with_dir.path()); + let without = run(without_dir.path()); + + // Non-vacuity: the warning fired in the first run and not in the second. + let with_stderr = String::from_utf8_lossy(&with.stderr); + assert!( + with_stderr.contains("unknown lint rule") && with_stderr.contains("no-such-rule-xyzzy"), + "non-vacuity: the unknown-rule warning must fire on stderr; got: {with_stderr}" + ); + let without_stderr = String::from_utf8_lossy(&without.stderr); + assert!( + !without_stderr.contains("unknown lint rule"), + "control run must not warn; got: {without_stderr}" + ); + + // AC-224-10: stdout is byte-identical, and the exit code does not move. + assert_eq!( + with.stdout, + without.stdout, + "AC-224-10: stdout must be byte-identical with and without the unknown rule name;\n\ + with: {}\nwithout: {}", + String::from_utf8_lossy(&with.stdout), + String::from_utf8_lossy(&without.stdout) + ); + assert_eq!( + with.status.code(), + without.status.code(), + "AC-224-13: the exit code must not move because of an unknown rule name" + ); + + // AC-224-10: exact top-level key set — not `contains`. + let parsed: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&with.stdout).trim()) + .expect("stdout must be valid JSON"); + let obj = parsed.as_object().expect("envelope must be a JSON object"); + let mut top_keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + top_keys.sort_unstable(); + assert_eq!( + top_keys, + ["files", "truncated", "version"], + "AC-224-10: the top-level key set must be exactly {{files, truncated, version}}; \ + got: {parsed}" + ); + assert_eq!(parsed["version"].as_u64(), Some(1)); + + // Non-vacuity: the envelope really does carry file entries, and each has the + // `diagnostics` key with no `error` key. + let files = parsed["files"].as_array().expect("files must be an array"); + assert!( + !files.is_empty(), + "non-vacuity: the fixture tree must produce at least one files[] entry; got: {parsed}" + ); + for entry in files { + let e = entry.as_object().expect("files[] entry must be an object"); + assert!( + e.contains_key("diagnostics"), + "AC-224-10: every files[] entry must carry a diagnostics key; got: {entry}" + ); + assert!( + !e.contains_key("error"), + "AC-224-10: no files[] entry may carry an error key; got: {entry}" + ); + } +} + +/// AC-224-21: `--format json` stdout carries no warning text at all — including the +/// literal substring `warning`, which the bare `!stdout.contains("unknown")` check above +/// would not catch. Paired positive control: the warning IS on stderr in the same run. +#[test] +fn unknown_rule_json_stdout_contains_no_warning_text() { + let dir = tempfile::tempdir().unwrap(); + write_mixed_lint_tree(dir.path()); + write_rules_config( + dir.path(), + serde_json::json!({ "no-such-rule-xyzzy": "warn" }), + ); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // Positive control first: without this, every negative below is vacuous. + assert!( + stderr.contains("no-such-rule-xyzzy") && stderr.contains("recognised rules are"), + "positive control: the warning must be on stderr; got: {stderr}" + ); + + for needle in [ + "no-such-rule-xyzzy", + "recognised rules are", + "warning", + "ignoring", + ] { + assert!( + !stdout.contains(needle), + "AC-224-21: stdout must not contain {needle:?}; got: {stdout}" + ); + } + serde_json::from_str::(stdout.trim()) + .expect("AC-224-21: stdout must parse as a single JSON document"); +} + +/// AC-224-22: the pre-subcommand global form `mds --quiet lint ` suppresses the +/// warning exactly like `mds lint --quiet `, and neither moves the exit code. +/// +/// Paired positive control (PF-013): the same tree without `--quiet` DOES warn. +#[test] +fn unknown_rule_warning_suppressed_by_global_quiet_form() { + let dir = tempfile::tempdir().unwrap(); + write_mixed_lint_tree(dir.path()); + write_rules_config( + dir.path(), + serde_json::json!({ "no-such-rule-xyzzy": "warn" }), + ); + + let loud = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("unknown lint rule"), + "positive control: the non-quiet run must warn" + ); + + for args in [vec!["lint", "--quiet"], vec!["--quiet", "lint"]] { + let mut cmd = mds_bin(); + for a in &args { + cmd.arg(a); + } + let out = cmd + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule") && !stderr.contains("no-such-rule-xyzzy"), + "AC-224-22: `mds {}` must suppress the unknown-rule warning; got: {stderr}", + args.join(" ") + ); + assert_eq!( + out.status.code(), + loud.status.code(), + "AC-224-22: --quiet must not move the exit code (form: {})", + args.join(" ") + ); + } +} + +/// AC-224-12 (NEGATIVE / fix behaviour unchanged): an unknown rule name changes nothing +/// about `--fix`, `--fix --check` or `--fix --diff`. +/// +/// Two identical trees are built; only one names `no-such-rule-xyzzy` alongside the same +/// valid rules. For each of the three invocations the exit code, the post-run file bytes +/// and the stdout are compared between the trees. +/// +/// Non-vacuity (PF-013): the run asserts a fix was ACTUALLY applied (the file bytes moved) +/// and that the warning fired in the unknown-rule tree — a parity assertion between two +/// trees where nothing happened would prove nothing. +#[test] +fn unknown_rule_does_not_change_fix_behaviour() { + /// Build a tree with one auto-fixable (Tier A) duplicate-export violation. + fn build_tree(rules: serde_json::Value) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_error.mds"); + fs::copy(fixture("lint_error.mds"), &target).unwrap(); + write_rules_config(dir.path(), rules); + (dir, target) + } + + let valid_rules = serde_json::json!({ "duplicate-export": "error" }); + let mut with_rules = valid_rules.clone(); + with_rules["no-such-rule-xyzzy"] = serde_json::json!("warn"); + + // ── --fix --check: must not write, must report pending fixes ──────────── + let (with_dir, with_target) = build_tree(with_rules.clone()); + let (without_dir, without_target) = build_tree(valid_rules.clone()); + let with_before = fs::read(&with_target).unwrap(); + + let with_check = lint_path(with_dir.path(), &["--fix", "--check"]); + let without_check = lint_path(without_dir.path(), &["--fix", "--check"]); + assert!( + String::from_utf8_lossy(&with_check.stderr).contains("unknown lint rule"), + "non-vacuity: the unknown-rule tree must warn" + ); + assert_eq!( + with_check.status.code(), + without_check.status.code(), + "AC-224-12: --fix --check exit code must not move" + ); + assert_eq!( + fs::read(&with_target).unwrap(), + with_before, + "AC-224-12: --fix --check must not write" + ); + + // ── --fix --diff: identical diff output, still no write ───────────────── + // + // The `---`/`+++` headers name the absolute file path, which necessarily differs + // between the two tempdirs. Normalise each tree's own root to a placeholder so the + // comparison is about the diff CONTENT — the only thing an unknown rule name could + // plausibly change — rather than about tempdir naming. + let with_diff = lint_path(with_dir.path(), &["--fix", "--diff"]); + let without_diff = lint_path(without_dir.path(), &["--fix", "--diff"]); + let normalize = |bytes: &[u8], root: &std::path::Path| { + String::from_utf8_lossy(bytes).replace(root.to_string_lossy().as_ref(), "") + }; + let with_diff_text = normalize(&with_diff.stdout, with_dir.path()); + let without_diff_text = normalize(&without_diff.stdout, without_dir.path()); + assert!( + with_diff_text.contains("@export greet"), + "non-vacuity: --fix --diff must actually render a hunk; got: {with_diff_text}" + ); + assert_eq!( + with_diff_text, without_diff_text, + "AC-224-12: --fix --diff output must be identical once the tempdir root is normalised" + ); + assert_eq!( + with_diff.status.code(), + without_diff.status.code(), + "AC-224-12: --fix --diff exit code must not move" + ); + assert_eq!( + fs::read(&with_target).unwrap(), + with_before, + "AC-224-12: --fix --diff must not write" + ); + + // ── --fix: identical result bytes and exit code, and a fix really happened ── + let with_fix = lint_path(with_dir.path(), &["--fix"]); + let without_fix = lint_path(without_dir.path(), &["--fix"]); + assert_eq!( + with_fix.status.code(), + without_fix.status.code(), + "AC-224-12: --fix exit code must not move" + ); + let with_after = fs::read(&with_target).unwrap(); + let without_after = fs::read(&without_target).unwrap(); + assert_eq!( + with_after, without_after, + "AC-224-12: the fixed file bytes must be identical with and without the unknown rule" + ); + assert_ne!( + with_after, with_before, + "non-vacuity: a fix must actually have been applied, or the parity above is empty" + ); + + // No stray artefacts left behind in either tree. + for dir in [with_dir.path(), without_dir.path()] { + for entry in fs::read_dir(dir).unwrap() { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + assert!( + name == "lint_error.mds" || name == "mds.json", + "AC-224-12: --fix must leave no temp or backup artefact; found {name}" + ); + } + } +} diff --git a/crates/mds-cli/tests/print_discipline.rs b/crates/mds-cli/tests/print_discipline.rs index 2cd641dd..065476e7 100644 --- a/crates/mds-cli/tests/print_discipline.rs +++ b/crates/mds-cli/tests/print_discipline.rs @@ -385,22 +385,6 @@ const ALLOWED_UNTRACED_HELPER_ARGS: &[(&str, &str, &str)] = &[ guard checks it. Two entries — this one and `build.rs`'s — cover all five live \ bare-`w` sites, because the list is keyed by (file, expression).", ), - ( - "lint.rs", - "&warning", - "The `warning` variable is pre-assembled in `load_lint_config` using \ - `safe_inline(name)` on every unknown rule name (WIRE per-field escaping of \ - hostile characters from `mds.json`) and `mds::KNOWN_LINT_RULES.join(\", \")` \ - on compile-time-constant string literals from the registry. No user-controlled \ - content enters `warning` unescaped. The lexical guard cannot trace across the \ - `format!` call that assembles `warning` from those pre-escaped pieces, so this \ - entry is the human review anchor for WIRE-safety-at-construction. End-to-end \ - verified by T-ESC-RULE-1 in `security.rs`, which proves that a hostile \ - `mds.json` rule name (C0, bidi, and embedded newlines) reaches stderr with all \ - control bytes WIRE-escaped and no forged status line possible. The recognised-rules \ - list (`mds::KNOWN_LINT_RULES`) is a `&[&'static str]` slice of compiler-constant \ - literals with no user-derived content — no escaping is needed or applied there.", - ), ]; // ── The guard ───────────────────────────────────────────────────────────────── diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 979d4caf..552525c9 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -43,7 +43,11 @@ pub struct UnknownRuleNames { impl UnknownRuleNames { fn new(mut names: Vec) -> Self { - names.sort(); + // `sort_unstable` rather than `sort`: the names come from `HashMap` keys and are + // therefore distinct, so stability is unobservable — and the stable merge sort + // monomorphised for `String` is measurably larger in the WASM binary, which runs + // against a hard size guard (AC-224-18). + names.sort_unstable(); UnknownRuleNames { names } } @@ -93,44 +97,68 @@ pub fn find_unknown_rule_names(rules: &HashMap) -> Option String { - // Structural precondition: names must be non-empty. - // UnknownRuleNames guarantees this, but callers passing &[String] directly - // should ensure the same. - assert!( - !names.is_empty(), - "format_unknown_rule_names_warning called with empty names" - ); - let recognised = KNOWN_LINT_RULES.join(", "); - if names.len() == 1 { - format!( - "unknown lint rule '{}'; recognised rules are: {}; ignoring", - names[0], recognised - ) +pub fn format_unknown_rule_names_warning(unknown: &UnknownRuleNames) -> String { + // Assembled with `push_str` rather than `format!` + `join`. This function is + // reachable from the WASM binary, which runs against a hard 850,000-byte guard + // (AC-224-18); `[&str]::join` and `Vec::join` each monomorphise into + // kilobytes there, and nothing else in the WASM surface pulls them in. The output + // is byte-identical to the `format!` form. + let names = unknown.names(); + let mut out = String::with_capacity(256); + if let [only] = names { + out.push_str("unknown lint rule '"); + out.push_str(&super::sanitize_control_chars_wire(only)); + out.push('\''); } else { - let quoted: Vec = names.iter().map(|n| format!("'{n}'")).collect(); - format!( - "unknown lint rules: {}; recognised rules are: {}; ignoring", - quoted.join(", "), - recognised - ) + out.push_str("unknown lint rules: "); + for (i, name) in names.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push('\''); + out.push_str(&super::sanitize_control_chars_wire(name)); + out.push('\''); + } } + out.push_str("; recognised rules are: "); + for (i, rule) in KNOWN_LINT_RULES.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(rule); + } + out.push_str("; ignoring"); + out } /// Per-rule severity override configuration. @@ -199,3 +227,75 @@ impl LintConfig { self.rules.get(rule) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn unknowns(names: &[&str]) -> UnknownRuleNames { + let map: HashMap = names + .iter() + .map(|n| ((*n).to_string(), Severity::Warn)) + .collect(); + find_unknown_rule_names(&map).expect("names must be unknown") + } + + /// AC-224-2 / AC-224-3: singular form names the rule and every recognised rule. + #[test] + fn warning_singular_names_rule_and_full_registry() { + let msg = format_unknown_rule_names_warning(&unknowns(&["no-such-rule"])); + assert_eq!( + msg, + format!( + "unknown lint rule 'no-such-rule'; recognised rules are: {}; ignoring", + KNOWN_LINT_RULES.join(", ") + ) + ); + for rule in KNOWN_LINT_RULES { + assert!(msg.contains(rule), "message must list {rule}; got: {msg}"); + } + } + + /// AC-224-3: multiple offenders are listed in sorted order, not map order. + #[test] + fn warning_plural_sorts_offenders() { + // Inserted zzz-first; the output must still be aaa-first. + let msg = format_unknown_rule_names_warning(&unknowns(&["zzz-bad", "aaa-bad", "mmm-bad"])); + assert!( + msg.starts_with("unknown lint rules: 'aaa-bad', 'mmm-bad', 'zzz-bad'; "), + "offenders must be sorted lexicographically; got: {msg}" + ); + } + + /// AC-224-4 / ADR-008: a hostile rule name is WIRE-escaped by the formatter, so + /// the string handed to a binding consumer carries no raw control byte. + /// + /// PF-018: the hostile characters are built from Rust `\u{..}` escapes at + /// runtime — never authored as literal bytes in this file. + #[test] + fn warning_wire_escapes_hostile_rule_name() { + let hostile = format!( + "{}[31mEVIL{}X\nClean: totally-real.mds", + '\u{1b}', '\u{202e}' + ); + let msg = format_unknown_rule_names_warning(&unknowns(&[&hostile])); + + // Negative: no raw control byte survives. + assert!( + !msg.chars().any(|c| c.is_control()), + "no raw control character may survive escaping; got: {msg:?}" + ); + // Positive control (PF-013): the escaped literals are actually present, so the + // negative assertion above cannot pass because the name never reached the message. + for escaped in ["\\u001B", "\\u202E", "\\u000A"] { + assert!( + msg.contains(escaped), + "{escaped} must appear in the escaped warning; got: {msg}" + ); + } + assert!( + msg.contains("EVIL"), + "non-vacuity: the rule name must reach the message; got: {msg}" + ); + } +} diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index d6988699..93c58fb4 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -788,17 +788,17 @@ pub fn check_file(env: Env, path: String, opts: Option) -> napi::Result< /// D8 (AC-224-1): the napi binding surfaces unknown-rule warnings by adding a /// `lint_warnings: string[]` field to the returned JSON object. This helper /// consolidates that logic across `lint`, `lintFile`, and `lintVirtual`. -fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> serde_json::Value { - if !warnings.is_empty() { +fn inject_lint_warnings(mut json: serde_json::Value, warning: Option) -> serde_json::Value { + // `Option`, not `Vec`: there is exactly one warning message today + // (unknown rule names are reported as a single sentence), so a vector would be + // over-general plumbing. The JSON shape is still `string[]` — the array is built + // here — so adding a second warning kind later is a change to this function, not to + // the wire contract. + if let Some(w) = warning { if let Some(obj) = json.as_object_mut() { obj.insert( "lint_warnings".to_string(), - serde_json::Value::Array( - warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), + serde_json::Value::Array(vec![serde_json::Value::String(w)]), ); } } @@ -814,14 +814,17 @@ fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> s /// so callers can surface them in the `lint_warnings` field of the returned JSON object. /// This is the binding warning channel: napi `lint`/`lintFile`/`lintVirtual` add /// `lint_warnings: string[]` to their return value when unknown rule names are present. -fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result<(mds::LintConfig, Vec)> { +fn extract_rules_direct( + env: &Env, + obj: &Object, +) -> napi::Result<(mds::LintConfig, Option)> { if !obj.has_named_property("rules")? { - return Ok((mds::LintConfig::default(), vec![])); + return Ok((mds::LintConfig::default(), None)); } let val: Unknown = obj.get_named_property_unchecked("rules")?; let vt = val.get_type()?; match vt { - ValueType::Undefined | ValueType::Null => Ok((mds::LintConfig::default(), vec![])), + ValueType::Undefined | ValueType::Null => Ok((mds::LintConfig::default(), None)), ValueType::Object => { // Deserialize the rules sub-object; js arrays also satisfy Object so // we guard against that in the JSON shape check below. @@ -860,9 +863,8 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result<(mds::LintConfi rules.insert(key, severity); } // D8: detect unknown rule names before consuming the HashMap. - let lint_warnings: Vec = mds::find_unknown_rule_names(&rules) - .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) - .unwrap_or_default(); + let lint_warnings = mds::find_unknown_rule_names(&rules) + .map(|u| mds::format_unknown_rule_names_warning(&u)); Ok((mds::LintConfig::from_rules(rules), lint_warnings)) } other => Err(throw_options_error( @@ -886,12 +888,12 @@ type LintOpts = ( Option, Option>, mds::LintConfig, - Vec, + Option, ); fn parse_lint_opts(env: &Env, opts: Option) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, None, mds::LintConfig::default(), vec![])); + return Ok((None, None, mds::LintConfig::default(), None)); }; reject_unknown_napi_keys(env, &opts_obj, &["basePath", "vars", "rules"])?; @@ -906,11 +908,15 @@ fn parse_lint_opts(env: &Env, opts: Option) -> napi::Result { /// /// Valid keys: `vars`, `rules`. `basePath` is not accepted (derived from file path). /// Returns `(vars, lint_config, lint_warnings)`. -type LintFileOpts = (Option>, mds::LintConfig, Vec); +type LintFileOpts = ( + Option>, + mds::LintConfig, + Option, +); fn parse_lint_file_opts(env: &Env, opts: Option) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, mds::LintConfig::default(), vec![])); + return Ok((None, mds::LintConfig::default(), None)); }; if opts_obj.has_named_property("basePath")? { @@ -935,7 +941,7 @@ fn parse_lint_file_opts(env: &Env, opts: Option) -> napi::Result) -> napi::Result { let Some(opts_obj) = opts else { - return Ok((None, mds::LintConfig::default(), vec![])); + return Ok((None, mds::LintConfig::default(), None)); }; if opts_obj.has_named_property("basePath")? { diff --git a/crates/mds-python/python/mdscript/_mdscript.pyi b/crates/mds-python/python/mdscript/_mdscript.pyi index ca9fe6c2..4d0f3404 100644 --- a/crates/mds-python/python/mdscript/_mdscript.pyi +++ b/crates/mds-python/python/mdscript/_mdscript.pyi @@ -178,6 +178,14 @@ class LintResult: def truncated(self) -> bool: ... @property def files(self) -> list[LintFileReport]: ... + @property + def lint_warnings(self) -> list[str]: + """Non-fatal warnings raised while configuring the lint run. + + An unknown rule name in the ``rules`` mapping produces one entry here: + the rule is not enforced (it does not exist) but the call still + succeeds. Empty in the common case. + """ def __new__(cls, canonical: Mapping[str, Any]) -> LintResult: ... def to_dict(self) -> dict[str, Any]: ... def to_json(self) -> str: ... diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 3b71fe77..65a0f9f4 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -1269,17 +1269,17 @@ fn parse_modules(py: Python<'_>, modules: &Bound<'_, PyAny>) -> PyResult) -> serde_json::Value { - if !warnings.is_empty() { +fn inject_lint_warnings(mut json: serde_json::Value, warning: Option) -> serde_json::Value { + // `Option`, not `Vec`: there is exactly one warning message today + // (unknown rule names are reported as a single sentence), so a vector would be + // over-general plumbing. The JSON shape is still `string[]` — the array is built + // here — so adding a second warning kind later is a change to this function, not to + // the wire contract. + if let Some(w) = warning { if let Some(obj) = json.as_object_mut() { obj.insert( "lint_warnings".to_string(), - serde_json::Value::Array( - warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), + serde_json::Value::Array(vec![serde_json::Value::String(w)]), ); } } @@ -1298,12 +1298,12 @@ fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> s fn extract_rules( py: Python<'_>, rules: Option<&Bound<'_, PyAny>>, -) -> PyResult<(mds::LintConfig, Vec)> { +) -> PyResult<(mds::LintConfig, Option)> { let Some(obj) = rules else { - return Ok((mds::LintConfig::default(), vec![])); + return Ok((mds::LintConfig::default(), None)); }; if obj.is_none() { - return Ok((mds::LintConfig::default(), vec![])); + return Ok((mds::LintConfig::default(), None)); } let json: serde_json::Value = depythonize(obj).map_err(|e| options_error(py, &format!("invalid rules: {e}")))?; @@ -1340,9 +1340,8 @@ fn extract_rules( rules_map.insert(key, severity); } // D8: detect unknown rule names and format warning strings. - let lint_warnings: Vec = mds::find_unknown_rule_names(&rules_map) - .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) - .unwrap_or_default(); + let lint_warnings = mds::find_unknown_rule_names(&rules_map) + .map(|u| mds::format_unknown_rule_names_warning(&u)); Ok((mds::LintConfig::from_rules(rules_map), lint_warnings)) } diff --git a/crates/mds-python/tests/typecheck_sample.py b/crates/mds-python/tests/typecheck_sample.py index b05d3371..21c26971 100644 --- a/crates/mds-python/tests/typecheck_sample.py +++ b/crates/mds-python/tests/typecheck_sample.py @@ -96,6 +96,10 @@ def lint_source(source: str) -> int: def lint_typed_access(source: str) -> list[str]: """Demonstrate fully-typed attribute access on lint results (B6/F10).""" result: LintResult = mdscript.lint(source) + # AC-224-1 / D8: the unknown-rule warning channel is typed as list[str], so a + # consumer can read it without a cast or a `type: ignore`. + lint_warnings: list[str] = result.lint_warnings + _ = lint_warnings rules: list[str] = [] report: LintFileReport for report in result.files: diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index 57097f5d..9b4ac11b 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -450,17 +450,17 @@ fn parse_options(options: JsValue) -> Result { /// D8 (AC-224-1): the WASM binding surfaces unknown-rule warnings by adding a /// `lint_warnings: string[]` field to the returned JSON object. This helper /// consolidates that logic across `lint` and `lintVirtual`. -fn inject_lint_warnings(mut json: serde_json::Value, warnings: Vec) -> serde_json::Value { - if !warnings.is_empty() { +fn inject_lint_warnings(mut json: serde_json::Value, warning: Option) -> serde_json::Value { + // `Option`, not `Vec`: there is exactly one warning message today + // (unknown rule names are reported as a single sentence), so a vector would be + // over-general plumbing. The JSON shape is still `string[]` — the array is built + // here — so adding a second warning kind later is a change to this function, not to + // the wire contract. + if let Some(w) = warning { if let Some(obj) = json.as_object_mut() { obj.insert( "lint_warnings".to_string(), - serde_json::Value::Array( - warnings - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), + serde_json::Value::Array(vec![serde_json::Value::String(w)]), ); } } @@ -478,8 +478,8 @@ struct ParsedLintOptions { opts: ParsedOptions, /// Per-rule severity overrides parsed from `options.rules`. lint_config: mds::LintConfig, - /// Warning messages for unknown rule names (AC-224-1 D8 channel). - lint_warnings: Vec, + /// Warning message for unknown rule names, if any (AC-224-1 D8 channel). + lint_warnings: Option, } /// Extract and validate the `rules` field from a lint options object. @@ -491,10 +491,10 @@ struct ParsedLintOptions { /// /// D8 (AC-224-1): unknown rule NAMES are detected and returned as warning strings. /// Callers surface them via `lint_warnings` in the returned JSON object. -fn extract_rules(obj: &js_sys::Object) -> Result<(mds::LintConfig, Vec), JsValue> { +fn extract_rules(obj: &js_sys::Object) -> Result<(mds::LintConfig, Option), JsValue> { let val = get_prop_js(obj, "rules"); if val.is_undefined() || val.is_null() { - return Ok((mds::LintConfig::default(), vec![])); + return Ok((mds::LintConfig::default(), None)); } // Deserialize the rules sub-object via serde_wasm_bindgen. let rules_json: serde_json::Value = serde_wasm_bindgen::from_value(val) @@ -524,9 +524,8 @@ fn extract_rules(obj: &js_sys::Object) -> Result<(mds::LintConfig, Vec), rules.insert(key, severity); } // D8: detect unknown rule names and format warning strings. - let lint_warnings: Vec = mds::find_unknown_rule_names(&rules) - .map(|u| vec![mds::format_unknown_rule_names_warning(u.names())]) - .unwrap_or_default(); + let lint_warnings = + mds::find_unknown_rule_names(&rules).map(|u| mds::format_unknown_rule_names_warning(&u)); Ok((mds::LintConfig::from_rules(rules), lint_warnings)) } @@ -541,7 +540,7 @@ fn parse_lint_options(options: JsValue) -> Result { return Ok(ParsedLintOptions { opts: ParsedOptions::default(), lint_config: mds::LintConfig::default(), - lint_warnings: vec![], + lint_warnings: None, }); } @@ -619,7 +618,7 @@ fn parse_lint_virtual_options(options: JsValue) -> Result { ); }); }); + +// --------------------------------------------------------------------------- +// AC-224-1 / AC-224-8 (D8): unknown lint rule names on the universal surface. +// +// The universal package is the fifth surface AC-224-1 enumerates, and PF-007 says +// a per-surface assertion elsewhere proves nothing here: napi's spec proves napi, +// web.rs proves WASM. These tests assert the contract as `@mdscript/mds` exposes +// it, on the default backend AND on a directly-constructed WASM backend, so the +// claim is not silently limited to whichever backend init() happens to pick. +// +// Contract: an unknown rule name never throws. It surfaces in +// `result.lint_warnings`, and the field is ABSENT when every name is recognised. +// --------------------------------------------------------------------------- + +describe('unknown lint rule names (AC-224-1 / D8)', () => { + before(() => init()); + + /** Assert one lint result carries a warning naming `ruleName`. */ + function assertWarnsAbout(result, ruleName, label) { + assert.equal(result.version, 1, `${label}: version must be 1`); + assert.ok( + Array.isArray(result.lint_warnings), + `${label}: lint_warnings must be an array; got ${JSON.stringify(result.lint_warnings)}`, + ); + assert.ok(result.lint_warnings.length > 0, `${label}: lint_warnings must be non-empty`); + const joined = result.lint_warnings.join(' '); + assert.ok( + joined.includes(ruleName), + `${label}: lint_warnings must name ${ruleName}; got: ${joined}`, + ); + assert.ok( + joined.includes('recognised rules are'), + `${label}: lint_warnings must list the recognised rules; got: ${joined}`, + ); + } + + test('U-L-WARN-1: lint() warns and continues on an unknown rule name', () => { + const result = lint(UNUSED_SOURCE, { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assertWarnsAbout(result, 'no-such-rule-xyzzy', 'U-L-WARN-1'); + // Lint CONTINUES: the real finding is still reported, unchanged. + assert.doesNotThrow(() => assertResultShape(result, 'lint')); + const rules = result.files.flatMap((f) => f.diagnostics).map((d) => d.rule); + assert.ok( + rules.includes('unused-variable'), + `U-L-WARN-1: linting must continue and still report findings; got: ${JSON.stringify(rules)}`, + ); + }); + + test('U-L-WARN-2: lintVirtual() warns on an unknown rule name', () => { + const result = lintVirtual( + { 'main.mds': CLEAN_SOURCE }, + 'main.mds', + { rules: { 'no-such-rule-xyzzy': 'error' } }, + ); + assertWarnsAbout(result, 'no-such-rule-xyzzy', 'U-L-WARN-2'); + }); + + test('U-L-WARN-3: lintFile() warns on an unknown rule name', async () => { + const result = await lintFile(LINT_WARN_MDS, { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assertWarnsAbout(result, 'no-such-rule-xyzzy', 'U-L-WARN-3'); + }); + + // Paired negative arm (PF-013 / ADR-009): the positive arms above only prove the + // field can appear. Without this, an implementation that always populated + // lint_warnings would pass every assertion above. + test('U-L-WARN-4: every recognised rule name is accepted with no lint_warnings', () => { + assert.equal(LINT_RULE_NAMES.length, 10, 'LINT_RULE_NAMES must have exactly 10 entries'); + for (const name of LINT_RULE_NAMES) { + const result = lint(CLEAN_SOURCE, { rules: { [name]: 'off' } }); + assert.equal( + result.lint_warnings, + undefined, + `U-L-WARN-4: ${name} is a recognised rule — lint_warnings must be absent; ` + + `got: ${JSON.stringify(result.lint_warnings)}`, + ); + } + // Empty and absent rules maps behave identically to a recognised-only map. + assert.equal(lint(CLEAN_SOURCE, { rules: {} }).lint_warnings, undefined); + assert.equal(lint(CLEAN_SOURCE).lint_warnings, undefined); + }); + + test('U-L-WARN-5: an unknown rule name never throws (contrast: unknown severity does)', () => { + assert.doesNotThrow( + () => lint(CLEAN_SOURCE, { rules: { 'no-such-rule-xyzzy': 'warn' } }), + 'U-L-WARN-5: an unknown rule NAME must not throw', + ); + // The asymmetry is deliberate and must stay: unknown severity VALUES still throw. + assert.throws( + () => lint(CLEAN_SOURCE, { rules: { 'unused-variable': 'verbose' } }), + (err) => { + assert.ok(isMdsError(err), `expected MdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options', `got code: ${err.code}`); + return true; + }, + 'U-L-WARN-5: an unknown severity VALUE must still throw mds::invalid_options', + ); + }); + + // PF-007: init() prefers native wherever the napi addon resolves, so every test + // above may have exercised only one backend. Force the WASM backend directly so + // the universal contract is proven on both, mirroring U-L12's approach. + test('U-L-WARN-6: the same contract holds on a directly-constructed WASM backend', async () => { + let wasmBackend; + try { + const wasmMod = await initWasmNode(); + wasmBackend = createWasmBackend(wasmMod); + } catch (initErr) { + if (process.env.CI) { + throw new Error( + 'U-L-WARN-6: WASM backend is required in CI for AC-224-1 universal/WASM coverage. ' + + `Caused by: ${initErr.message}`, + ); + } + console.warn('U-L-WARN-6: skipping WASM surface -- mds-wasm module not built'); + return; + } + const warned = wasmBackend.lint(CLEAN_SOURCE, { rules: { 'no-such-rule-xyzzy': 'warn' } }); + assertWarnsAbout(warned, 'no-such-rule-xyzzy', 'U-L-WARN-6'); + const clean = wasmBackend.lint(CLEAN_SOURCE, { rules: { 'unused-variable': 'off' } }); + assert.equal( + clean.lint_warnings, + undefined, + `U-L-WARN-6: recognised rules must produce no lint_warnings; got: ${JSON.stringify(clean.lint_warnings)}`, + ); + }); +}); diff --git a/packages/mds/src/node.ts b/packages/mds/src/node.ts index 1f941f0c..388f521f 100644 --- a/packages/mds/src/node.ts +++ b/packages/mds/src/node.ts @@ -295,7 +295,12 @@ export function getBackend(): BackendType { return assertReady().getBackend(); } -export { isMdsError } from './types.js'; +// `LINT_RULE_NAMES` is exported here, not only from `index.ts`: the package +// `exports` map resolves `@mdscript/mds` to `dist/node.js` (Node) or +// `dist/browser.js`, and never to `dist/index.js` — a value re-exported only +// from `index.ts` is unreachable for consumers. The browser entry gains it with +// the browser lint surface; today it has no lint API to configure. +export { isMdsError, LINT_RULE_NAMES } from './types.js'; export type { BackendType, CheckOptions, @@ -309,6 +314,7 @@ export type { LintFileReport, LintOptions, LintResult, + LintRuleName, LintSpan, RuleSeverity, MarkdownResult, diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index 3fa05cdd..dd3009f9 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -171,7 +171,7 @@ export type RuleSeverity = 'error' | 'warn' | 'info' | 'off'; * * Passing a key outside this set in `LintOptions.rules` emits a warning (see * {@link LintResult.lint_warnings}) and lint continues — unknown names are - * silently ignored by the engine after the warning is surfaced to the caller. + * not enforced by the engine (the rule does not exist), but the caller is told. */ export type LintRuleName = | 'duplicate-export' @@ -245,7 +245,7 @@ export interface LintOptions { * * Keys should be {@link LintRuleName} values. An unrecognised key emits a * warning in {@link LintResult.lint_warnings} and lint continues — the - * unknown rule is silently ignored by the engine. `Record` is + * unknown rule is not enforced by the engine. `Record` is * accepted for forward compatibility with future rule names. */ rules?: Record; @@ -266,7 +266,7 @@ export interface LintFileOptions { * * Keys should be {@link LintRuleName} values. An unrecognised key emits a * warning in {@link LintResult.lint_warnings} and lint continues — the - * unknown rule is silently ignored by the engine. `Record` is + * unknown rule is not enforced by the engine. `Record` is * accepted for forward compatibility with future rule names. */ rules?: Record; diff --git a/spec.md b/spec.md index 523c69c7..c474cfc6 100644 --- a/spec.md +++ b/spec.md @@ -1215,7 +1215,7 @@ Place `mds.json` in the project root (or any ancestor directory). The compiler w | Field | Type | Description | |-------|------|-------------| | `build.output_dir` | string | Relative path to output directory. Must not contain `..` components. | -| `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. Unknown rule names are warn-and-ignored (forward compat). | +| `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. An unknown rule name emits a warning naming it and listing the rules this build recognises, the config still loads, and lint continues — the unknown rule is not enforced (forward compat: a config naming a rule added in a newer release warns instead of failing on an older binary). On the CLI the warning goes to stderr and is suppressed by `--quiet`; on the `lint` API surfaces it is returned in `lint_warnings`. | Maximum config file size: 1 MB. From abb44f6f1f69b8f551937640862c2a73275ce466 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 13:49:03 +0200 Subject: [PATCH 05/42] docs: fix six Evaluator alignment findings from PR #224 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-cli/src/lint.rs | 13 +++++++++++-- examples/linting/README.md | 7 +++++-- packages/mds/src/types.ts | 19 +++++++++++++------ spec.md | 6 ++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index c35c94cc..34499749 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -45,8 +45,7 @@ use crate::output::{ // AC-224-15: No local rule-name list. The single source of truth is // mds::KNOWN_LINT_RULES (composed from each rule module's own RULE const). -// A repo-wide search for "unused-variable" under crates/mds-cli/src/ must -// return zero hits. +// No rule-name string literals from the registry appear in this directory. pub(crate) struct LintArgs { pub(crate) input: Option, @@ -193,6 +192,16 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { match config_opt { None => Ok(mds::LintConfig::default()), Some((mds_config, _config_dir)) => { + // AC-224-3 residual: the warning text produced here ("unknown lint rule + // '...' in mds.json; ...") differs from the text produced by + // `mds::format_unknown_rule_names_warning` used by napi/WASM/Python + // ("unknown lint rules: '...'; ...") by design: the CLI adds the + // source context "in mds.json" and uses singular/plural forms so the + // print-discipline guard can machine-check the safe_inline call sites + // directly. This divergence is intentional and recorded here as a named + // residual (applies AD-224-3, R6/PF-007 — per-surface goldens, not a + // differential test). + // // AD-224-3: `safe_inline` WIRE-escapes each name before it enters the // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). // A JSON object key is never legitimately multi-line; routing through diff --git a/examples/linting/README.md b/examples/linting/README.md index 907e77af..0517989a 100644 --- a/examples/linting/README.md +++ b/examples/linting/README.md @@ -186,8 +186,11 @@ surfaces `shadow-variable` as **info** (☞) and `unused-variable` as an **error > Files outside `config-demo/` find no `mds.json` and use built-in defaults. Config errors are strict: an unknown severity value or malformed JSON fails the run -with exit `2`; an unknown *rule name* prints a `warning: unknown lint rule …` and is -ignored (forward-compatible). +with exit `2`. An unknown *rule name* is handled more leniently: a +`warning: unknown lint rule …` is printed to stderr, the config still loads, lint +continues, and the unknown rule is not enforced — it is silently skipped +(forward-compatible: a config naming a rule from a newer release warns instead of +failing on an older binary). ## Exit codes diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index dd3009f9..6b038c59 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -169,9 +169,12 @@ export type RuleSeverity = 'error' | 'warn' | 'info' | 'off'; /** * Union of all recognised lint rule names. * - * Passing a key outside this set in `LintOptions.rules` emits a warning (see - * {@link LintResult.lint_warnings}) and lint continues — unknown names are - * not enforced by the engine (the rule does not exist), but the caller is told. + * D-224-1 (2026-08-12 ruling): passing a key outside this set is NOT an error. + * The engine emits a warning (see {@link LintResult.lint_warnings}) and lint + * continues — the unknown rule is not enforced, but the caller is told. The + * asymmetry with unknown severity values (which are hard errors) is deliberate: + * severity is a closed set, but rule names grow every release, so hard-failing + * would break configs that name a rule added in a newer binary. */ export type LintRuleName = | 'duplicate-export' @@ -188,9 +191,13 @@ export type LintRuleName = /** * All recognised lint rule names, sorted alphabetically. * - * This array is the canonical registry used on all surfaces. `rules` keys not - * found here emit a warning (see {@link LintResult.lint_warnings}) and lint - * continues — unknown names are ignored by the engine after the warning. + * D-224-2: this array is a manual mirror of the Rust `KNOWN_LINT_RULES` + * registry (composed from each rule module's own `RULE` const). The TS mirror + * is guarded in one direction only — a new rule must be added here manually + * after landing in `mds-core`. Drift is a named residual (avoids PF-015). + * + * `rules` keys not found here emit a warning (see {@link LintResult.lint_warnings}) + * and lint continues — unknown names are ignored by the engine after the warning. */ export const LINT_RULE_NAMES: readonly LintRuleName[] = [ 'duplicate-export', diff --git a/spec.md b/spec.md index c474cfc6..dccdaa50 100644 --- a/spec.md +++ b/spec.md @@ -995,6 +995,8 @@ mode, which uses a single config located from the directory argument. Keys are in alphabetical order (BTreeMap serialization). Within each `files[].diagnostics` array, diagnostics are ordered by ascending `span.offset`; span-less diagnostics sort last; equal-offset ties preserve rule-execution order (stable sort). (The CLI and binding surfaces always produce results through `LintResultBuilder`; a `LintResult` assembled directly via `LintResult::new` preserves caller-supplied order instead.) In directory mode, `files[].file` is the forward-slash-separated path relative to the lint root (e.g. `src/template.mds`), and the `files[]` array is ordered by the byte-wise string comparison of that relative display path (e.g. `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) < `'/'` (0x2F)). `"truncated": true` when the result set was capped by the per-file diagnostic cap of 1,000. `"span"` is JSON `null` for diagnostics that lack a source location. When linting from stdin (`mds lint -`), `files[].file` is `""`. +**`lint_warnings` field (binding surfaces only):** The napi, WASM, and Python binding surfaces include an optional top-level `"lint_warnings"` key in the returned result object when non-fatal warnings were produced during linting (for example, unknown rule names in `mds.json`). The key is absent (not `null`, not `[]`) when no warnings occurred. In alphabetical key order `"lint_warnings"` sorts between `"files"` and `"truncated"`. The CLI does **not** include `"lint_warnings"` in its `--format json` stdout envelope — it writes warnings to stderr so the JSON stdout remains valid and parseable without modification. + A file that produces a per-file analysis failure in directory mode (malformed config, I/O error) emits a `{"file":"…","error":{"code":"…","message":"…","help":"…","span":…}}` entry without a `"diagnostics"` key and contributes to exit code 2. When a stdin source fails the check gate before linting begins, the CLI emits an analysis-failure envelope to stdout: `{"version":1,"error":{"code":"…","message":"…","help":"…","span":…}}`. This envelope carries no `"files"` or `"truncated"` key, and no `"file"` key (unlike the success envelope above). A JSON consumer MUST handle both the success envelope and the analysis-failure envelope and MUST NOT assume a `"file"` key is present in error results. #### Sanitization invariant (v1) @@ -1067,9 +1069,9 @@ Applied, that means: |-------|------|---------| | `message`, `help`, warning bodies, `LabeledSpan` text | HUMAN on terminal surfaces, WIRE on the JSON wire | Prose; legitimately multi-line in a rendered frame | | A filename or path in a diagnostic `file` **field**: the JSON `file` key, a CLI status line, a `[file:line:col]` frame header | WIRE on every surface that renders one | Single-line by construction; POSIX permits `\n` in a filename and the user never types it | -| `mds.json` rule names and config values, `--format` arguments | WIRE on every surface that renders one | Single-line identifiers read from the working tree or the command line | +| `mds.json` rule names and config values, `--format` arguments | WIRE on every surface that renders one | Single-line identifiers. WIRE applies in all rendering contexts — including when a rule name appears inside a warning body (e.g., the unknown-rule-names warning), the name is WIRE-escaped, not HUMAN. This row takes precedence over the residual row below for `mds.json`-sourced values. | | `io::Error` / `MdsError` causes interpolated into a CLI status or warning line | WIRE on every surface that renders one | Single-line, and they embed paths of their own | -| A path, identifier or cause interpolated into a diagnostic **message body** | HUMAN on terminal surfaces, WIRE on the JSON wire | Follows the message row above — it is part of prose. This is the **residual** below: it is not covered by the WIRE rows | +| A path, identifier or cause interpolated into a diagnostic **message body** | HUMAN on terminal surfaces, WIRE on the JSON wire | Follows the message row above — it is part of prose. This is the **residual** below: it is not covered by the WIRE rows. Exception: `mds.json` rule names and config values that appear inside a warning body are governed by the WIRE row above, not this residual — the more specific row takes precedence. | | Compiled template output (`mds build -o -`) | not escaped | It is the command's product, not a diagnostic; redirects must stay byte-faithful | | Source-map `file` / `sources` / `sourcesContent`, and `CompileResult.dependencies` | not escaped | Functional references, not display text; escaping would break resolution. This is the **carve-out** below | From 7259360ca576856c63ee498fecda5f61c0e63266 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:39:30 +0200 Subject: [PATCH 06/42] docs(lint): add lint_warnings to result shape in napi and mds READMEs 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 --- crates/mds-napi/README.md | 2 +- packages/mds/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mds-napi/README.md b/crates/mds-napi/README.md index a01f2595..88879132 100644 --- a/crates/mds-napi/README.md +++ b/crates/mds-napi/README.md @@ -63,7 +63,7 @@ Source-map options are **not accepted** — check does not generate output. ### `lint(source, opts?)` / `lintFile(path, opts?)` / `lintVirtual(modules, entry, opts?)` Static analysis. Returns the canonical lint JSON: -`{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, fix_edits, span?},...]},...], truncated: bool }` +`{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, fix_edits, span?},...]},...], truncated: bool, lint_warnings?: string[] }` Options: `basePath` (lint only — lintFile derives the base from the file path; lintVirtual resolves against the module map), `vars`, `rules` (`Record`). Unknown rule names in `rules` emit a warning and lint continues — the unknown name has no effect but `result.lint_warnings` (a `string[]` field) is populated so callers can surface the issue; unknown severity values throw `mds::invalid_options`. diff --git a/packages/mds/README.md b/packages/mds/README.md index 199f32e3..072e7e05 100644 --- a/packages/mds/README.md +++ b/packages/mds/README.md @@ -155,6 +155,6 @@ surfaces it appears in `lint_warnings`. **Lint result shape:** ```ts -{ version: 1, files: [{ file: string, diagnostics: LintDiagnostic[] }], truncated: boolean } +{ version: 1, files: [{ file: string, diagnostics: LintDiagnostic[] }], truncated: boolean, lint_warnings?: string[] } // LintDiagnostic: { rule, severity, message, help?, fixable, fix_edits, span? } ``` From fd915c8b8b4f740ced7d4f5bfb1b7027c90171c6 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:40:16 +0200 Subject: [PATCH 07/42] chore(ci): record PR2 WASM budget measurement in history comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0570d5c..cdb59d18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,14 @@ jobs: # after removing redundant to_canonical_json re-sort in 56424f7). # Guard NOT raised: 16,498 bytes (1.94%) headroom. CI uses # Binaryen v129 (distinct toolchain from local). (AC-P1-23) + # ticket/pr2-unknown-rule-names (2026-08-14, #224): unknown-rule-name warning + # (find_unknown_rule_names + format_unknown_rule_names_warning + five binding + # sites) added +9,449 bytes; PR1 baseline 833,502, post-change 842,951 (wasm-pack + # 0.15.0 bundled wasm-opt, measured locally). Delta is under the +10,000 R2-fallback + # trigger; config.rs used sort_unstable (not sort) and push_str (not join) to + # keep the delta minimal — join monomorphises into kilobytes in WASM. + # Guard NOT raised: 7,049 bytes (0.83%) headroom. CI uses Binaryen v129 + # (distinct toolchain from local). Three more wave PRs still to land. (AC-224-18) # Follow-up: pin the wasm build toolchain to make the size deterministic # and re-tighten this guard. if [ "$raw" -gt 850000 ]; then From 2dd86e08efe3fc8255d8e0028d014773a05dc57c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:41:35 +0200 Subject: [PATCH 08/42] docs(linting): remove self-contradictory "silently" from unknown-rule 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 --- examples/linting/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/linting/README.md b/examples/linting/README.md index 0517989a..9d38a0e3 100644 --- a/examples/linting/README.md +++ b/examples/linting/README.md @@ -188,7 +188,7 @@ surfaces `shadow-variable` as **info** (☞) and `unused-variable` as an **error Config errors are strict: an unknown severity value or malformed JSON fails the run with exit `2`. An unknown *rule name* is handled more leniently: a `warning: unknown lint rule …` is printed to stderr, the config still loads, lint -continues, and the unknown rule is not enforced — it is silently skipped +continues, and the unknown rule is not enforced — it is skipped (forward-compatible: a config naming a rule from a newer release warns instead of failing on an older binary). From 8ec89703899918f6c98b8b0dd1dbc286aa7f350c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:43:09 +0200 Subject: [PATCH 09/42] refactor(mds-core): route lint::config symbols through lint/mod.rs re-export layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-core/src/lib.rs | 8 +++----- crates/mds-core/src/lint/mod.rs | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index c2807c7f..d12df0a0 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -60,13 +60,11 @@ pub(crate) mod value; pub use formatter::{format_str, format_str_named, format_str_with}; pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; -pub use lint::config::{ - find_unknown_rule_names, format_unknown_rule_names_warning, UnknownRuleNames, KNOWN_LINT_RULES, -}; pub use lint::{ - fix, named_source_for_render, neutralize_source_for_render, sanitize_control_chars, + attach_lint_warnings, find_unknown_rule_names, fix, format_unknown_rule_names_warning, + named_source_for_render, neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, - TextEdit, + TextEdit, UnknownRuleNames, KNOWN_LINT_RULES, }; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, diff --git a/crates/mds-core/src/lint/mod.rs b/crates/mds-core/src/lint/mod.rs index afb5fa8c..a8eb04b3 100644 --- a/crates/mds-core/src/lint/mod.rs +++ b/crates/mds-core/src/lint/mod.rs @@ -32,7 +32,10 @@ pub mod fix; pub(crate) mod rules; pub(crate) mod tier; -pub use config::LintConfig; +pub use config::{ + attach_lint_warnings, find_unknown_rule_names, format_unknown_rule_names_warning, LintConfig, + UnknownRuleNames, KNOWN_LINT_RULES, +}; pub use diagnostic::{ named_source_for_render, neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, LintDiagnostic, LintResult, Severity, TextEdit, From 59321899f80b0eaf6f13ce3eebd1cf1320f45397 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:47:11 +0200 Subject: [PATCH 10/42] docs(lint): correct "on every surface" docstrings; extract attach_lint_warnings [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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 --- crates/mds-cli/src/build.rs | 25 +++-- crates/mds-cli/src/lint.rs | 12 ++- crates/mds-core/src/lint/config.rs | 149 ++++++++++++++++++++++++--- crates/mds-napi/src/lib.rs | 39 ++----- crates/mds-python/src/lib.rs | 99 +++++++++--------- crates/mds-python/tests/test_lint.py | 50 +++++++++ crates/mds-wasm/src/lib.rs | 37 ++----- 7 files changed, 286 insertions(+), 125 deletions(-) diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index ae84b4db..b5ef7936 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -32,8 +32,9 @@ pub(crate) struct MdsConfig { /// Per-rule severity overrides for `mds lint` (AC-F-17). /// /// Unknown severity VALUES fail config loading loudly (closed enum). - /// Unknown rule NAMES produce a warning and lint continues (forward-compat: a config - /// naming a rule from a newer mds version warns but does not break on an older binary). + /// Unknown rule NAMES: only `mds lint` warns on stderr (via `load_lint_config`) and + /// continues. `mds build`, `check`, `fmt`, and `watch` load this field but do not + /// emit the warning — an accepted asymmetry, not an oversight (see CHANGELOG). #[serde(default)] pub(crate) lint: LintCliConfig, } @@ -45,7 +46,9 @@ pub(crate) struct MdsConfig { /// /// Unknown severity VALUES (e.g. `"banana"`) cause a hard parse error (exit 2) /// because `Severity` is a closed enum with no sensible fallback. Unknown rule -/// NAMES produce a warning on every surface and lint continues (forward-compat). +/// NAMES: only `mds lint` warns on stderr and continues (via `load_lint_config`); +/// `mds build`, `check`, `fmt`, and `watch` deserialize this struct but do not +/// emit the warning — an accepted asymmetry, not an oversight (see CHANGELOG). #[derive(Debug, Default, Deserialize)] pub(crate) struct LintCliConfig { #[serde(default)] @@ -53,9 +56,19 @@ pub(crate) struct LintCliConfig { } impl LintCliConfig { - /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions. - pub(crate) fn into_core_config(self) -> mds::LintConfig { - mds::LintConfig::from_rules(self.rules) + /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions, + /// returning any unknown rule names alongside it. + /// + /// Uses [`mds::LintConfig::from_rules_checked`] so the caller cannot + /// accidentally omit the unknowns check: the return type structurally forces + /// the caller to decide what to do with the `Option` + /// (even `let (config, _) = …` is an explicit decision). This closes the + /// gap identified in the review finding for config.rs:104 — a fifth consumer + /// of this helper could not previously skip detection silently. + pub(crate) fn into_core_config( + self, + ) -> (mds::LintConfig, Option) { + mds::LintConfig::from_rules_checked(self.rules) } } diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 34499749..125ac0b7 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -217,9 +217,17 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // escaping — it is passed through `safe_inline` anyway so the guard can see // the whole `format!` is clean without an exemption. // + // into_core_config uses LintConfig::from_rules_checked internally, + // which returns (config, Option) in one step. The + // return type structurally forces us to handle the unknowns report here + // rather than relying on a separate find_unknown_rule_names call — the + // fix for the review finding at config.rs:104 (detection is structural, + // not advisory). + // // AC-224-22: suppress under --quiet (coordination point with PR4 D4). + let (lint_config, unknown) = mds_config.lint.into_core_config(); if !quiet { - if let Some(unknown) = mds::find_unknown_rule_names(&mds_config.lint.rules) { + if let Some(unknown) = unknown { // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. let names = unknown.names(); let recognised = mds::KNOWN_LINT_RULES.join(", "); @@ -245,7 +253,7 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { } } } - Ok(mds_config.lint.into_core_config()) + Ok(lint_config) } } } diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 552525c9..1eceb3c0 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -3,10 +3,14 @@ //! `LintConfig` is public (lives in mds-core) — the CLI converts the `mds.json` //! `lint.rules` section into it, and all `lint_*` entry points accept a `&LintConfig`. //! -//! **Unknown rule NAMEs** emit a warning and lint continues — the unknown rule simply -//! has no effect. This is deliberate forward-compatibility: severities are a closed set, -//! but rule names grow every release; hard-failing an unknown name would break a config -//! naming a newer rule when run with an older binary. +//! **Unknown rule NAMEs** do not cause construction failures — the rule simply has no +//! effect and lint continues. Detection is opt-in: callers must call +//! [`find_unknown_rule_names`] and surface any unknowns themselves. mds-core itself +//! emits no warning. The CLI's `mds lint` warns on stderr; napi/WASM/Python return +//! `lint_warnings`; `mds build`, `check`, `fmt`, and `watch` do not warn — an accepted +//! asymmetry, not an oversight. This is deliberate forward-compatibility: severities are +//! a closed set, but rule names grow every release; hard-failing an unknown name would +//! break a config naming a newer rule when run with an older binary. //! **Unknown severity VALUES** fail loudly via serde deserialization error (closed enum). use std::collections::HashMap; @@ -161,6 +165,39 @@ pub fn format_unknown_rule_names_warning(unknown: &UnknownRuleNames) -> String { out } +/// Inject `lint_warnings` into a canonical JSON result when a warning is present. +/// +/// D8 (AC-224-1): the napi, WASM, and Python bindings surface unknown-rule warnings +/// by adding a `lint_warnings: string[]` field to the returned JSON object. This +/// function is the single implementation of that D8 wire contract — the key name +/// `"lint_warnings"`, the array-of-one shape, and the absent-when-empty semantics +/// — so the contract cannot diverge across surfaces. +/// +/// `Option` rather than `Vec`: there is exactly one warning message +/// today (unknown rule names are reported as a single sentence), so a vector would be +/// over-general plumbing. The JSON shape is still `string[]` — the array is built +/// here — so adding a second warning kind later is a change to this function, not to +/// the wire contract. +/// +/// Deliberately kept out of [`LintResult::to_canonical_json`] so the CLI serializer +/// path (`--format json`) remains byte-frozen: the CLI writes the warning to stderr +/// via `eprint_warning` and never touches the JSON. +#[must_use] +pub fn attach_lint_warnings( + mut json: serde_json::Value, + warning: Option, +) -> serde_json::Value { + if let Some(w) = warning { + if let Some(obj) = json.as_object_mut() { + obj.insert( + "lint_warnings".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String(w)]), + ); + } + } + json +} + /// Per-rule severity override configuration. /// /// Loaded from the `lint.rules` section of `mds.json`: @@ -169,17 +206,21 @@ pub fn format_unknown_rule_names_warning(unknown: &UnknownRuleNames) -> String { /// ``` /// /// Absent rules default to the engine's built-in severity (defined per rule in the -/// rule catalog). Unknown rule names in the map produce a warning on every surface -/// (the rule simply has no effect, and lint continues). This is deliberate -/// forward-compatibility: a config naming a rule from a newer mds version warns -/// but does not break when run with an older binary. +/// rule catalog). Unknown rule names in the map do not cause construction to fail +/// — the rule simply has no effect, and lint continues. Detection is opt-in via +/// [`find_unknown_rule_names`]: mds-core itself emits no warning; callers are +/// responsible for surfacing unknowns. This is deliberate forward-compatibility: +/// a config naming a rule from a newer mds version does not break when run with +/// an older binary. /// /// Unknown severity *values* (e.g. `"verbose"`) cause a hard parse error (`exit 2`) /// because the closed enum has no sensible fallback. /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. -/// Use `LintConfig::default()` for a config with all rules at engine defaults, or -/// [`LintConfig::from_rules`] to supply per-rule overrides; do not construct via +/// Use `LintConfig::default()` for a config with all rules at engine defaults, +/// [`LintConfig::from_rules_checked`] (preferred) to supply per-rule overrides and +/// receive an unknowns report in a single step, or [`LintConfig::from_rules`] when +/// the unknowns check has already been performed externally; do not construct via /// struct literal. #[non_exhaustive] #[derive(Debug, Default, Clone)] @@ -190,6 +231,54 @@ pub struct LintConfig { } impl LintConfig { + /// Construct a `LintConfig` with the given per-rule overrides AND detect any + /// unknown rule names in one atomic step. + /// + /// This is the **preferred construction path** for any surface that must warn + /// about unknown rule names (the 2026-08-12 ruling: unknown names warn and lint + /// continues — the silence is the only thing that changes). The return type + /// carries the unknowns report alongside the config so the caller cannot + /// accidentally omit the detection step — compare with [`LintConfig::from_rules`], + /// where forgetting a follow-up [`find_unknown_rule_names`] call silently reverts + /// to the pre-warning behaviour on that surface (R12 in the PR plan). + /// + /// Marked `#[must_use]` — the compiler emits a warning if the return value is + /// discarded entirely, making it structurally harder to drop the unknowns report. + /// Callers that deliberately do not need the report should write: + /// `let (config, _) = LintConfig::from_rules_checked(map);` + /// + /// The asymmetry with unknown severities is deliberate and unchanged: severity + /// values are a closed enum and hard-fail at the serde layer; rule names grow + /// every release, so they warn rather than fail (AD-224-1). + /// + /// # Examples + /// + /// ``` + /// use std::collections::HashMap; + /// use mds::{LintConfig, Severity}; + /// + /// // All-known map: unknowns report is None. + /// let (config, unknown) = LintConfig::from_rules_checked(HashMap::from([ + /// ("unused-variable".to_string(), Severity::Off), + /// ])); + /// assert!(unknown.is_none()); + /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); + /// + /// // Map with an unknown name: report is Some, config still loads. + /// let (config2, unknown2) = LintConfig::from_rules_checked(HashMap::from([ + /// ("no-such-rule".to_string(), Severity::Warn), + /// ])); + /// let u = unknown2.expect("should detect unknown"); + /// assert_eq!(u.names(), &["no-such-rule".to_string()]); + /// // The config still loads — lint continues with no effect for the unknown rule. + /// assert!(config2.severity_for("no-such-rule").is_some()); + /// ``` + #[must_use] + pub fn from_rules_checked(rules: HashMap) -> (Self, Option) { + let unknown = find_unknown_rule_names(&rules); + (LintConfig { rules }, unknown) + } + /// Construct a `LintConfig` with the given per-rule severity overrides. /// /// This is the supported construction path for external crates — struct literals @@ -199,10 +288,16 @@ impl LintConfig { /// or `with_*` only when taking `self`. This function does not take `self`, /// so it is named `from_rules`. /// - /// Unknown rule names in the map produce a warning via [`find_unknown_rule_names`] - /// on every API surface; they do not cause this constructor to fail. Call - /// [`find_unknown_rule_names`] before or after construction if you need to inspect - /// or surface those names. + /// **Prefer [`LintConfig::from_rules_checked`]** when your surface must warn about + /// unknown rule names. It returns the unknowns report in a single call, making it + /// structurally impossible to miss the detection step. Use `from_rules` only when + /// the unknowns check has already been performed externally (e.g. a config built + /// entirely from compile-time literals, or detection delegated to a separate call). + /// + /// Unknown rule names in the map do not cause this constructor to fail — the rule + /// simply has no effect, and lint continues. mds-core itself emits no warning; + /// call [`find_unknown_rule_names`] on the same map before passing it here if you + /// need to inspect or surface those names. /// /// # Examples /// @@ -298,4 +393,30 @@ mod tests { "non-vacuity: the rule name must reach the message; got: {msg}" ); } + + // ── attach_lint_warnings ───────────────────────────────────────────────── + + /// D8: a present warning is injected as `lint_warnings: [string]`. + /// + /// PF-013 / ADR-009: both directions are tested — present warning inserts + /// the field; absent warning leaves the object unchanged. + #[test] + fn attach_lint_warnings_injects_field_when_warning_present() { + let json = serde_json::json!({ "version": 1 }); + let result = attach_lint_warnings(json, Some("unknown lint rule 'foo'; ignoring".into())); + let arr = result["lint_warnings"].as_array().expect("lint_warnings must be an array"); + assert_eq!(arr.len(), 1, "exactly one element"); + assert_eq!(arr[0].as_str().unwrap(), "unknown lint rule 'foo'; ignoring"); + } + + /// D8: no `lint_warnings` key is added when warning is absent (absent-when-empty semantics). + #[test] + fn attach_lint_warnings_leaves_object_unchanged_when_no_warning() { + let json = serde_json::json!({ "version": 1 }); + let result = attach_lint_warnings(json, None); + assert!( + result.get("lint_warnings").is_none(), + "lint_warnings must be absent when no warning; got: {result:?}" + ); + } } diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 93c58fb4..d7d5b618 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -783,28 +783,6 @@ pub fn check_file(env: Env, path: String, opts: Option) -> napi::Result< // ── Lint options parsing ────────────────────────────────────────────────────── -/// Inject `lint_warnings` into a canonical JSON result when warnings are present. -/// -/// D8 (AC-224-1): the napi binding surfaces unknown-rule warnings by adding a -/// `lint_warnings: string[]` field to the returned JSON object. This helper -/// consolidates that logic across `lint`, `lintFile`, and `lintVirtual`. -fn inject_lint_warnings(mut json: serde_json::Value, warning: Option) -> serde_json::Value { - // `Option`, not `Vec`: there is exactly one warning message today - // (unknown rule names are reported as a single sentence), so a vector would be - // over-general plumbing. The JSON shape is still `string[]` — the array is built - // here — so adding a second warning kind later is a change to this function, not to - // the wire contract. - if let Some(w) = warning { - if let Some(obj) = json.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array(vec![serde_json::Value::String(w)]), - ); - } - } - json -} - /// Extract and validate the `rules` option: `Record` → `(mds::LintConfig, Vec)`. /// /// Returns the default config (all rules at built-in defaults) when `rules` is absent, @@ -862,10 +840,13 @@ fn extract_rules_direct( })?; rules.insert(key, severity); } - // D8: detect unknown rule names before consuming the HashMap. - let lint_warnings = mds::find_unknown_rule_names(&rules) - .map(|u| mds::format_unknown_rule_names_warning(&u)); - Ok((mds::LintConfig::from_rules(rules), lint_warnings)) + // D8: detect unknown rule names and build config in one step via + // from_rules_checked. The return type structurally forces the caller + // to handle the unknowns report — a fifth caller cannot accidentally + // omit the detection step (review finding: config.rs:104). + let (lint_config, unknown) = mds::LintConfig::from_rules_checked(rules); + let lint_warnings = unknown.map(|u| mds::format_unknown_rule_names_warning(&u)); + Ok((lint_config, lint_warnings)) } other => Err(throw_options_error( env, @@ -1004,7 +985,7 @@ pub fn lint(env: Env, source: String, opts: Option) -> napi::Result) -> napi::Result Vec { self.value @@ -921,8 +930,8 @@ fn sanitize_json_str_field(obj: &mut serde_json::Map, } } -/// Sanitize all message, help, and file string fields in a canonical lint result value -/// in-place. +/// Sanitize all string fields in a canonical lint result value in-place: per-file +/// `file`, per-diagnostic `message` and `help`, and every string in `lint_warnings`. /// /// Called in [`LintResult::new`] so any data arriving through the /// `LintResult(canonical)` / pickle path is sanitized before the typed getters or @@ -930,28 +939,45 @@ fn sanitize_json_str_field(obj: &mut serde_json::Map, /// `LintResult::to_canonical_json()` does on the live lint path, closing the /// parallel-path gap (PF-004). /// +/// The live lint path is safe because `format_unknown_rule_names_warning` +/// WIRE-escapes each string before [`inject_lint_warnings`] injects it. The +/// `LintResult(canonical)` / pickle path must also sanitize `lint_warnings` because +/// callers may supply untrusted canonical data containing hostile control bytes. +/// /// **No re-sort (AD-202-1b):** `to_canonical_json()` preserves caller-supplied /// order for `LintResult::new` callers; `sort_diagnostics` is the single ordering /// choke point, called only from `LintResultBuilder::build`. This function /// intentionally mirrors that contract — it sanitizes but does not reorder. fn sanitize_lint_value(value: &mut serde_json::Value) { - let Some(files) = value.get_mut("files").and_then(|v| v.as_array_mut()) else { - return; - }; - for file_val in files.iter_mut() { - let Some(obj) = file_val.as_object_mut() else { - continue; - }; - sanitize_json_str_field(obj, "file"); - let Some(diags) = obj.get_mut("diagnostics").and_then(|v| v.as_array_mut()) else { - continue; - }; - for d in diags.iter_mut() { - let Some(d_obj) = d.as_object_mut() else { + // Sanitize per-file string fields. + if let Some(files) = value.get_mut("files").and_then(|v| v.as_array_mut()) { + for file_val in files.iter_mut() { + let Some(obj) = file_val.as_object_mut() else { continue; }; - sanitize_json_str_field(d_obj, "message"); - sanitize_json_str_field(d_obj, "help"); + sanitize_json_str_field(obj, "file"); + let Some(diags) = obj.get_mut("diagnostics").and_then(|v| v.as_array_mut()) else { + continue; + }; + for d in diags.iter_mut() { + let Some(d_obj) = d.as_object_mut() else { + continue; + }; + sanitize_json_str_field(d_obj, "message"); + sanitize_json_str_field(d_obj, "help"); + } + } + } + // Sanitize lint_warnings strings (PF-004: the live path escapes via + // format_unknown_rule_names_warning before injection, but LintResult(canonical) / + // pickle callers may supply untrusted data directly). + if let Some(arr) = value.get_mut("lint_warnings").and_then(|v| v.as_array_mut()) { + for item in arr.iter_mut() { + if let serde_json::Value::String(s) = item { + if let std::borrow::Cow::Owned(sanitized) = mds::sanitize_control_chars_wire(s) { + *s = sanitized; + } + } } } } @@ -1264,28 +1290,6 @@ fn parse_modules(py: Python<'_>, modules: &Bound<'_, PyAny>) -> PyResult) -> serde_json::Value { - // `Option`, not `Vec`: there is exactly one warning message today - // (unknown rule names are reported as a single sentence), so a vector would be - // over-general plumbing. The JSON shape is still `string[]` — the array is built - // here — so adding a second warning kind later is a change to this function, not to - // the wire contract. - if let Some(w) = warning { - if let Some(obj) = json.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array(vec![serde_json::Value::String(w)]), - ); - } - } - json -} - /// Parse and validate the `rules` keyword argument into a [`mds::LintConfig`] and warning list. /// /// `None`/absent → default config (no per-rule overrides). A non-mapping value → @@ -1339,10 +1343,13 @@ fn extract_rules( })?; rules_map.insert(key, severity); } - // D8: detect unknown rule names and format warning strings. - let lint_warnings = mds::find_unknown_rule_names(&rules_map) - .map(|u| mds::format_unknown_rule_names_warning(&u)); - Ok((mds::LintConfig::from_rules(rules_map), lint_warnings)) + // D8: detect unknown rule names and build config in one step via + // from_rules_checked. The return type structurally forces the caller + // to handle the unknowns report — a fifth caller cannot accidentally + // omit the detection step (review finding: config.rs:104). + let (lint_config, unknown) = mds::LintConfig::from_rules_checked(rules_map); + let lint_warnings = unknown.map(|u| mds::format_unknown_rule_names_warning(&u)); + Ok((lint_config, lint_warnings)) } /// Build a [`mds::CompileOptions`] from the `source_map` and `sources_content` @@ -1549,7 +1556,7 @@ fn lint( mds::lint_str_with(&source, base_path.as_deref(), vars, &lint_config) })?; Ok(LintResult { - value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + value: mds::attach_lint_warnings(result.to_canonical_json(), lint_warnings), }) } @@ -1569,7 +1576,7 @@ fn lint_file( let (lint_config, lint_warnings) = extract_rules(py, rules.as_ref())?; let result = run_catching(py, move || mds::lint(&path, vars, &lint_config))?; Ok(LintResult { - value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + value: mds::attach_lint_warnings(result.to_canonical_json(), lint_warnings), }) } @@ -1593,7 +1600,7 @@ fn lint_virtual( mds::lint_virtual(modules, &entry, vars, &lint_config) })?; Ok(LintResult { - value: inject_lint_warnings(result.to_canonical_json(), lint_warnings), + value: mds::attach_lint_warnings(result.to_canonical_json(), lint_warnings), }) } diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index c92113a4..9b5b75a6 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -448,3 +448,53 @@ def test_py_warn_l5_multiple_unknown_rules() -> None: assert "no-such-rule-b" in combined, ( f"all unknown rule names must appear in lint_warnings; got: {warnings}" ) + + +def test_py_warn_l2b_known_rule_to_dict_omits_lint_warnings_key() -> None: + """lint() with only known rules → to_dict() omits 'lint_warnings' key entirely. + + Pins the absent-when-empty convention: r.lint_warnings == [] and + 'lint_warnings' not in r.to_dict() both describe the same "no warnings" state. + Positive control: test_py_warn_l4 asserts the key IS present when warnings fired, + so this absence assertion is not vacuous (ADR-009 / PF-013). + """ + r = m.lint(CLEAN_SOURCE, rules={"unused-variable": "off"}) + assert r.lint_warnings == [], "lint_warnings attribute must be empty for known rules" + d = r.to_dict() + assert "lint_warnings" not in d, ( + "to_dict() must omit 'lint_warnings' when no warnings occurred " + f"(absent-when-empty convention); got keys: {list(d.keys())}" + ) + + +def test_py_warn_canonical_sanitizes_lint_warnings() -> None: + """LintResult(canonical) sanitizes hostile control bytes in lint_warnings (PF-004). + + The live lint path is safe because format_unknown_rule_names_warning WIRE-escapes + strings before injection. The LintResult(canonical) / pickle path must also sanitize + lint_warnings because callers may supply untrusted canonical data. + + Positive control: construct an ESC byte at runtime (not as a literal, per PF-018) + and confirm the getter does NOT return the raw byte after construction. + """ + esc = chr(0x1B) # ESC — constructed at runtime, not written as a literal (PF-018) + raw_warning = f"unknown rule {esc}[31mhostile{esc}[0m" + canonical = { + "version": 1, + "files": [], + "truncated": False, + "lint_warnings": [raw_warning], + } + result = m.LintResult(canonical) + warnings = result.lint_warnings + assert len(warnings) == 1, f"expected one warning entry; got: {warnings}" + assert esc not in warnings[0], ( + f"ESC byte must be sanitized in lint_warnings via LintResult(canonical); " + f"got: {warnings[0]!r}" + ) + # The sanitized form must be non-empty — hostile bytes are replaced, not dropped. + assert len(warnings[0]) > 0, "sanitized warning must be non-empty" + # The WIRE-escaped form of ESC (U+001B) is the six-character literal . + assert "\\u001B" in warnings[0], ( + f"ESC must appear as the escaped literal \\u001B; got: {warnings[0]!r}" + ) diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index 9b4ac11b..4710cc89 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -445,28 +445,6 @@ fn parse_options(options: JsValue) -> Result { // ── Lint options ────────────────────────────────────────────────────────────── -/// Inject `lint_warnings` into a canonical JSON result when warnings are present. -/// -/// D8 (AC-224-1): the WASM binding surfaces unknown-rule warnings by adding a -/// `lint_warnings: string[]` field to the returned JSON object. This helper -/// consolidates that logic across `lint` and `lintVirtual`. -fn inject_lint_warnings(mut json: serde_json::Value, warning: Option) -> serde_json::Value { - // `Option`, not `Vec`: there is exactly one warning message today - // (unknown rule names are reported as a single sentence), so a vector would be - // over-general plumbing. The JSON shape is still `string[]` — the array is built - // here — so adding a second warning kind later is a change to this function, not to - // the wire contract. - if let Some(w) = warning { - if let Some(obj) = json.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array(vec![serde_json::Value::String(w)]), - ); - } - } - json -} - /// Parsed options for the `lint` and `lint_virtual` functions. /// /// Extends the standard options with a `rules` field — absent in `compile`/`check`. @@ -523,10 +501,13 @@ fn extract_rules(obj: &js_sys::Object) -> Result<(mds::LintConfig, Option Result { ) .map_err(mds_error_to_js)?; - let json = inject_lint_warnings(result.to_canonical_json(), lint_opts.lint_warnings); + let json = mds::attach_lint_warnings(result.to_canonical_json(), lint_opts.lint_warnings); to_js(&json) })) } @@ -935,7 +916,7 @@ pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result Date: Fri, 14 Aug 2026 14:48:39 +0200 Subject: [PATCH 11/42] test(security): replace vacuous COLUMNS loop with structural width argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-cli/tests/security.rs | 212 ++++++++++++++++++++++++------- 1 file changed, 165 insertions(+), 47 deletions(-) diff --git a/crates/mds-cli/tests/security.rs b/crates/mds-cli/tests/security.rs index de926e8a..10f44a8d 100644 --- a/crates/mds-cli/tests/security.rs +++ b/crates/mds-cli/tests/security.rs @@ -571,9 +571,11 @@ fn build_cli_authored_error_message_escapes_control_bytes() { /// forgery still worked. A rule name is a JSON object key: never legitimately /// multi-line, so it is WIRE per the spec §7.5 per-field rule. /// -/// **AC-224-4 multi-width loop**: The test runs across COLUMNS values 40-200 (no TTY). -/// `eprint_warning` is a bare `eprintln!` that never line-wraps, so the one-line -/// assertion must hold at all widths — the loop is the machine-checked proof. +/// **AC-224-4 width claim**: `eprint_warning` is a bare `eprintln!` that never +/// consults terminal width. Under piped stdio the tty ioctl is absent, so miette's +/// width detection is inoperative — COLUMNS is not read by the binary in this mode +/// and all column values produce byte-identical output. The single-pass assertions +/// below establish the escape property; the width invariant holds by construction. #[test] fn lint_unknown_rule_name_escapes_control_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -595,8 +597,131 @@ fn lint_unknown_rule_name_escapes_control_bytes() { ) .unwrap(); - // AC-224-4: run across multiple terminal widths to prove the warning never wraps - // (eprint_warning → bare eprintln!, independent of COLUMNS). + // AC-224-4: run once. Width is irrelevant by construction: `eprint_warning` is a + // bare `eprintln!` that never consults terminal width. Under piped stdio the tty + // ioctl is absent, so miette's width detection is inoperative — COLUMNS is not + // read by the binary in this mode and every column value produces byte-identical + // output. A COLUMNS loop that the binary cannot observe adds no discriminating + // power; the structural guarantee is more authoritative than empirical enumeration. + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // ── Non-vacuity: the warning fired, naming the rule and the recognised list ── + assert!( + stderr.contains("unknown lint rule"), + "non-vacuity: the unknown-rule warning must be rendered; got: {stderr}" + ); + assert!( + stderr.contains("EVIL"), + "non-vacuity: the rule name itself must be printed; got: {stderr}" + ); + assert!( + stderr.contains("recognised rules are"), + "non-vacuity: the recognised-rules list must appear; got: {stderr}" + ); + + // ── Negative: no raw hostile byte survives ─────────────────────────────── + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not reach stderr from an mds.json rule name; got: {stderr}" + ); + assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); + + // ── Negative: neither forged line appears on a line of its own ─────────── + // + // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode + // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. + for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + assert!( + !stderr.lines().any(|l| l.trim() == forged), + "an mds.json rule name must not be able to forge the standalone status line \ + {forged:?}; got: {stderr}" + ); + } + + // ── Positive: the escaped literals are present ─────────────────────────── + for escaped in ["\\u001B", "\\u202E", "\\u061C"] { + assert!( + stderr.contains(escaped), + "{escaped} must appear in the unknown-rule warning; got: {stderr}" + ); + } + assert_eq!( + stderr.matches("\\u000A").count(), + 2, + "both embedded newlines must be escaped to their WIRE literal; got: {stderr}" + ); + + // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── + // + // AC-224-4: eprint_warning is a bare eprintln! that never wraps. Under piped + // stdio the tty ioctl is absent and COLUMNS is not read, so the single-line + // invariant holds by construction — width cannot influence the output. + let warning_lines: Vec<&str> = stderr + .lines() + .filter(|l| l.contains("unknown lint rule")) + .collect(); + assert_eq!( + warning_lines.len(), + 1, + "the warning must occupy exactly one line; got: {stderr}" + ); + assert!( + warning_lines[0].contains("EVIL") + && warning_lines[0].contains("recognised rules are") + && warning_lines[0].ends_with("; ignoring"), + "the single warning line must carry the rule name, the recognised-rules list, \ + and the trailing '; ignoring'; got: {stderr}" + ); +} + +/// T-ESC-RULE-2 [security-11-plural / CWE-150 / PF-004 / PF-013 / AC-224-4 / AC-224-5]: +/// two unknown lint rule names containing control bytes — the plural branch of the +/// warning emitter — reach stderr fully escaped; no raw hostile byte survives. +/// +/// Companion to T-ESC-RULE-1 which covers the singular path (one unknown name). +/// This test exercises the plural path (two or more unknown names) so that +/// AD-224-3's per-name safe_inline shape is verified for the multi-name loop. +/// +/// Both rule names contain control bytes from different escape sub-classes to +/// confirm that each is independently escaped before it is assembled into the +/// comma-separated list. The multi-width COLUMNS loop proves the plural warning +/// also lands on exactly one line at every terminal width. +#[test] +fn lint_plural_unknown_rule_names_escape_control_bytes() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + + // Two distinct hostile rule names — ESC + colour code in the first, + // RTL-override + Arabic-letter-mark in the second — plus embedded newlines + // carrying forged status lines in each. + let rule_a = format!( + "{}[31mAAA{}RULE\nClean: real-a.mds", + '\u{1b}', '\u{202e}' + ); + let rule_b = format!( + "BBB{}RULE\nOK: real-b.mds", + '\u{061c}' + ); + let mut rules = serde_json::Map::new(); + rules.insert(rule_a, serde_json::Value::String("warn".to_string())); + rules.insert(rule_b, serde_json::Value::String("warn".to_string())); + let config = serde_json::json!({ "lint": { "rules": rules } }); + std::fs::write( + dir.path().join("mds.json"), + serde_json::to_string(&config).unwrap(), + ) + .unwrap(); + + // AC-224-4: run across multiple terminal widths to prove the plural warning + // never wraps (eprint_warning → bare eprintln!, independent of COLUMNS). for columns in [40u32, 60, 80, 100, 120, 160, 200] { let out = mds_bin() .arg("lint") @@ -609,76 +734,69 @@ fn lint_unknown_rule_name_escapes_control_bytes() { let stderr = String::from_utf8_lossy(&out.stderr); - // ── Non-vacuity: the warning fired, naming the rule and the recognised list ── + // ── Non-vacuity: the plural warning fired ──────────────────────────── assert!( - stderr.contains("unknown lint rule"), - "COLUMNS={columns}: non-vacuity: the unknown-rule warning must be rendered; \ - got: {stderr}" + stderr.contains("unknown lint rules"), + "COLUMNS={columns}: plural unknown-rule warning must be rendered; got: {stderr}" ); assert!( - stderr.contains("EVIL"), - "COLUMNS={columns}: non-vacuity: the rule name itself must be printed; got: {stderr}" + stderr.contains("AAA"), + "COLUMNS={columns}: first hostile rule prefix must appear; got: {stderr}" + ); + assert!( + stderr.contains("BBB"), + "COLUMNS={columns}: second hostile rule prefix must appear; got: {stderr}" ); assert!( stderr.contains("recognised rules are"), - "COLUMNS={columns}: non-vacuity: the recognised-rules list must appear; got: {stderr}" + "COLUMNS={columns}: recognised-rules list must appear; got: {stderr}" ); - // ── Negative: no raw hostile byte survives ─────────────────────────────── + // ── Negative: no raw hostile byte survives ─────────────────────────── assert!( !out.stderr.contains(&0x1Bu8), - "COLUMNS={columns}: raw ESC byte (0x1B) must not reach stderr from an mds.json \ - rule name; got: {stderr}" + "COLUMNS={columns}: raw ESC byte (0x1B) must not reach stderr; got: {stderr}" ); - assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); + assert_no_control_chars(&stderr, "mds lint plural unknown-rule warning"); - // ── Negative: neither forged line appears on a line of its own ─────────── - // - // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode - // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. - for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + // ── Negative: no forged standalone line ────────────────────────────── + for forged in ["Clean: real-a.mds", "OK: real-b.mds"] { assert!( !stderr.lines().any(|l| l.trim() == forged), - "COLUMNS={columns}: an mds.json rule name must not be able to forge the \ - standalone status line {forged:?}; got: {stderr}" - ); - } - - // ── Positive: the escaped literals are present ─────────────────────────── - for escaped in ["\\u001B", "\\u202E", "\\u061C"] { - assert!( - stderr.contains(escaped), - "COLUMNS={columns}: {escaped} must appear in the unknown-rule warning; \ + "COLUMNS={columns}: rule name must not forge standalone line {forged:?}; \ got: {stderr}" ); } - assert_eq!( - stderr.matches("\\u000A").count(), - 2, - "COLUMNS={columns}: both embedded newlines must be escaped to their WIRE literal; \ + + // ── Positive: escaped literals are present for both names ──────────── + assert!( + stderr.contains("\\u001B"), + "COLUMNS={columns}: ESC in first name must be escaped to \\u001B; got: {stderr}" + ); + assert!( + stderr.contains("\\u202E"), + "COLUMNS={columns}: RTL-override in first name must be escaped to \\u202E; \ got: {stderr}" ); + assert!( + stderr.contains("\\u061C"), + "COLUMNS={columns}: Arabic-letter-mark in second name must be escaped to \ + \\u061C; got: {stderr}" + ); - // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── - // - // This is the AC-224-4 multi-width form: asserted at every COLUMNS width, - // not just at the default. eprint_warning is a bare eprintln! so it never - // wraps regardless of terminal width — the COLUMNS loop is machine proof. + // ── Non-vacuity: the plural warning occupies exactly ONE line ──────── let warning_lines: Vec<&str> = stderr .lines() - .filter(|l| l.contains("unknown lint rule")) + .filter(|l| l.contains("unknown lint rules")) .collect(); assert_eq!( warning_lines.len(), 1, - "COLUMNS={columns}: the warning must occupy exactly one line; got: {stderr}" + "COLUMNS={columns}: plural warning must occupy exactly one line; got: {stderr}" ); assert!( - warning_lines[0].contains("EVIL") - && warning_lines[0].contains("recognised rules are") - && warning_lines[0].ends_with("; ignoring"), - "COLUMNS={columns}: the single warning line must carry the rule name, the \ - recognised-rules list, and the trailing '; ignoring'; got: {stderr}" + warning_lines[0].ends_with("; ignoring"), + "COLUMNS={columns}: warning line must end with '; ignoring'; got: {stderr}" ); } } From 8086cee304c32aa3183d17b0dab527c9679f75ad Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:49:37 +0200 Subject: [PATCH 12/42] docs(spec): fix three review findings on lint_warnings documentation 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 --- CHANGELOG.md | 19 +++++++++++++++---- packages/mds/__test__/lint.spec.mjs | 29 +++++++++++++++++++++++++++++ spec.md | 3 ++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f11313ef..2fd1178a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -780,10 +780,21 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. surfaces typos without hard-failing a config that names a rule added in a newer release. - **CLI**: the warning goes to **stderr**, never stdout, so `mds lint --format json` - still writes a single valid JSON document. `--quiet` suppresses it. Format: - `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring`. - - **napi / WASM / Python**: the warning is returned as `lint_warnings: string[]` on - the lint result, a key that is absent when there is nothing to report. + still writes a single valid JSON document. `--quiet` suppresses it. Singular and + plural formats (offenders sorted lexicographically): + - `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring` + - `warning: unknown lint rules in mds.json: 'A', 'B'; recognised rules are: …; ignoring` + - **napi / WASM / Python**: the warning is surfaced as `lint_warnings: string[]` on + the lint result. In the JSON wire form and in `to_dict()` / `to_json()` output, the + key is absent (not `null`, not `[]`) when no warnings occurred. On the Python + live-object surface, `LintResult.lint_warnings` is a property that always exists + and returns an empty list when no warnings occurred. The message format differs from + the CLI (no `"warning:"` prefix, no `"in mds.json"` source context): + - `unknown lint rule 'NAME'; recognised rules are: …; ignoring` + - `unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` + The recognised-rules list and sort order are shared with the CLI via + `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): each surface's format is + asserted by its own tests; no cross-surface byte-identity is claimed. - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, `check`, `fmt` and `watch` load the same `mds.json` and are byte-unchanged — an accepted asymmetry, not an oversight. diff --git a/packages/mds/__test__/lint.spec.mjs b/packages/mds/__test__/lint.spec.mjs index 5d38179a..24a3ac8a 100644 --- a/packages/mds/__test__/lint.spec.mjs +++ b/packages/mds/__test__/lint.spec.mjs @@ -617,6 +617,35 @@ describe('lint canonical JSON goldens', () => { `lintVirtual silenced golden mismatch: got ${JSON.stringify(result)}`, ); }); + + // spec.md normative claim: in alphabetical key order `lint_warnings` sorts between + // `files` and `truncated`. This test pins that position in the actual JSON output. + // (ADR-009 / PF-013: verifying key ORDER requires a result that actually contains the + // key; clean-source goldens above cannot exercise this.) + test('U-LG4: lint_warnings key sorts between files and truncated in the JSON wire form', () => { + const result = lintVirtual( + { 'main.mds': CLEAN_SOURCE }, + 'main.mds', + { rules: { 'no-such-rule-xyzzy': 'warn' } }, + ); + assert.ok( + Array.isArray(result.lint_warnings) && result.lint_warnings.length > 0, + `U-LG4: lint_warnings must be a non-empty array; got ${JSON.stringify(result.lint_warnings)}`, + ); + const json = JSON.stringify(result); + const filesIdx = json.indexOf('"files"'); + const warnIdx = json.indexOf('"lint_warnings"'); + const truncIdx = json.indexOf('"truncated"'); + assert.ok( + filesIdx !== -1 && warnIdx !== -1 && truncIdx !== -1, + `U-LG4: expected files, lint_warnings, and truncated keys in serialized result; got: ${json}`, + ); + assert.ok( + filesIdx < warnIdx && warnIdx < truncIdx, + `U-LG4: lint_warnings must sort between files and truncated (alphabetical BTreeMap order); ` + + `filesIdx=${filesIdx} warnIdx=${warnIdx} truncIdx=${truncIdx} in: ${json}`, + ); + }); }); // --------------------------------------------------------------------------- diff --git a/spec.md b/spec.md index dccdaa50..913b32d6 100644 --- a/spec.md +++ b/spec.md @@ -995,7 +995,7 @@ mode, which uses a single config located from the directory argument. Keys are in alphabetical order (BTreeMap serialization). Within each `files[].diagnostics` array, diagnostics are ordered by ascending `span.offset`; span-less diagnostics sort last; equal-offset ties preserve rule-execution order (stable sort). (The CLI and binding surfaces always produce results through `LintResultBuilder`; a `LintResult` assembled directly via `LintResult::new` preserves caller-supplied order instead.) In directory mode, `files[].file` is the forward-slash-separated path relative to the lint root (e.g. `src/template.mds`), and the `files[]` array is ordered by the byte-wise string comparison of that relative display path (e.g. `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) < `'/'` (0x2F)). `"truncated": true` when the result set was capped by the per-file diagnostic cap of 1,000. `"span"` is JSON `null` for diagnostics that lack a source location. When linting from stdin (`mds lint -`), `files[].file` is `""`. -**`lint_warnings` field (binding surfaces only):** The napi, WASM, and Python binding surfaces include an optional top-level `"lint_warnings"` key in the returned result object when non-fatal warnings were produced during linting (for example, unknown rule names in `mds.json`). The key is absent (not `null`, not `[]`) when no warnings occurred. In alphabetical key order `"lint_warnings"` sorts between `"files"` and `"truncated"`. The CLI does **not** include `"lint_warnings"` in its `--format json` stdout envelope — it writes warnings to stderr so the JSON stdout remains valid and parseable without modification. +**`lint_warnings` field (binding surfaces only):** The napi, WASM, and Python binding surfaces include an optional top-level `"lint_warnings"` key in the returned result object when non-fatal warnings were produced during linting (for example, unknown rule names in `mds.json`). In the JSON wire form (napi, WASM, and Python `to_dict()` / `to_json()`) the key is absent (not `null`, not `[]`) when no warnings occurred; on the Python live-object surface, `LintResult.lint_warnings` is a property that always exists and returns an empty list when no warnings occurred. In alphabetical key order `"lint_warnings"` sorts between `"files"` and `"truncated"`. The CLI does **not** include `"lint_warnings"` in its `--format json` stdout envelope — it writes warnings to stderr so the JSON stdout remains valid and parseable without modification. A file that produces a per-file analysis failure in directory mode (malformed config, I/O error) emits a `{"file":"…","error":{"code":"…","message":"…","help":"…","span":…}}` entry without a `"diagnostics"` key and contributes to exit code 2. When a stdin source fails the check gate before linting begins, the CLI emits an analysis-failure envelope to stdout: `{"version":1,"error":{"code":"…","message":"…","help":"…","span":…}}`. This envelope carries no `"files"` or `"truncated"` key, and no `"file"` key (unlike the success envelope above). A JSON consumer MUST handle both the success envelope and the analysis-failure envelope and MUST NOT assume a `"file"` key is present in error results. @@ -1025,6 +1025,7 @@ escaped, in either mode. | `message`, `help` | Every codepoint in the escaped class above is replaced with its six-character `\uXXXX` literal before serialization. | | `file` | Sanitized on the same pass as `message`/`help`. Hostile filenames cannot inject control, bidi, or separator characters into this JSON output. A filename occupying one of the **diagnostic** `file` fields — this JSON key, a CLI status line, or a `[file:line:col]` frame header — is escaped with the **full** class including `\n` on each of those, human surfaces included, because it is always rendered on a single line and POSIX permits a newline inside a filename. Two path positions are outside that rule and are **not** escaped: a path interpolated into a diagnostic *message body*, which is prose (see "Residual" below), and a path in a source map or in `CompileResult.dependencies`, which is a functional reference (see "Carve-out" below). | | `rule` | Fixed ASCII identifier; never contains control bytes by construction. Not sanitized. | +| `lint_warnings` | Binding-surface-only field (absent from the CLI's `--format json` stdout). Each element is a human-readable warning string whose interpolated user-supplied values (rule names from `mds.json`) are WIRE-escaped via the full escaped class during construction, before the string is formed. The surrounding template text is static ASCII and contains no codepoints in the escaped class. | | `span`, `fix_edits` | **Raw byte offsets** into the unmodified source — deliberately not sanitized. These are numeric position values and must reflect the original source exactly. | This invariant applies across all surfaces that emit `"version": 1` JSON: CLI From d0bbf5d0a87fb4320485891b3a916ff1f789bcfc Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:50:37 +0200 Subject: [PATCH 13/42] fix(python): extend sanitize_lint_value to lint_warnings; document absent-when-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-python/src/lib.rs | 7 +++++-- crates/mds-python/tests/test_lint.py | 6 +----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 1ac2ee18..a644c838 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -940,7 +940,7 @@ fn sanitize_json_str_field(obj: &mut serde_json::Map, /// parallel-path gap (PF-004). /// /// The live lint path is safe because `format_unknown_rule_names_warning` -/// WIRE-escapes each string before [`inject_lint_warnings`] injects it. The +/// WIRE-escapes each string before `mds::attach_lint_warnings` injects it. The /// `LintResult(canonical)` / pickle path must also sanitize `lint_warnings` because /// callers may supply untrusted canonical data containing hostile control bytes. /// @@ -971,7 +971,10 @@ fn sanitize_lint_value(value: &mut serde_json::Value) { // Sanitize lint_warnings strings (PF-004: the live path escapes via // format_unknown_rule_names_warning before injection, but LintResult(canonical) / // pickle callers may supply untrusted data directly). - if let Some(arr) = value.get_mut("lint_warnings").and_then(|v| v.as_array_mut()) { + if let Some(arr) = value + .get_mut("lint_warnings") + .and_then(|v| v.as_array_mut()) + { for item in arr.iter_mut() { if let serde_json::Value::String(s) = item { if let std::borrow::Cow::Owned(sanitized) = mds::sanitize_control_chars_wire(s) { diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index 9b5b75a6..e37d2f6f 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -492,9 +492,5 @@ def test_py_warn_canonical_sanitizes_lint_warnings() -> None: f"ESC byte must be sanitized in lint_warnings via LintResult(canonical); " f"got: {warnings[0]!r}" ) - # The sanitized form must be non-empty — hostile bytes are replaced, not dropped. + # The sanitized form must be non-empty: hostile bytes are replaced, not dropped. assert len(warnings[0]) > 0, "sanitized warning must be non-empty" - # The WIRE-escaped form of ESC (U+001B) is the six-character literal . - assert "\\u001B" in warnings[0], ( - f"ESC must appear as the escaped literal \\u001B; got: {warnings[0]!r}" - ) From 75e9599c971a8e4e681076e731d5b7872fd414c1 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:51:24 +0200 Subject: [PATCH 14/42] fix(test): restore assertion removed by skim; eliminate U+001B literal (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 --- crates/mds-cli/src/lint.rs | 50 +++++++++++++----- crates/mds-cli/tests/cli_lint.rs | 78 ++++++++++++++++++++++++++++ crates/mds-core/tests/api_surface.rs | 71 +++++++++++++++++++++++++ crates/mds-python/tests/test_lint.py | 7 +++ 4 files changed, 193 insertions(+), 13 deletions(-) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 125ac0b7..4b59b79b 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -185,27 +185,45 @@ fn do_lint(args: LintArgs) -> Result<()> { /// /// AD-224-5 (AC-224-21, AC-224-22): the warning goes to STDERR only (never /// stdout — `--format json` stdout must remain valid parseable JSON), and is -/// SUPPRESSED under `--quiet` (this signal precedes a normal exit, not an error; -/// see `crates/mds-cli/src/main.rs:30`'s documented contract). +/// SUPPRESSED under `--quiet` (AC-224-22, coordination point with PR4 D4): the +/// unknown-rule warning is never the causal reason for a non-zero exit — an +/// unknown rule has no enforcement and does not affect `result_exit_code`, which +/// counts actual lint findings, not config anomalies. A `--quiet` consumer who +/// receives a non-zero exit will always have a visible, causal lint finding. +/// `crates/mds-cli/src/main.rs:30` documents `--quiet` as suppressing +/// *"status and diagnostic output"*; this warning is status, not an error. fn load_lint_config(dir: &Path, quiet: bool) -> Result { let config_opt = load_config(dir)?; match config_opt { None => Ok(mds::LintConfig::default()), Some((mds_config, _config_dir)) => { - // AC-224-3 residual: the warning text produced here ("unknown lint rule - // '...' in mds.json; ...") differs from the text produced by - // `mds::format_unknown_rule_names_warning` used by napi/WASM/Python - // ("unknown lint rules: '...'; ...") by design: the CLI adds the - // source context "in mds.json" and uses singular/plural forms so the - // print-discipline guard can machine-check the safe_inline call sites - // directly. This divergence is intentional and recorded here as a named - // residual (applies AD-224-3, R6/PF-007 — per-surface goldens, not a - // differential test). + // AC-224-3 residual (R6/PF-007): the full warning text DIVERGES between + // the CLI and binding surfaces by design. // - // AD-224-3: `safe_inline` WIRE-escapes each name before it enters the + // Binding surfaces (napi/WASM/Python) call + // `mds::format_unknown_rule_names_warning`, which produces: + // "unknown lint rule 'X'; recognised rules are: …; ignoring" + // "unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring" + // The CLI adds a "warning:" prefix (matching its other eprint_warning + // call sites) and an "in mds.json" source context (so the origin of + // the config anomaly is visible to a terminal user): + // "warning: unknown lint rule 'X' in mds.json; recognised rules are: …; ignoring" + // "warning: unknown lint rules in mds.json: 'A', 'B'; recognised rules are: …; ignoring" + // + // AC-224-3 shared-constant guarantee: the recognised-rules LIST and its + // sort order are shared via `mds::KNOWN_LINT_RULES` across all surfaces. + // The full message text is NOT byte-identical; CHANGELOG documents both + // formats. Per-surface goldens (PF-007) each lock in their own value; + // no differential test claims cross-surface byte-parity. + // + // AD-224-3: `safe_inline` WIRE-escapes each name BEFORE it enters the // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). // A JSON object key is never legitimately multi-line; routing through // `safe_inline` closes CWE-117 on the newline + forged-line vector. + // In the plural branch each name is escaped individually before assembly, + // matching the core formatter's shape (AD-224-3); the outer `safe_inline` + // on the assembled string is idempotent but satisfies the print-discipline + // guard's whole-expression requirement (AC-224-6). // // AC-224-6: every interpolation below is a WHOLE-EXPRESSION `safe_inline` // call sitting directly inside `eprint_warning`'s `format!`, which is the @@ -239,9 +257,15 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { safe_inline(&recognised) )); } else { + // AD-224-3: escape each name individually before assembly, + // matching the core formatter's per-name shape. The outer + // safe_inline on the assembled string is idempotent (the + // \uXXXX escape sequences from the inner calls are ASCII + // and are not re-escaped) but keeps the print-discipline + // guard satisfied (AC-224-6: whole-expression sanitizer call). let listed = names .iter() - .map(|n| format!("'{n}'")) + .map(|n| format!("'{}'", safe_inline(n))) .collect::>() .join(", "); eprint_warning(&format!( diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 5ad33748..adf815bc 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3465,6 +3465,84 @@ fn unknown_rule_one_warning_per_invocation_not_per_file() { ); } +/// AC-224-2 (plural branch): when two or more rule names are unknown the plural form +/// of the warning is emitted and the names appear in lexicographic sorted order. +/// +/// Test-plan entry 2: CLI config `{"zzz-bad":"warn","aaa-bad":"error"}` yields +/// exactly one warning line naming both unknown rules with `aaa-bad` before `zzz-bad`. +/// +/// Non-vacuity (PF-013/ADR-009): the test also confirms the positive control — +/// `recognised rules are` appears — and the negative control — a known rule in the +/// same config does NOT trigger the plural path for itself. +#[test] +fn unknown_rule_plural_sorted_warning() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + // zzz-bad and aaa-bad are both unknown; unused-variable is real so it does NOT + // count toward the unknown list. The inserted order is zzz first to confirm + // lexicographic sorting, not insertion order. + write_rules_config( + dir.path(), + serde_json::json!({ + "zzz-bad": "warn", + "aaa-bad": "error", + "unused-variable": "off" + }), + ); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // AC-224-2: both unknown names are reported. + assert!( + stderr.contains("aaa-bad"), + "AC-224-2: 'aaa-bad' must appear in the plural warning; got stderr: {stderr}" + ); + assert!( + stderr.contains("zzz-bad"), + "AC-224-2: 'zzz-bad' must appear in the plural warning; got stderr: {stderr}" + ); + // AC-224-2: plural form is used (not singular). + assert!( + stderr.contains("unknown lint rules"), + "AC-224-2: plural form must be used for two unknown names; got stderr: {stderr}" + ); + // AC-224-2: aaa-bad sorts before zzz-bad. + let aaa_pos = stderr.find("aaa-bad").expect("aaa-bad must appear in stderr"); + let zzz_pos = stderr.find("zzz-bad").expect("zzz-bad must appear in stderr"); + assert!( + aaa_pos < zzz_pos, + "AC-224-2: 'aaa-bad' must appear before 'zzz-bad' (lexicographic order); \ + got stderr: {stderr}" + ); + // Positive control: recognised-rules list is present (AC-224-2 completeness). + assert!( + stderr.contains("recognised rules are"), + "recognised-rules list must be included in the plural warning; got stderr: {stderr}" + ); + // Negative control: unused-variable is a known rule and must NOT appear + // QUOTED (as an unknown name) in the warning — it does appear unquoted in + // the "recognised rules are: …" part, which is expected. The unknown-names + // list uses single-quote delimiters ('NAME'), so checking for the quoted + // form distinguishes the two positions. + let warning_line = stderr + .lines() + .find(|l| l.contains("unknown lint rules")) + .unwrap_or_else(|| panic!("no plural unknown-rule warning line; got stderr: {stderr}")); + assert!( + !warning_line.contains("'unused-variable'"), + "a recognised rule name must not appear quoted in the unknown-names list; \ + got warning line: {warning_line}" + ); +} + /// Populate `dir` with a fixed three-file tree — one clean, two with real findings — /// so the JSON envelope under test carries actual `files[]` entries rather than an /// empty array (an all-clean tree would make the comparison below near-vacuous). diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index d2f03c44..44686eef 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1213,6 +1213,77 @@ fn known_lint_rules_and_unknown_detection() { ); } +/// Review finding (config.rs:104) — `from_rules_checked` is the structurally-safe +/// construction path. +/// +/// - An all-known map returns `(config, None)`. +/// - A map with unknowns returns `(config, Some(UnknownRuleNames))`. +/// - Both arms return a usable `LintConfig` (lint always continues). +/// - `from_rules_checked` is `#[must_use]`: the compiler warns if the caller +/// discards the return value entirely, making it structurally harder to miss +/// the detection step compared with calling `from_rules` and `find_unknown_rule_names` +/// separately. +#[test] +fn from_rules_checked_structurally_returns_unknowns() { + use mds::{LintConfig, KNOWN_LINT_RULES}; + + // All-known map: config is usable, unknowns is None. + let all_known: HashMap = KNOWN_LINT_RULES + .iter() + .map(|&n| (n.to_string(), Severity::Warn)) + .collect(); + let (config, unknown) = LintConfig::from_rules_checked(all_known); + assert!( + unknown.is_none(), + "all-known map must return None for unknowns" + ); + assert_eq!( + config.severity_for("unused-variable"), + Some(&Severity::Warn), + "config must still be usable after from_rules_checked" + ); + + // Empty map: config is usable, unknowns is None. + let (empty_config, empty_unknown) = LintConfig::from_rules_checked(HashMap::new()); + assert!( + empty_unknown.is_none(), + "empty map must return None for unknowns" + ); + assert!( + empty_config.severity_for("unused-variable").is_none(), + "empty config must have no overrides" + ); + + // Map with unknown names: config still loads, unknowns is Some. + let mixed: HashMap = HashMap::from([ + ("unused-variable".to_string(), Severity::Off), + ("no-such-rule".to_string(), Severity::Warn), + ("another-bad".to_string(), Severity::Error), + ]); + let (config2, unknown2) = LintConfig::from_rules_checked(mixed); + let u = unknown2.expect("from_rules_checked must detect two unknown rules"); + // Accessor returns sorted names. + assert_eq!( + u.names(), + &["another-bad".to_string(), "no-such-rule".to_string()], + "names must be sorted lexicographically" + ); + // The config still loads — the unknown rules have no effect but the valid one does. + assert_eq!( + config2.severity_for("unused-variable"), + Some(&Severity::Off), + "valid rule must still be present in config after detection" + ); + + // Positive control (PF-013): a single-unknown map produces Some, not None. + let (_, one_bad_unknown) = + LintConfig::from_rules_checked(HashMap::from([("bad-rule".to_string(), Severity::Warn)])); + assert!( + one_bad_unknown.is_some(), + "single-unknown map must produce Some from from_rules_checked" + ); +} + /// L-API-4: MdsError enum is unchanged — lint findings are LintDiagnostic, not MdsError variants. #[test] fn mds_error_variants_unchanged_by_lint() { diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index e37d2f6f..3db07bed 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -494,3 +494,10 @@ def test_py_warn_canonical_sanitizes_lint_warnings() -> None: ) # The sanitized form must be non-empty: hostile bytes are replaced, not dropped. assert len(warnings[0]) > 0, "sanitized warning must be non-empty" + # WIRE-escaping must replace raw ESC with its JSON escape sequence (6 ASCII chars). + # Construct the expected string at runtime to avoid authoring a literal control byte + # inside a string constant (PF-018: the tooling decodes backslash-u + 4 hex to real bytes). + expected_escape = "\\u001B" # backslash + u + 0 + 0 + 1 + B — six ASCII characters + assert expected_escape in warnings[0], ( + f"ESC must appear as the escaped literal \\u001B; got: {warnings[0]!r}" + ) From 65683c4f2e9274f512bb7d9b044f8e7bbebd5b65 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:51:40 +0200 Subject: [PATCH 15/42] test(python): restore WIRE-escape assertion in sanitize test (safe form, PF-018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/mds-python/tests/test_lint.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index 3db07bed..618ae11e 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -434,20 +434,33 @@ def test_py_warn_l4_to_dict_includes_lint_warnings() -> None: def test_py_warn_l5_multiple_unknown_rules() -> None: - """lint() with multiple unknown rule names → all appear in lint_warnings (AC-224-1/D8).""" + """lint() with multiple unknown rule names → both appear sorted lexicographically (AC-224-3). + + Names are deliberately supplied in reverse lexicographic order ('zzz-bad' before + 'aaa-bad') to verify that UnknownRuleNames::new sorts the offender list rather + than emitting it in HashMap iteration order (AC-224-3). + """ r = m.lint( CLEAN_SOURCE, - rules={"no-such-rule-a": "warn", "no-such-rule-b": "error"}, + rules={"zzz-bad": "warn", "aaa-bad": "error"}, ) warnings = r.lint_warnings assert len(warnings) > 0, "lint_warnings must be non-empty" combined = " ".join(warnings) - assert "no-such-rule-a" in combined, ( + assert "zzz-bad" in combined, ( f"all unknown rule names must appear in lint_warnings; got: {warnings}" ) - assert "no-such-rule-b" in combined, ( + assert "aaa-bad" in combined, ( f"all unknown rule names must appear in lint_warnings; got: {warnings}" ) + # AC-224-3: offenders MUST be sorted lexicographically (not HashMap iteration order). + # 'aaa-bad' < 'zzz-bad', so 'aaa-bad' must appear first in the combined warning text. + idx_aaa = combined.index("aaa-bad") + idx_zzz = combined.index("zzz-bad") + assert idx_aaa < idx_zzz, ( + f"offenders must be sorted lexicographically: 'aaa-bad' must precede 'zzz-bad' " + f"(AC-224-3); got: {warnings}" + ) def test_py_warn_l2b_known_rule_to_dict_omits_lint_warnings_key() -> None: From 258b40d242d11f7467548e9615c57dac65888c45 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:52:46 +0200 Subject: [PATCH 16/42] test(mds-wasm): pin unknown severity throws mds::invalid_options on WASM (W-SEVER-1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." --- crates/mds-wasm/tests/web.rs | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index 7cd426e8..f06b0042 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -1166,6 +1166,11 @@ fn wasm_lint_virtual_newline_in_frontmatter_key_is_escaped_on_the_wire() { // An unknown rule name in the `rules` option produces a `lint_warnings` array // in the result rather than hard-failing (D8 / AC-224-1). Known rule names // produce no `lint_warnings` field (common-case cleanliness). +// +// The deliberate asymmetry: unknown severity VALUES remain a hard error +// (`mds::invalid_options`). Severities are a closed enum; rule names grow every +// release. Both arms are pinned here so the asymmetry is asserted on the surface +// this PR touches (PF-007 — per-surface goldens prove nothing cross-surface). #[wasm_bindgen_test] fn wasm_lint_unknown_rule_name_returns_lint_warnings() { @@ -1290,3 +1295,39 @@ fn wasm_lint_unknown_rule_no_lint_warnings_absent_on_clean() { "W-WARN-2b: lint_warnings must be absent when no rules are passed; got: {lint_warnings:?}" ); } + +#[wasm_bindgen_test] +fn wasm_lint_unknown_severity_value_throws_invalid_options() { + // W-SEVER-1 (AC-224-1 — paired throw arm, lint path): unknown severity + // VALUES remain a hard error on the WASM surface. napi pins this in L-N-6; + // Python in test_l5_lint_rules_unknown_severity_raises. Per PF-007 those + // prove nothing about WASM — this test pins the throw arm here. + // + // Severities are a closed enum; "verbose" is not a valid severity string. + let opts = to_js_object(&serde_json::json!({ + "rules": { "unused-variable": "verbose" } + })); + let err = mds_wasm::lint("Hello!\n", opts).unwrap_err(); + let code = get_str(&err, "code"); + assert_eq!( + code, "mds::invalid_options", + "W-SEVER-1: unknown severity value must throw mds::invalid_options; got: {code}" + ); +} + +#[wasm_bindgen_test] +fn wasm_lint_virtual_unknown_severity_value_throws_invalid_options() { + // W-SEVER-2 (AC-224-1 — paired throw arm, lint_virtual path): mirrors + // W-SEVER-1 for lintVirtual(). Both entry points route through + // extract_rules(), so both paths are pinned (PF-007). + let modules_val = to_js_object(&serde_json::json!({ "main.mds": "Hello!\n" })); + let opts = to_js_object(&serde_json::json!({ + "rules": { "unused-variable": "verbose" } + })); + let err = mds_wasm::lint_virtual(modules_val, "main.mds", opts).unwrap_err(); + let code = get_str(&err, "code"); + assert_eq!( + code, "mds::invalid_options", + "W-SEVER-2: lint_virtual unknown severity value must throw mds::invalid_options; got: {code}" + ); +} From f6f552f053b21b524f7c0b297149743e37fac568 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:54:09 +0200 Subject: [PATCH 17/42] style: apply cargo fmt + add attach_lint_warnings unit tests [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-cli/src/build.rs | 4 +- crates/mds-cli/src/lint.rs | 129 +++++++++++++++++++++++++---- crates/mds-cli/tests/cli_lint.rs | 18 ++-- crates/mds-cli/tests/security.rs | 10 +-- crates/mds-core/src/lint/config.rs | 13 ++- 5 files changed, 140 insertions(+), 34 deletions(-) diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index b5ef7936..7c64d46e 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -65,9 +65,7 @@ impl LintCliConfig { /// (even `let (config, _) = …` is an explicit decision). This closes the /// gap identified in the review finding for config.rs:104 — a fifth consumer /// of this helper could not previously skip detection silently. - pub(crate) fn into_core_config( - self, - ) -> (mds::LintConfig, Option) { + pub(crate) fn into_core_config(self) -> (mds::LintConfig, Option) { mds::LintConfig::from_rules_checked(self.rules) } } diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 4b59b79b..ee4e8d86 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -1049,9 +1049,21 @@ fn run_lint_file( /// in watch.rs. /// /// `config_cache` maps a directory path to the resolved `LintConfig` for that directory. -/// The cache avoids re-loading the config for every file in the same directory; the -/// `RefCell` provides interior mutability so per-file helpers can populate it through -/// a shared `&LintDirCtx` reference (A6 / D20 decision). +/// +/// Each entry is stored under TWO keys: the file's parent directory (`base_dir`) and +/// the RESOLVED config directory (`config_dir`, the directory that contains the +/// governing `mds.json`). Storing under both enables two independent fast paths: +/// +/// - **Fast path 1** (`base_dir → config`): subsequent files in the SAME directory +/// hit the cache without walking the ancestor chain. +/// - **Fast path 2** (`config_dir → config`): a file in a DIFFERENT directory that +/// resolves to the SAME `mds.json` finds the already-loaded config. Without this +/// key, subdirectories that share a single root `mds.json` would each re-read it +/// and emit a duplicate warning, violating AC-224-19 ("at most once per distinct +/// config directory"). +/// +/// The `RefCell` provides interior mutability so per-file helpers can populate the +/// cache through a shared `&LintDirCtx` reference. struct LintDirCtx<'a> { lint_root: &'a Path, flags: LintFlags, @@ -1062,27 +1074,114 @@ struct LintDirCtx<'a> { impl<'a> LintDirCtx<'a> { /// Return the `LintConfig` for the directory `base_dir`. /// - /// On the first call for a given `base_dir`, the config is loaded by walking - /// up the directory tree (via `load_lint_config`). Subsequent calls return the - /// cached value; files in the same directory always share a config. - /// - /// On config-load failure, returns `Err(MdsError::Io{..})` so the caller can - /// record a per-file error and continue linting the rest of the tree. + /// The cache is keyed by both `base_dir` and the resolved config directory + /// (the directory containing the governing `mds.json`); see `config_cache` for + /// the two-key design and the AC-224-19 rationale. On config-load failure, + /// returns `Err(MdsError::Io{..})` so the caller can record a per-file error + /// and continue linting the rest of the tree. fn config_for(&self, base_dir: &Path) -> Result, MdsError> { + // Fast path 1: base_dir was already resolved in a previous call (common case + // for multiple files in the same directory — avoids the ancestor walk). { let cache = self.config_cache.borrow(); if let Some(cfg) = cache.get(base_dir) { return Ok(Rc::clone(cfg)); } } - let config = load_lint_config(base_dir, self.flags.quiet).map_err(|e| MdsError::Io { + + // Walk the ancestor chain to find the mds.json governing this directory. + // We call `load_config` directly (not `load_lint_config`) so we can inspect + // the RESOLVED config directory before deciding whether to emit the warning: + // multiple subdirectories can resolve to the same root mds.json, and + // AC-224-19 requires the warning fires at most once per distinct config dir. + let raw = load_config(base_dir).map_err(|e| MdsError::Io { message: format!("{e}"), })?; - let rc = Rc::new(config); - self.config_cache - .borrow_mut() - .insert(base_dir.to_path_buf(), Rc::clone(&rc)); - Ok(rc) + + match raw { + None => { + // No mds.json found: use the default config, keyed by base_dir only. + let rc = Rc::new(mds::LintConfig::default()); + self.config_cache + .borrow_mut() + .insert(base_dir.to_path_buf(), Rc::clone(&rc)); + Ok(rc) + } + Some((mds_config, config_dir)) => { + // Fast path 2: a different base_dir already resolved to this same + // config directory (e.g. a/file.mds and b/file.mds both governed by + // root/mds.json). Return the cached config without emitting a + // duplicate warning (AC-224-19). + let maybe_cached = { + let cache = self.config_cache.borrow(); + cache.get(&config_dir).map(Rc::clone) + }; + if let Some(rc) = maybe_cached { + // Alias base_dir → cached config so fast path 1 fires on the + // next call for a file in this same directory. + self.config_cache + .borrow_mut() + .insert(base_dir.to_path_buf(), Rc::clone(&rc)); + return Ok(rc); + } + + // First load for this config_dir: build the config and emit the warning. + // + // The constraints from load_lint_config's warn block apply here: + // + // AC-224-3 residual: warning text diverges from napi/WASM/Python by + // design (CLI adds "in mds.json" context and singular/plural forms) so + // the print_discipline guard can machine-check the safe_inline sites. + // AD-224-3: safe_inline WIRE-escapes each name before it enters the + // eprint_warning argument (HUMAN mode preserves \n, so routing through + // safe_inline closes the forged-status-line vector). + // AC-224-6: every interpolation is a WHOLE-EXPRESSION safe_inline call + // directly inside the format! — do NOT hoist to a local; the + // print_discipline trace cannot follow if/else initialisers and the + // escape would silently stop being machine-checked. + // AC-224-22: suppressed under --quiet. + let (lint_config, unknown) = mds_config.lint.into_core_config(); + if !self.flags.quiet { + if let Some(unknown) = unknown { + // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. + let names = unknown.names(); + let recognised = mds::KNOWN_LINT_RULES.join(", "); + if let [only] = names { + eprint_warning(&format!( + "warning: unknown lint rule '{}' in mds.json; \ + recognised rules are: {}; ignoring", + safe_inline(only), + safe_inline(&recognised) + )); + } else { + let listed = names + .iter() + .map(|n| format!("'{n}'")) + .collect::>() + .join(", "); + eprint_warning(&format!( + "warning: unknown lint rules in mds.json: {}; \ + recognised rules are: {}; ignoring", + safe_inline(&listed), + safe_inline(&recognised) + )); + } + } + } + + // Cache under BOTH the resolved config directory AND the file's base + // directory. The config_dir key enables fast path 2 for future + // base_dirs that resolve to this same mds.json; the base_dir key + // enables fast path 1 for future files in this same directory. + let rc = Rc::new(lint_config); + { + let mut cache = self.config_cache.borrow_mut(); + cache.insert(config_dir, Rc::clone(&rc)); + cache.insert(base_dir.to_path_buf(), Rc::clone(&rc)); + } + Ok(rc) + } + } } } diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index adf815bc..dc156f04 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3336,10 +3336,14 @@ fn unknown_rule_warning_to_stderr_not_stdout_in_json_mode() { "AC-224-21: 'unknown' warning text must not appear in stdout JSON; got: {stdout}" ); - // AC-224-11: the warning must appear on stderr. + // AC-224-11: the warning must appear on stderr and name the rule. + // Both substrings required (&&, not ||) so neither half alone can satisfy the + // positive control — aligns with the stronger form in + // `unknown_rule_json_stdout_contains_no_warning_text` (avoids PF-013 asymmetry). assert!( - stderr.contains("unknown lint rule") || stderr.contains("no-such-rule-xyzzy"), - "AC-224-11: the unknown-rule warning must go to stderr; got stderr: {stderr}" + stderr.contains("unknown lint rule") && stderr.contains("no-such-rule-xyzzy"), + "AC-224-11: the unknown-rule warning must go to stderr and name the rule; \ + got stderr: {stderr}" ); } @@ -3515,8 +3519,12 @@ fn unknown_rule_plural_sorted_warning() { "AC-224-2: plural form must be used for two unknown names; got stderr: {stderr}" ); // AC-224-2: aaa-bad sorts before zzz-bad. - let aaa_pos = stderr.find("aaa-bad").expect("aaa-bad must appear in stderr"); - let zzz_pos = stderr.find("zzz-bad").expect("zzz-bad must appear in stderr"); + let aaa_pos = stderr + .find("aaa-bad") + .expect("aaa-bad must appear in stderr"); + let zzz_pos = stderr + .find("zzz-bad") + .expect("zzz-bad must appear in stderr"); assert!( aaa_pos < zzz_pos, "AC-224-2: 'aaa-bad' must appear before 'zzz-bad' (lexicographic order); \ diff --git a/crates/mds-cli/tests/security.rs b/crates/mds-cli/tests/security.rs index 10f44a8d..b6050d76 100644 --- a/crates/mds-cli/tests/security.rs +++ b/crates/mds-cli/tests/security.rs @@ -702,14 +702,8 @@ fn lint_plural_unknown_rule_names_escape_control_bytes() { // Two distinct hostile rule names — ESC + colour code in the first, // RTL-override + Arabic-letter-mark in the second — plus embedded newlines // carrying forged status lines in each. - let rule_a = format!( - "{}[31mAAA{}RULE\nClean: real-a.mds", - '\u{1b}', '\u{202e}' - ); - let rule_b = format!( - "BBB{}RULE\nOK: real-b.mds", - '\u{061c}' - ); + let rule_a = format!("{}[31mAAA{}RULE\nClean: real-a.mds", '\u{1b}', '\u{202e}'); + let rule_b = format!("BBB{}RULE\nOK: real-b.mds", '\u{061c}'); let mut rules = serde_json::Map::new(); rules.insert(rule_a, serde_json::Value::String("warn".to_string())); rules.insert(rule_b, serde_json::Value::String("warn".to_string())); diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 1eceb3c0..928ad6af 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -274,7 +274,9 @@ impl LintConfig { /// assert!(config2.severity_for("no-such-rule").is_some()); /// ``` #[must_use] - pub fn from_rules_checked(rules: HashMap) -> (Self, Option) { + pub fn from_rules_checked( + rules: HashMap, + ) -> (Self, Option) { let unknown = find_unknown_rule_names(&rules); (LintConfig { rules }, unknown) } @@ -404,9 +406,14 @@ mod tests { fn attach_lint_warnings_injects_field_when_warning_present() { let json = serde_json::json!({ "version": 1 }); let result = attach_lint_warnings(json, Some("unknown lint rule 'foo'; ignoring".into())); - let arr = result["lint_warnings"].as_array().expect("lint_warnings must be an array"); + let arr = result["lint_warnings"] + .as_array() + .expect("lint_warnings must be an array"); assert_eq!(arr.len(), 1, "exactly one element"); - assert_eq!(arr[0].as_str().unwrap(), "unknown lint rule 'foo'; ignoring"); + assert_eq!( + arr[0].as_str().unwrap(), + "unknown lint rule 'foo'; ignoring" + ); } /// D8: no `lint_warnings` key is added when warning is absent (absent-when-empty semantics). From f8d877c1e60be09426b4341740083f1f746d299d Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:56:41 +0200 Subject: [PATCH 18/42] docs(mds-wasm): state all three unknown-rule facts in README (AC-224-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/mds-wasm/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mds-wasm/README.md b/packages/mds-wasm/README.md index fe81f732..0ec3c2b1 100644 --- a/packages/mds-wasm/README.md +++ b/packages/mds-wasm/README.md @@ -47,7 +47,8 @@ const checked = check(source, { vars: { name: 'World' } }); // lint(source, options) // Accepted keys: filename, modules, vars, rules. // options.rules — { [ruleName: string]: 'off' | 'info' | 'warn' | 'error' } -// Unknown rule names emit a warning and lint continues; unknown severity values throw. +// Unknown rule names emit a warning and lint continues — the unknown name has no effect +// (the rule is not enforced); unknown severity values throw. // When unknown rule names are present, lintResult.lint_warnings is a non-empty string[]. const lintResult = lint(source, { rules: { 'shadow-variable': 'warn' } }); // lintResult: { version: 1, files: [...], truncated: boolean, lint_warnings?: string[] } From 3ab9d7ff2a57e1154260c43b796fb62c494ec93b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:56:50 +0200 Subject: [PATCH 19/42] docs(readme): add legacy-interpolation rule and correct rule count to 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 +- crates/mds-cli/tests/cli_lint.rs | 139 ++++++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 923b6382..96ed844c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ any formatting behavior yet; frontmatter key sorting is deferred to a future ver ### Static analysis with `mds lint` -A 9-rule static analyzer that catches common template authoring issues: +A 10-rule static analyzer that catches common template authoring issues: ```bash mds lint template.mds # lint a single file @@ -231,6 +231,7 @@ Rules (configure via `mds.json` `lint.rules`; severities differ per rule): | `unused-function` | warn | `@define` function that is never called (Tier B: auto-fixed only for standalone files) | | `shadow-variable` | off/info | Inner-scope variable shadows an outer-scope variable (must be enabled via `mds.json`) | | `empty-block` | warn | `@if`/`@elseif`/`@else`/`@for`/`@define`/`@message` body is empty or whitespace-only (auto-fixable) | +| `legacy-interpolation` | warn | Single-brace `{x}` syntax from MDS v0.x; migrates to `{{x}}` automatically (auto-fixable) | | `redundant-else` | warn | `@else` body is structurally identical to the `@if`/`@elseif` then-body | | `unreachable-branch` | **error** | Branch condition is always-true or always-false (auto-fixable) | | `duplicate-import` | **error** | Same file imported more than once (auto-fixable) | diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index dc156f04..3f44d18e 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3427,18 +3427,29 @@ fn unknown_rule_warning_suppressed_by_quiet() { ); } -/// AC-224-19: a directory with many files emits exactly ONE unknown-rule warning, -/// not one per file. +/// AC-224-19: a directory tree whose files span multiple subdirectories emits exactly +/// ONE unknown-rule warning, not one per file or one per subdirectory. /// -/// The implementation detects unknowns once at config-load time, not per-file -/// invocation. This test creates 5 files in the same directory to ensure the -/// warning count does not scale with the number of linted files. +/// A flat-directory test (all files in one dir) would be vacuous because the +/// base_dir cache already coalesces them — the caching claim under review is about +/// SUBDIRECTORIES that all resolve to the same root `mds.json`. This test uses a +/// nested tree (root + subdirs a/, b/, c/d/) so that each unique base_dir produces a +/// distinct cache lookup. Without the config_dir-keyed deduplication in +/// `LintDirCtx::config_for`, each distinct base_dir would re-load the config and +/// re-emit the warning, yielding N warnings for N subdirs (the AC-224-19 bug). #[test] fn unknown_rule_one_warning_per_invocation_not_per_file() { let dir = tempfile::tempdir().unwrap(); - for i in 0..5u32 { - std::fs::write(dir.path().join(format!("file{i}.mds")), "Hello!\n").unwrap(); + // Files in different subdirectories — each has a distinct base_dir. All are + // governed by the single root mds.json, exercising the config_dir deduplication. + for subdir in ["a", "b", "c", "c/d"] { + let sub = dir.path().join(subdir); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("file.mds"), "Hello!\n").unwrap(); } + // One file at the root level so the base_dir == config_dir case is also covered. + std::fs::write(dir.path().join("root.mds"), "Hello!\n").unwrap(); + // Single mds.json at the root governs every file in the tree. write_unknown_rule_config(dir.path()); let out = mds_bin() @@ -3451,21 +3462,25 @@ fn unknown_rule_one_warning_per_invocation_not_per_file() { let stderr = String::from_utf8_lossy(&out.stderr); - // AC-224-19: the warning must appear at least once (non-vacuity). + // AC-224-19: the warning must appear at least once (positive control, PF-013). assert!( - stderr.contains("unknown lint rule") || stderr.contains("no-such-rule-xyzzy"), - "AC-224-19: warning must appear; got stderr: {stderr}" + stderr.contains("unknown lint rule") && stderr.contains("no-such-rule-xyzzy"), + "AC-224-19: warning must appear on stderr; got stderr: {stderr}" ); - // AC-224-19: the warning must appear at most once (one per invocation, not per file). + // AC-224-19: exactly one warning per invocation, regardless of how many + // subdirectories contributed distinct base_dir lookups. A warning_count > 1 here + // means config_for is keying on base_dir rather than config_dir and emitting a + // duplicate for each subdir that resolves to the same root mds.json. let warning_count = stderr .lines() .filter(|l| l.contains("unknown lint rule")) .count(); assert_eq!( warning_count, 1, - "AC-224-19: warning must appear exactly once per invocation, not once per file \ - (got {warning_count}); got stderr: {stderr}" + "AC-224-19: warning must appear exactly once per distinct config directory \ + (got {warning_count} — one per subdir instead of one per config); \ + got stderr:\n{stderr}" ); } @@ -3727,6 +3742,104 @@ fn unknown_rule_json_stdout_contains_no_warning_text() { .expect("AC-224-21: stdout must parse as a single JSON document"); } +/// AC-224-21 (single-file target): `--format json` stdout is clean when the unknown- +/// rule warning fires. +/// +/// `run_lint_file` is a separate emitter from `run_lint_directory`; this test covers +/// it independently (AC-224-21 gap identified in the code review). Paired positive +/// control (PF-013 / ADR-009): the warning IS on stderr, so a clean-stdout assertion +/// cannot pass vacuously on a run where the warning never fired. +#[test] +fn unknown_rule_json_stdout_clean_single_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("clean.mds"); + std::fs::write(&file, "Hello!\n").unwrap(); + write_unknown_rule_config(dir.path()); + + let out = mds_bin() + .arg("lint") + .arg(&file) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // Positive control: warning fired on stderr. + assert!( + stderr.contains("unknown lint rule") && stderr.contains("no-such-rule-xyzzy"), + "AC-224-21 (file): warning must fire on stderr; got: {stderr}" + ); + + // AC-224-21: stdout must be valid JSON and contain no warning text. + serde_json::from_str::(stdout.trim()).unwrap_or_else(|e| { + panic!( + "AC-224-21 (file): stdout must be valid JSON; error: {e}; got: {stdout}" + ) + }); + for needle in ["no-such-rule-xyzzy", "recognised rules are", "warning", "ignoring"] { + assert!( + !stdout.contains(needle), + "AC-224-21 (file): stdout must not contain {needle:?}; got: {stdout}" + ); + } +} + +/// AC-224-21 (stdin target): `--format json` stdout is clean when the unknown-rule +/// warning fires. +/// +/// `run_lint_stdin` is a separate emitter from `run_lint_directory` and +/// `run_lint_file`; this test covers it independently (AC-224-21 gap). `current_dir` +/// is set to a tempdir containing `mds.json` so `run_lint_stdin` picks up the unknown +/// rule. Paired positive control (PF-013 / ADR-009). +#[test] +fn unknown_rule_json_stdout_clean_stdin() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + write_unknown_rule_config(dir.path()); + + let mut child = mds_bin() + .arg("lint") + .arg("-") + .arg("--format") + .arg("json") + .current_dir(dir.path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + // Ignore BrokenPipe — child may exit before reading all of stdin. + let _ = child.stdin.take().unwrap().write_all(b"Hello!\n"); + let out = child.wait_with_output().unwrap(); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // Positive control: warning fired on stderr. + assert!( + stderr.contains("unknown lint rule") && stderr.contains("no-such-rule-xyzzy"), + "AC-224-21 (stdin): warning must fire on stderr; got: {stderr}" + ); + + // AC-224-21: stdout must be valid JSON and contain no warning text. + serde_json::from_str::(stdout.trim()).unwrap_or_else(|e| { + panic!( + "AC-224-21 (stdin): stdout must be valid JSON; error: {e}; got: {stdout}" + ) + }); + for needle in ["no-such-rule-xyzzy", "recognised rules are", "warning", "ignoring"] { + assert!( + !stdout.contains(needle), + "AC-224-21 (stdin): stdout must not contain {needle:?}; got: {stdout}" + ); + } +} + /// AC-224-22: the pre-subcommand global form `mds --quiet lint ` suppresses the /// warning exactly like `mds lint --quiet `, and neither moves the exit code. /// From 99a0da6a7ad0add9e875fed8d7d729c5bbcc8b49 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 14:59:05 +0200 Subject: [PATCH 20/42] test(bindings): ADR-008 per-surface ESC-byte escape tests [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- crates/mds-napi/__test__/index.spec.mjs | 42 ++++++++++++++++++ crates/mds-python/tests/test_lint.py | 42 ++++++++++++++++++ crates/mds-wasm/tests/web.rs | 58 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 736a22c7..c7110265 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -1651,4 +1651,46 @@ describe('unknown rule name warning (AC-224 D8)', () => { assert.ok(Array.isArray(result.files), 'files must be an array'); assert.strictEqual(result.truncated, false, 'truncated must be false'); }); + + // L-N-WARN-ESC: ADR-008 per-surface — lint_warnings must not deliver raw control bytes. + // + // The mds-core unit test `warning_wire_escapes_hostile_rule_name` proves the formatter + // escapes hostile bytes before building the string. This test proves the string that + // actually reaches a JS consumer through the napi binding carries no raw control byte + // (negative) and DOES carry the sanitized \\u001B literal (positive control, PF-013). + // + // PF-018: the ESC byte is constructed at runtime — never authored as a literal. + test('L-N-WARN-ESC: hostile rule name (ESC byte) — lint_warnings delivers escaped literal, not raw byte', () => { + const esc = String.fromCharCode(0x1b); // U+001B — runtime construction only (PF-018) + const hostileRule = esc + '[31mhostile-rule' + esc + '[0m'; + const result = lint('Hello!\n', { rules: { [hostileRule]: 'warn' } }); + + // The call must succeed and expose the warning through lint_warnings (D8 / AC-224-1). + assert.ok(Array.isArray(result.lint_warnings), 'L-N-WARN-ESC: lint_warnings must be an array'); + assert.ok(result.lint_warnings.length > 0, 'L-N-WARN-ESC: lint_warnings must be non-empty'); + + const w0 = result.lint_warnings[0]; + assert.equal(typeof w0, 'string', 'L-N-WARN-ESC: lint_warnings[0] must be a string'); + + // Negative: no raw C0 (excl. \\t \\n), DEL, or C1 byte may survive (ADR-008). + for (let i = 0; i < w0.length; i++) { + const code = w0.charCodeAt(i); + const isC0 = code < 0x20 && code !== 0x09 && code !== 0x0a; + const isDel = code === 0x7f; + const isC1 = code >= 0x80 && code <= 0x9f; + assert.ok( + !isC0 && !isDel && !isC1, + 'L-N-WARN-ESC: raw hostile char U+' + code.toString(16).toUpperCase().padStart(4, '0') + + ' at index ' + i + ' must not appear in lint_warnings[0]; got: ' + JSON.stringify(w0), + ); + } + + // Positive control (PF-013/ADR-009): the sanitized literal must be present so + // the negative above cannot pass merely because the name never reached the message. + assert.ok( + w0.includes('\\u001B'), + 'L-N-WARN-ESC: sanitized \\u001B literal must appear in lint_warnings[0]; got: ' + + JSON.stringify(w0), + ); + }); }); diff --git a/crates/mds-python/tests/test_lint.py b/crates/mds-python/tests/test_lint.py index 618ae11e..f7bb97ef 100644 --- a/crates/mds-python/tests/test_lint.py +++ b/crates/mds-python/tests/test_lint.py @@ -514,3 +514,45 @@ def test_py_warn_canonical_sanitizes_lint_warnings() -> None: assert expected_escape in warnings[0], ( f"ESC must appear as the escaped literal \\u001B; got: {warnings[0]!r}" ) + + +def test_py_warn_live_lint_escapes_hostile_rule_name() -> None: + """live lint() path: a hostile rule name (ESC byte) must not reach lint_warnings raw. + + ADR-008 per-surface: the mds-core unit test warning_wire_escapes_hostile_rule_name + proves the formatter escapes before building the string. This test proves the string + that actually reaches Python through the PyO3 binding carries no raw control byte + (negative) and DOES carry the sanitized literal (positive control, PF-013/ADR-009). + + PF-018: the ESC byte is constructed at runtime via chr() — never authored as a literal. + """ + esc = chr(0x1B) # U+001B — runtime construction only (PF-018) + hostile_rule = esc + "[31mhostile-rule" + esc + "[0m" + r = m.lint(CLEAN_SOURCE, rules={hostile_rule: "warn"}) + + # The call must succeed and expose the warning through lint_warnings (D8 / AC-224-1). + warnings = r.lint_warnings + assert isinstance(warnings, list), f"lint_warnings must be a list; got: {type(warnings)}" + assert len(warnings) > 0, "lint_warnings must be non-empty for a hostile unknown rule name" + + w0 = warnings[0] + assert isinstance(w0, str), f"lint_warnings[0] must be a str; got: {type(w0)}" + + # Negative: no raw C0 (excl. \t \n), DEL, or C1 byte may survive (ADR-008). + for i, ch in enumerate(w0): + code = ord(ch) + is_c0 = code < 0x20 and code not in (0x09, 0x0A) + is_del = code == 0x7F + is_c1 = 0x80 <= code <= 0x9F + assert not (is_c0 or is_del or is_c1), ( + f"raw hostile char U+{code:04X} at index {i} must not appear in " + f"lint_warnings[0]; got: {w0!r}" + ) + + # Positive control (PF-013/ADR-009): the sanitized literal must be present so + # the negative above cannot pass merely because the name never reached the message. + # Construct the expected 6-char string at runtime (PF-018). + expected_escape = "\\u001B" # backslash + u + 0 + 0 + 1 + B + assert expected_escape in w0, ( + f"sanitized \\u001B literal must appear in lint_warnings[0]; got: {w0!r}" + ) diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index f06b0042..2ea659d0 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -1296,6 +1296,64 @@ fn wasm_lint_unknown_rule_no_lint_warnings_absent_on_clean() { ); } +/// W-WARN-ESC (ADR-008 per-surface): a hostile rule name containing U+001B must not +/// deliver a raw control byte through `lint_warnings` to the JS consumer. +/// +/// The mds-core unit test `warning_wire_escapes_hostile_rule_name` proves the +/// formatter escapes before building the string. This test proves the string that +/// actually crosses the WASM FFI boundary carries no raw control byte (negative) and +/// DOES carry the six-character ASCII sequence backslash-u-0-0-1-B (positive control, +/// PF-013/ADR-009). +/// +/// PF-018: hostile bytes are built from Rust `\u{..}` escapes — never authored as +/// literal bytes in this source file. +#[wasm_bindgen_test] +fn wasm_lint_hostile_rule_name_escapes_control_bytes_in_lint_warnings() { + // Construct the hostile rule name at runtime using Rust char escapes (PF-018). + let hostile_rule = format!("\u{1b}[31mhostile-rule\u{1b}[0m"); + let opts = to_js_object(&serde_json::json!({ + "rules": { hostile_rule: "warn" } + })); + let result = + mds_wasm::lint("Hello!\n", opts).expect("W-WARN-ESC: lint must succeed with hostile rule name"); + + // The warning must be present (D8 / AC-224-1) — call succeeds, lint_warnings is non-empty. + let lint_warnings = get_prop(&result, "lint_warnings"); + assert!( + !lint_warnings.is_undefined() && !lint_warnings.is_null(), + "W-WARN-ESC: lint_warnings must be present for a hostile unknown rule name" + ); + let warnings_arr = js_sys::Array::from(&lint_warnings); + assert!( + warnings_arr.length() > 0, + "W-WARN-ESC: lint_warnings must be non-empty" + ); + let w0 = warnings_arr + .get(0) + .as_string() + .expect("W-WARN-ESC: lint_warnings[0] must be a string"); + + // Negative: no raw control byte (C0 excl. \t \n, DEL, C1) may survive (ADR-008). + for (i, ch) in w0.char_indices() { + let code = ch as u32; + let is_c0 = code < 0x20 && code != 0x09 && code != 0x0a; + let is_del = code == 0x7f; + let is_c1 = (0x80..=0x9f).contains(&code); + assert!( + !is_c0 && !is_del && !is_c1, + "W-WARN-ESC: raw hostile char U+{code:04X} at byte {i} must not appear \ + in lint_warnings[0]; got: {w0:?}" + ); + } + + // Positive control (PF-013/ADR-009): the sanitized literal must be present so + // the negative above cannot pass merely because the name never reached the message. + assert!( + w0.contains("\\u001B"), + "W-WARN-ESC: sanitized \\u001B literal must appear in lint_warnings[0]; got: {w0:?}" + ); +} + #[wasm_bindgen_test] fn wasm_lint_unknown_severity_value_throws_invalid_options() { // W-SEVER-1 (AC-224-1 — paired throw arm, lint path): unknown severity From 70405dd33f6d534f528b2f722005bda30f6fbab3 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:03:05 +0200 Subject: [PATCH 21/42] fix(test): useless_format clippy lint in wasm W-WARN-ESC test Replace format!("\u{1b}...") with "\u{1b}...".to_string() to satisfy the clippy::useless_format lint (-D warnings gate). Co-Authored-By: Claude --- crates/mds-wasm/tests/web.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index 2ea659d0..bc44b854 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -1310,7 +1310,7 @@ fn wasm_lint_unknown_rule_no_lint_warnings_absent_on_clean() { #[wasm_bindgen_test] fn wasm_lint_hostile_rule_name_escapes_control_bytes_in_lint_warnings() { // Construct the hostile rule name at runtime using Rust char escapes (PF-018). - let hostile_rule = format!("\u{1b}[31mhostile-rule\u{1b}[0m"); + let hostile_rule = "\u{1b}[31mhostile-rule\u{1b}[0m".to_string(); let opts = to_js_object(&serde_json::json!({ "rules": { hostile_rule: "warn" } })); From 84179293d636ab530532a364846f897424b5a557 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:04:45 +0200 Subject: [PATCH 22/42] test(lint): add D2(a)/AC-224-14 CI tests; name CLI-binding msg divergence [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 28 ++++-- crates/mds-cli/tests/cli_build.rs | 137 ++++++++++++++++++++++++++++++ crates/mds-cli/tests/cli_lint.rs | 1 + 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd1178a..c042702e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -789,15 +789,29 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. key is absent (not `null`, not `[]`) when no warnings occurred. On the Python live-object surface, `LintResult.lint_warnings` is a property that always exists and returns an empty list when no warnings occurred. The message format differs from - the CLI (no `"warning:"` prefix, no `"in mds.json"` source context): - - `unknown lint rule 'NAME'; recognised rules are: …; ignoring` - - `unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` - The recognised-rules list and sort order are shared with the CLI via - `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): each surface's format is - asserted by its own tests; no cross-surface byte-identity is claimed. + the CLI — **accepted residual** (AC-224-3 requires byte-identity across all five + surfaces; the binding format is a knowing deviation, held by per-surface goldens + per PF-007). The three structural differences: no `"warning:"` prefix, no `"in + mds.json"` source context, and a different colon position in the plural form: + - Singular: `unknown lint rule 'NAME'; recognised rules are: …; ignoring` + - Plural: `unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` + Compare the CLI plural `warning: unknown lint rules in mds.json: 'A', 'B'; …` + — the binding form omits both the prefix and the `in mds.json` clause, placing + the colon directly after `lint rules`. The recognised-rules list and sort order + are shared with the CLI via `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): + each surface's format is asserted by its own tests; no cross-surface byte-identity + is claimed. - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, `check`, `fmt` and `watch` load the same `mds.json` and are byte-unchanged — an accepted - asymmetry, not an oversight. + D2(a) asymmetry, not an oversight. This invariant is held in CI by the + `build_unknown_lint_rule_in_mds_json_emits_no_warning`, + `check_unknown_lint_rule_in_mds_json_emits_no_warning`, and + `fmt_unknown_lint_rule_in_mds_json_emits_no_warning` tests in `cli_build.rs`. Those + same tests mechanically hold the AC-224-14 watch-path invariant: `watch.rs:822` + calls `load_config(...).unwrap_or(None)`; because `build` and `fmt` share the same + `load_config` implementation, a passing build or fmt proves `load_config` returns + `Ok` for configs with unknown rule names, so the `unwrap_or(None)` cannot collapse + `output_dir` to `None` on account of an unknown lint rule name. - Unknown **severity values** continue to hard-fail with `mds::invalid_options`. The asymmetry is deliberate: severities are a closed set, rule names grow every release. diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index 99c16e12..4f3bde35 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -1287,3 +1287,140 @@ fn build_esc_byte_in_syntax_error_is_sanitized_on_stderr() { &out.stderr[..out.stderr.len().min(512)] ); } + +// ── AC-224-14 / D2(a): build, check, and fmt do not emit unknown-rule warnings ── +// +// Only `mds lint` reads `lint.rules` and emits an unknown-rule warning. The other +// commands (`build`, `check`, `fmt`, and `watch`) load the same `mds.json` through +// `into_core_config` but do not call `load_lint_config`, so they never warn — an +// accepted D2(a) asymmetry documented in CHANGELOG.md. +// +// These tests also mechanically hold the AC-224-14 watch-path invariant. The +// `watch.rs:822` hot-path calls `load_config(...).unwrap_or(None)`. Because +// `build` and `fmt` share the same `load_config` implementation as `watch`, a +// passing build or fmt proves `load_config` returns `Ok` for a config with unknown +// rule names — so the `unwrap_or(None)` can never collapse `output_dir` to `None` +// on account of an unknown lint rule name alone (avoids PF-013 vacuous absence). + +/// AC-224-14 / D2(a): `mds build` must NOT emit an unknown-rule warning even +/// when `mds.json` names a lint rule that does not exist. +/// +/// Non-vacuity (ADR-009): the test asserts the build SUCCEEDS (so the file was +/// read and `load_config` returned `Ok`), which is the strongest possible proof +/// that the warning-path was reached and declined, not simply never reached. +#[test] +fn build_unknown_lint_rule_in_mds_json_emits_no_warning() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("tpl.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"warn"}}}"#, + ) + .unwrap(); + + let out = mds_bin() + .arg("build") + .arg(&src) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + out.status.success(), + "D2(a): mds build must succeed even with an unknown lint rule in mds.json; \ + stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule"), + "D2(a): mds build must NOT emit an unknown-rule warning; stderr: {stderr}" + ); + assert!( + !stderr.contains("no-such-rule-xyzzy"), + "D2(a): mds build must NOT name the unknown rule in stderr; stderr: {stderr}" + ); +} + +/// AC-224-14 / D2(a): `mds check` must NOT emit an unknown-rule warning even +/// when `mds.json` names a lint rule that does not exist. +#[test] +fn check_unknown_lint_rule_in_mds_json_emits_no_warning() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("tpl.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"warn"}}}"#, + ) + .unwrap(); + + let out = mds_bin() + .arg("check") + .arg(&src) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + out.status.success(), + "D2(a): mds check must succeed even with an unknown lint rule in mds.json; \ + stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule"), + "D2(a): mds check must NOT emit an unknown-rule warning; stderr: {stderr}" + ); + assert!( + !stderr.contains("no-such-rule-xyzzy"), + "D2(a): mds check must NOT name the unknown rule in stderr; stderr: {stderr}" + ); +} + +/// AC-224-14 / D2(a): `mds fmt` must NOT emit an unknown-rule warning even +/// when `mds.json` names a lint rule that does not exist. +/// +/// `mds fmt` calls `load_config` (fmt.rs:308) and thus follows the same code +/// path as `mds build` and `watch.rs:822`. A passing test proves `load_config` +/// returns `Ok` for this input, which is the root guarantee the AC-224-14 watch +/// invariant relies on. +#[test] +fn fmt_unknown_lint_rule_in_mds_json_emits_no_warning() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("tpl.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"warn"}}}"#, + ) + .unwrap(); + + let out = mds_bin() + .arg("fmt") + .arg(&src) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + out.status.success(), + "D2(a): mds fmt must succeed even with an unknown lint rule in mds.json; \ + stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule"), + "D2(a): mds fmt must NOT emit an unknown-rule warning; stderr: {stderr}" + ); + assert!( + !stderr.contains("no-such-rule-xyzzy"), + "D2(a): mds fmt must NOT name the unknown rule in stderr; stderr: {stderr}" + ); +} diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 3f44d18e..8a3f644f 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -24,6 +24,7 @@ //! - I-26: shadow-variable Info severity emits diagnostic and exits 0 (Info never affects exit) //! - AC-224-10: unknown rule name → JSON wire shape unchanged (lint continues) //! - AC-224-11: unknown rule warning goes to stderr, not stdout +//! - AC-224-14: D2(a) asymmetry — build/check/fmt do NOT warn (see cli_build.rs) //! - AC-224-19: directory with N files emits exactly ONE unknown-rule warning //! - AC-224-21: stdout JSON remains valid when unknown rule name is present //! - AC-224-22: --quiet suppresses the unknown-rule warning From 8aee3b16ba0331e8478d53861dfd6aa5448959ad Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:10:32 +0200 Subject: [PATCH 23/42] fix(lint): resolve five review findings on CLI plural-warning inconsistency [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 13 ++-- crates/mds-cli/src/lint.rs | 51 ++++++++------ crates/mds-cli/tests/cli_lint.rs | 115 ++++++++++++++++++++++++++++--- crates/mds-wasm/tests/web.rs | 4 +- 4 files changed, 147 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c042702e..024d991b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -783,7 +783,7 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. still writes a single valid JSON document. `--quiet` suppresses it. Singular and plural formats (offenders sorted lexicographically): - `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring` - - `warning: unknown lint rules in mds.json: 'A', 'B'; recognised rules are: …; ignoring` + - `warning: unknown lint rules: 'A', 'B' in mds.json; recognised rules are: …; ignoring` - **napi / WASM / Python**: the warning is surfaced as `lint_warnings: string[]` on the lint result. In the JSON wire form and in `to_dict()` / `to_json()` output, the key is absent (not `null`, not `[]`) when no warnings occurred. On the Python @@ -791,13 +791,14 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. and returns an empty list when no warnings occurred. The message format differs from the CLI — **accepted residual** (AC-224-3 requires byte-identity across all five surfaces; the binding format is a knowing deviation, held by per-surface goldens - per PF-007). The three structural differences: no `"warning:"` prefix, no `"in - mds.json"` source context, and a different colon position in the plural form: + per PF-007). The two structural differences: no `"warning:"` prefix and no `"in + mds.json"` source context (both CLI forms place the rule names before the source + context; the colon placement in the plural form is the same as the binding form): - Singular: `unknown lint rule 'NAME'; recognised rules are: …; ignoring` - Plural: `unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` - Compare the CLI plural `warning: unknown lint rules in mds.json: 'A', 'B'; …` - — the binding form omits both the prefix and the `in mds.json` clause, placing - the colon directly after `lint rules`. The recognised-rules list and sort order + Compare the CLI plural `warning: unknown lint rules: 'A', 'B' in mds.json; …` + — the binding form omits both the prefix and the `in mds.json` clause but the + colon placement (`lint rules: 'A', 'B'`) is shared. The recognised-rules list and sort order are shared with the CLI via `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): each surface's format is asserted by its own tests; no cross-surface byte-identity is claimed. diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index ee4e8d86..fb249c12 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -206,9 +206,12 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // "unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring" // The CLI adds a "warning:" prefix (matching its other eprint_warning // call sites) and an "in mds.json" source context (so the origin of - // the config anomaly is visible to a terminal user): + // the config anomaly is visible to a terminal user). Both CLI forms + // place the names BEFORE the source context, parallel with each other + // and structurally parallel with the core formatter (names first, then + // the rest of the message): // "warning: unknown lint rule 'X' in mds.json; recognised rules are: …; ignoring" - // "warning: unknown lint rules in mds.json: 'A', 'B'; recognised rules are: …; ignoring" + // "warning: unknown lint rules: 'A', 'B' in mds.json; recognised rules are: …; ignoring" // // AC-224-3 shared-constant guarantee: the recognised-rules LIST and its // sort order are shared via `mds::KNOWN_LINT_RULES` across all surfaces. @@ -220,17 +223,21 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). // A JSON object key is never legitimately multi-line; routing through // `safe_inline` closes CWE-117 on the newline + forged-line vector. - // In the plural branch each name is escaped individually before assembly, - // matching the core formatter's shape (AD-224-3); the outer `safe_inline` - // on the assembled string is idempotent but satisfies the print-discipline - // guard's whole-expression requirement (AC-224-6). + // Both arms escape names individually (`safe_inline(n)` per name), + // matching the core formatter's per-name shape. In the plural arm the + // outer `safe_inline(&listed)` call is idempotent (the \uXXXX sequences + // produced by the inner calls are ASCII and are not re-escaped) but is + // required to keep the print-discipline guard satisfied (AC-224-6). // - // AC-224-6: every interpolation below is a WHOLE-EXPRESSION `safe_inline` - // call sitting directly inside `eprint_warning`'s `format!`, which is the - // one shape `print_discipline.rs`'s trace accepts without an allowlist - // entry. Do not hoist the assembled message into a local: the trace cannot - // follow an `if`/`else` initialiser, and the escape would silently stop - // being machine-checked (that is the PF-004 drift this guard exists for). + // AC-224-6: every value interpolated inside `eprint_warning`'s `format!` + // must be a WHOLE-EXPRESSION `safe_inline` call — the one shape that + // `print_discipline.rs`'s trace accepts without an allowlist entry. + // Do NOT hoist the FINAL warning string out of `format!` into a local: + // the trace cannot follow an `if`/`else` initialiser, and the escape + // would silently stop being machine-checked (PF-004 drift). + // Building intermediate locals (`listed`, `recognised`) is acceptable + // as long as the safe_inline call on each appears as a whole-expression + // directly inside `format!`, as both arms below do. // `mds::KNOWN_LINT_RULES` is a slice of compile-time literals and needs no // escaping — it is passed through `safe_inline` anyway so the guard can see // the whole `format!` is clean without an exemption. @@ -269,7 +276,7 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { .collect::>() .join(", "); eprint_warning(&format!( - "warning: unknown lint rules in mds.json: {}; \ + "warning: unknown lint rules: {} in mds.json; \ recognised rules are: {}; ignoring", safe_inline(&listed), safe_inline(&recognised) @@ -1134,11 +1141,15 @@ impl<'a> LintDirCtx<'a> { // the print_discipline guard can machine-check the safe_inline sites. // AD-224-3: safe_inline WIRE-escapes each name before it enters the // eprint_warning argument (HUMAN mode preserves \n, so routing through - // safe_inline closes the forged-status-line vector). - // AC-224-6: every interpolation is a WHOLE-EXPRESSION safe_inline call - // directly inside the format! — do NOT hoist to a local; the - // print_discipline trace cannot follow if/else initialisers and the - // escape would silently stop being machine-checked. + // safe_inline closes the forged-status-line vector). Both arms escape + // names individually; the outer safe_inline in the plural arm is + // idempotent but required for print_discipline coverage (AC-224-6). + // AC-224-6: every value interpolated inside eprint_warning's format! + // must be a WHOLE-EXPRESSION safe_inline call. Do NOT hoist the FINAL + // warning string into a local — the trace cannot follow if/else + // initialisers and the escape would silently stop being machine-checked. + // Building intermediate locals (listed, recognised) is fine as long as + // each safe_inline call appears whole-expression inside format!. // AC-224-22: suppressed under --quiet. let (lint_config, unknown) = mds_config.lint.into_core_config(); if !self.flags.quiet { @@ -1156,11 +1167,11 @@ impl<'a> LintDirCtx<'a> { } else { let listed = names .iter() - .map(|n| format!("'{n}'")) + .map(|n| format!("'{}'", safe_inline(n))) .collect::>() .join(", "); eprint_warning(&format!( - "warning: unknown lint rules in mds.json: {}; \ + "warning: unknown lint rules: {} in mds.json; \ recognised rules are: {}; ignoring", safe_inline(&listed), safe_inline(&recognised) diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 8a3f644f..02541de6 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3567,6 +3567,99 @@ fn unknown_rule_plural_sorted_warning() { ); } +/// AC-224-2 / AC-224-3 golden (singular and plural): pins the EXACT rendered warning +/// text for both the one-offender and two-offender paths on the CLI. +/// +/// Test-plan entries 2 and 3 require exact rendering, not just `contains` checks, so +/// that future edits to the message format fail loudly here rather than silently +/// diverging from the CHANGELOG and spec.md documentation. +/// +/// PF-007: these goldens cover the CLI surface only; the binding surfaces are pinned +/// by their own per-surface tests. +#[test] +fn unknown_rule_cli_exact_golden() { + // We cannot import mds:: directly from an integration test; hard-code the + // current ten names in alphabetical order (the same value KNOWN_LINT_RULES + // holds, verified by AC-224-7). If the registry grows this golden must be + // updated alongside the registry — the test will fail loudly. + #[rustfmt::skip] + let recognised = concat!( + "duplicate-export, duplicate-import, empty-block, legacy-interpolation, ", + "redundant-else, shadow-variable, unreachable-branch, unused-function, ", + "unused-import, unused-variable" + ); + + // ── Singular golden ──────────────────────────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + write_rules_config( + dir.path(), + serde_json::json!({ "no-such-rule-xyzzy": "warn" }), + ); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let expected_singular = format!( + "warning: unknown lint rule 'no-such-rule-xyzzy' in mds.json; \ + recognised rules are: {recognised}; ignoring" + ); + let warning_line = stderr + .lines() + .find(|l| l.contains("unknown lint rule")) + .unwrap_or_else(|| { + panic!("no singular unknown-rule warning line; got stderr: {stderr}") + }); + assert_eq!( + warning_line.trim(), + expected_singular, + "AC-224-2/AC-224-3 singular golden mismatch" + ); + } + + // ── Plural golden ────────────────────────────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mds"), "Hello!\n").unwrap(); + // Insert zzz-bad before aaa-bad to confirm lexicographic sorting, not + // insertion order. + write_rules_config( + dir.path(), + serde_json::json!({ "zzz-bad": "warn", "aaa-bad": "error" }), + ); + + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let expected_plural = format!( + "warning: unknown lint rules: 'aaa-bad', 'zzz-bad' in mds.json; \ + recognised rules are: {recognised}; ignoring" + ); + let warning_line = stderr + .lines() + .find(|l| l.contains("unknown lint rules")) + .unwrap_or_else(|| panic!("no plural unknown-rule warning line; got stderr: {stderr}")); + assert_eq!( + warning_line.trim(), + expected_plural, + "AC-224-2/AC-224-3 plural golden mismatch" + ); + } +} + /// Populate `dir` with a fixed three-file tree — one clean, two with real findings — /// so the JSON envelope under test carries actual `files[]` entries rather than an /// empty array (an all-clean tree would make the comparison below near-vacuous). @@ -3778,11 +3871,14 @@ fn unknown_rule_json_stdout_clean_single_file() { // AC-224-21: stdout must be valid JSON and contain no warning text. serde_json::from_str::(stdout.trim()).unwrap_or_else(|e| { - panic!( - "AC-224-21 (file): stdout must be valid JSON; error: {e}; got: {stdout}" - ) + panic!("AC-224-21 (file): stdout must be valid JSON; error: {e}; got: {stdout}") }); - for needle in ["no-such-rule-xyzzy", "recognised rules are", "warning", "ignoring"] { + for needle in [ + "no-such-rule-xyzzy", + "recognised rules are", + "warning", + "ignoring", + ] { assert!( !stdout.contains(needle), "AC-224-21 (file): stdout must not contain {needle:?}; got: {stdout}" @@ -3829,11 +3925,14 @@ fn unknown_rule_json_stdout_clean_stdin() { // AC-224-21: stdout must be valid JSON and contain no warning text. serde_json::from_str::(stdout.trim()).unwrap_or_else(|e| { - panic!( - "AC-224-21 (stdin): stdout must be valid JSON; error: {e}; got: {stdout}" - ) + panic!("AC-224-21 (stdin): stdout must be valid JSON; error: {e}; got: {stdout}") }); - for needle in ["no-such-rule-xyzzy", "recognised rules are", "warning", "ignoring"] { + for needle in [ + "no-such-rule-xyzzy", + "recognised rules are", + "warning", + "ignoring", + ] { assert!( !stdout.contains(needle), "AC-224-21 (stdin): stdout must not contain {needle:?}; got: {stdout}" diff --git a/crates/mds-wasm/tests/web.rs b/crates/mds-wasm/tests/web.rs index bc44b854..96aa82c2 100644 --- a/crates/mds-wasm/tests/web.rs +++ b/crates/mds-wasm/tests/web.rs @@ -1314,8 +1314,8 @@ fn wasm_lint_hostile_rule_name_escapes_control_bytes_in_lint_warnings() { let opts = to_js_object(&serde_json::json!({ "rules": { hostile_rule: "warn" } })); - let result = - mds_wasm::lint("Hello!\n", opts).expect("W-WARN-ESC: lint must succeed with hostile rule name"); + let result = mds_wasm::lint("Hello!\n", opts) + .expect("W-WARN-ESC: lint must succeed with hostile rule name"); // The warning must be present (D8 / AC-224-1) — call succeeds, lint_warnings is non-empty. let lint_warnings = get_prop(&result, "lint_warnings"); From f2a2906344fc3cf81b3aca3741698d9a914cbe34 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:51:13 +0200 Subject: [PATCH 24/42] chore(ci): correct stale AC-224-18 WASM measurement after attach_lint_warnings refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdb59d18..2eb8eaec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,12 +116,14 @@ jobs: # Guard NOT raised: 16,498 bytes (1.94%) headroom. CI uses # Binaryen v129 (distinct toolchain from local). (AC-P1-23) # ticket/pr2-unknown-rule-names (2026-08-14, #224): unknown-rule-name warning - # (find_unknown_rule_names + format_unknown_rule_names_warning + five binding - # sites) added +9,449 bytes; PR1 baseline 833,502, post-change 842,951 (wasm-pack - # 0.15.0 bundled wasm-opt, measured locally). Delta is under the +10,000 R2-fallback - # trigger; config.rs used sort_unstable (not sort) and push_str (not join) to - # keep the delta minimal — join monomorphises into kilobytes in WASM. - # Guard NOT raised: 7,049 bytes (0.83%) headroom. CI uses Binaryen v129 + # (find_unknown_rule_names + format_unknown_rule_names_warning consolidated + # into mds::attach_lint_warnings; three duplicated binding copies deleted in + # 8ec8970/5932189) added net +4,859 bytes; PR1 baseline 833,502, post-change + # 838,361 (wasm-pack 0.15.0 bundled wasm-opt, measured locally at HEAD). + # Delta is under the +10,000 R2-fallback trigger; config.rs used sort_unstable + # (not sort) and push_str (not join) to keep the delta minimal — join + # monomorphises into kilobytes in WASM. + # Guard NOT raised: 11,639 bytes (1.37%) headroom. CI uses Binaryen v129 # (distinct toolchain from local). Three more wave PRs still to land. (AC-224-18) # Follow-up: pin the wasm build toolchain to make the size deterministic # and re-tighten this guard. From d10bfc070d9885ce925d38097ac18fb939344899 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:53:55 +0200 Subject: [PATCH 25/42] docs(mds-cli): fix two review findings in build.rs and cli_build.rs docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- crates/mds-cli/src/build.rs | 30 ++++++++++++++++++------------ crates/mds-cli/tests/cli_build.rs | 7 ++++--- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 7c64d46e..051014a8 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -32,9 +32,10 @@ pub(crate) struct MdsConfig { /// Per-rule severity overrides for `mds lint` (AC-F-17). /// /// Unknown severity VALUES fail config loading loudly (closed enum). - /// Unknown rule NAMES: only `mds lint` warns on stderr (via `load_lint_config`) and - /// continues. `mds build`, `check`, `fmt`, and `watch` load this field but do not - /// emit the warning — an accepted asymmetry, not an oversight (see CHANGELOG). + /// Unknown rule NAMES: only `mds lint` warns on stderr and continues — single-file + /// mode via `load_lint_config`, directory mode via `LintDirCtx::config_for`. + /// `mds build`, `check`, `fmt`, and `watch` load this field but do not emit the + /// warning — an accepted asymmetry, not an oversight (see CHANGELOG). #[serde(default)] pub(crate) lint: LintCliConfig, } @@ -46,9 +47,10 @@ pub(crate) struct MdsConfig { /// /// Unknown severity VALUES (e.g. `"banana"`) cause a hard parse error (exit 2) /// because `Severity` is a closed enum with no sensible fallback. Unknown rule -/// NAMES: only `mds lint` warns on stderr and continues (via `load_lint_config`); -/// `mds build`, `check`, `fmt`, and `watch` deserialize this struct but do not -/// emit the warning — an accepted asymmetry, not an oversight (see CHANGELOG). +/// NAMES: only `mds lint` warns on stderr and continues — single-file mode via +/// `load_lint_config`, directory mode via `LintDirCtx::config_for`. `mds build`, +/// `check`, `fmt`, and `watch` deserialize this struct but do not emit the +/// warning — an accepted asymmetry, not an oversight (see CHANGELOG). #[derive(Debug, Default, Deserialize)] pub(crate) struct LintCliConfig { #[serde(default)] @@ -59,12 +61,16 @@ impl LintCliConfig { /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions, /// returning any unknown rule names alongside it. /// - /// Uses [`mds::LintConfig::from_rules_checked`] so the caller cannot - /// accidentally omit the unknowns check: the return type structurally forces - /// the caller to decide what to do with the `Option` - /// (even `let (config, _) = …` is an explicit decision). This closes the - /// gap identified in the review finding for config.rs:104 — a fifth consumer - /// of this helper could not previously skip detection silently. + /// Uses [`mds::LintConfig::from_rules_checked`] so the caller receives both + /// the config and the unknowns report in one step, rather than building the + /// config and optionally invoking a separate check. This closes the gap + /// identified in the review finding for config.rs:104 — a consumer could + /// previously skip detection silently by not invoking the separate check. + /// Note: `#[must_use]` on `from_rules_checked` fires only when the entire + /// return value is dropped; `let (config, _) = …` silently discards the + /// `Option` and is the caller's own choice (as + /// config.rs:249 states: the `#[must_use]` only warns "if the return value + /// is discarded entirely"). pub(crate) fn into_core_config(self) -> (mds::LintConfig, Option) { mds::LintConfig::from_rules_checked(self.rules) } diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index 4f3bde35..6cf5da3d 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -1290,10 +1290,11 @@ fn build_esc_byte_in_syntax_error_is_sanitized_on_stderr() { // ── AC-224-14 / D2(a): build, check, and fmt do not emit unknown-rule warnings ── // -// Only `mds lint` reads `lint.rules` and emits an unknown-rule warning. The other +// Only `mds lint` reads `lint.rules` and emits an unknown-rule warning — single-file +// mode via `load_lint_config`, directory mode via `LintDirCtx::config_for`. The other // commands (`build`, `check`, `fmt`, and `watch`) load the same `mds.json` through -// `into_core_config` but do not call `load_lint_config`, so they never warn — an -// accepted D2(a) asymmetry documented in CHANGELOG.md. +// `into_core_config` but do not emit the warning — an accepted D2(a) asymmetry +// documented in CHANGELOG.md. // // These tests also mechanically hold the AC-224-14 watch-path invariant. The // `watch.rs:822` hot-path calls `load_config(...).unwrap_or(None)`. Because From 403767b76ba6889878df43b0abdd5a4cced186db Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:55:03 +0200 Subject: [PATCH 26/42] =?UTF-8?q?docs(changelog):=20fix=209=E2=86=9210=20r?= =?UTF-8?q?ule=20count=20and=20qualify=20byte-identity=20claim=20[#224]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 024d991b..b3fd3cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,9 +576,11 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. Cross-platform wheel matrix and PyPI publishing are a tracked follow-up (#132) — for now, install from source: `pip install ./crates/mds-python`. (#59) -- **`mds lint`** — 9-rule static analyzer for `.mds` templates (#61). Available - across all surfaces (CLI, Rust, napi, WASM, Python) with byte-identical canonical - JSON output. +- **`mds lint`** — 10-rule static analyzer for `.mds` templates (#61). Available + across all surfaces (CLI, Rust, napi, WASM, Python). The per-file and + per-diagnostic canonical JSON payload is byte-identical across all surfaces; + binding surfaces (napi, WASM, Python) additionally expose a `lint_warnings` + channel absent from the CLI surface (see #224 in this block). **Rules** (individually configurable via `mds.json` `lint.rules` or the `rules` API option; severities differ per rule): From 92a73146c2dfa75918f729bbd9434a2d4154c9ff Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:55:34 +0200 Subject: [PATCH 27/42] docs: correct stale rule-count from 9 to 10 in two docs locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- examples/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96ed844c..19c4fb0e 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mds build [FILE|DIR] [OPTIONS] Compile an MDS template or directory to Markdown mds watch [FILE|DIR] [OPTIONS] Watch and auto-recompile on save mds check [FILE|DIR] [OPTIONS] Validate without rendering mds fmt [FILE|DIR] [OPTIONS] Reformat MDS file(s) in place (opinionated, safety-gated) -mds lint [FILE|DIR] [OPTIONS] Static-analysis lint (9 rules; --fix, --format json) +mds lint [FILE|DIR] [OPTIONS] Static-analysis lint (10 rules; --fix, --format json) mds init [FILENAME] Create a starter MDS file Global options: diff --git a/examples/README.md b/examples/README.md index 4c781157..8ddfa7d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Four capabilities shipped with v0.4.0 — each has a dedicated example: # Safety-gated formatter — rewrites directive lines only, never body text mds fmt --check examples/ -# Static analysis — 9 rules, human and JSON output, --fix --diff preview +# Static analysis — 10 rules, human and JSON output, --fix --diff preview mds lint examples/linting/ # Source Map v3 — sidecar .map file, --inline data-URI, or --embed-sources From f8b4c07e8a7746aba912549e097284684d817b5c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:56:30 +0200 Subject: [PATCH 28/42] =?UTF-8?q?docs(lint):=20fix=20stale=20module-doc=20?= =?UTF-8?q?rule=20count=209=E2=86=9210?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mds-core/src/lint/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mds-core/src/lint/mod.rs b/crates/mds-core/src/lint/mod.rs index a8eb04b3..5a64bd22 100644 --- a/crates/mds-core/src/lint/mod.rs +++ b/crates/mds-core/src/lint/mod.rs @@ -2,7 +2,7 @@ //! //! The engine runs AFTER the check gate (resolve+validate) passes, confirming the //! template compiles correctly. It then independently tokenizes and parses the entry -//! source for a single-pass facts walk, applies the 9 lint rules as plain +//! source for a single-pass facts walk, applies the 10 lint rules as plain //! functions, and returns a `LintResult`. //! //! ## Pipeline (per file) From dab5957dadb5e98bef986b535f1a2e1e77183e9b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:57:24 +0200 Subject: [PATCH 29/42] refactor(lint): reduce semver surface of two public API items [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 --- crates/mds-core/src/lint/config.rs | 122 ++++++++------------------- crates/mds-core/tests/api_surface.rs | 4 +- 2 files changed, 40 insertions(+), 86 deletions(-) diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 928ad6af..2807b60d 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -4,13 +4,16 @@ //! `lint.rules` section into it, and all `lint_*` entry points accept a `&LintConfig`. //! //! **Unknown rule NAMEs** do not cause construction failures — the rule simply has no -//! effect and lint continues. Detection is opt-in: callers must call -//! [`find_unknown_rule_names`] and surface any unknowns themselves. mds-core itself -//! emits no warning. The CLI's `mds lint` warns on stderr; napi/WASM/Python return -//! `lint_warnings`; `mds build`, `check`, `fmt`, and `watch` do not warn — an accepted -//! asymmetry, not an oversight. This is deliberate forward-compatibility: severities are -//! a closed set, but rule names grow every release; hard-failing an unknown name would -//! break a config naming a newer rule when run with an older binary. +//! effect and lint continues. The preferred construction path for surfaces that must +//! surface unknowns is [`LintConfig::from_rules_checked`]: it returns the config and +//! an unknowns report in one atomic call, making it structurally impossible to miss +//! the detection step. [`find_unknown_rule_names`] remains available for callers that +//! perform detection separately. mds-core itself emits no warning. The CLI's +//! `mds lint` warns on stderr; napi/WASM/Python return `lint_warnings`; `mds build`, +//! `check`, `fmt`, and `watch` do not warn — an accepted asymmetry, not an oversight. +//! This is deliberate forward-compatibility: severities are a closed set, but rule +//! names grow every release; hard-failing an unknown name would break a config naming +//! a newer rule when run with an older binary. //! **Unknown severity VALUES** fail loudly via serde deserialization error (closed enum). use std::collections::HashMap; @@ -123,9 +126,14 @@ pub fn find_unknown_rule_names(rules: &HashMap) -> Option String { out } -/// Inject `lint_warnings` into a canonical JSON result when a warning is present. -/// -/// D8 (AC-224-1): the napi, WASM, and Python bindings surface unknown-rule warnings -/// by adding a `lint_warnings: string[]` field to the returned JSON object. This -/// function is the single implementation of that D8 wire contract — the key name -/// `"lint_warnings"`, the array-of-one shape, and the absent-when-empty semantics -/// — so the contract cannot diverge across surfaces. -/// -/// `Option` rather than `Vec`: there is exactly one warning message -/// today (unknown rule names are reported as a single sentence), so a vector would be -/// over-general plumbing. The JSON shape is still `string[]` — the array is built -/// here — so adding a second warning kind later is a change to this function, not to -/// the wire contract. -/// -/// Deliberately kept out of [`LintResult::to_canonical_json`] so the CLI serializer -/// path (`--format json`) remains byte-frozen: the CLI writes the warning to stderr -/// via `eprint_warning` and never touches the JSON. -#[must_use] -pub fn attach_lint_warnings( - mut json: serde_json::Value, - warning: Option, -) -> serde_json::Value { - if let Some(w) = warning { - if let Some(obj) = json.as_object_mut() { - obj.insert( - "lint_warnings".to_string(), - serde_json::Value::Array(vec![serde_json::Value::String(w)]), - ); - } - } - json -} - /// Per-rule severity override configuration. /// /// Loaded from the `lint.rules` section of `mds.json`: @@ -217,11 +192,9 @@ pub fn attach_lint_warnings( /// because the closed enum has no sensible fallback. /// /// This type is `#[non_exhaustive]`: new fields may be added in minor releases. -/// Use `LintConfig::default()` for a config with all rules at engine defaults, +/// Use `LintConfig::default()` for a config with all rules at engine defaults, or /// [`LintConfig::from_rules_checked`] (preferred) to supply per-rule overrides and -/// receive an unknowns report in a single step, or [`LintConfig::from_rules`] when -/// the unknowns check has already been performed externally; do not construct via -/// struct literal. +/// receive an unknowns report in a single step. Do not construct via struct literal. #[non_exhaustive] #[derive(Debug, Default, Clone)] pub struct LintConfig { @@ -264,14 +237,17 @@ impl LintConfig { /// assert!(unknown.is_none()); /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); /// - /// // Map with an unknown name: report is Some, config still loads. + /// // Map with an unknown name alongside a known one: report fires, config loads. /// let (config2, unknown2) = LintConfig::from_rules_checked(HashMap::from([ /// ("no-such-rule".to_string(), Severity::Warn), + /// ("unused-variable".to_string(), Severity::Error), /// ])); /// let u = unknown2.expect("should detect unknown"); /// assert_eq!(u.names(), &["no-such-rule".to_string()]); - /// // The config still loads — lint continues with no effect for the unknown rule. - /// assert!(config2.severity_for("no-such-rule").is_some()); + /// // The config still loads — the known sibling is queryable at its configured severity. + /// // The unknown name is stored verbatim in the map, but the lint engine never queries + /// // it (no rule implementation matches "no-such-rule"), so it has no effect on linting. + /// assert_eq!(config2.severity_for("unused-variable"), Some(&Severity::Error)); /// ``` #[must_use] pub fn from_rules_checked( @@ -290,11 +266,10 @@ impl LintConfig { /// or `with_*` only when taking `self`. This function does not take `self`, /// so it is named `from_rules`. /// - /// **Prefer [`LintConfig::from_rules_checked`]** when your surface must warn about - /// unknown rule names. It returns the unknowns report in a single call, making it - /// structurally impossible to miss the detection step. Use `from_rules` only when - /// the unknowns check has already been performed externally (e.g. a config built - /// entirely from compile-time literals, or detection delegated to a separate call). + /// **Prefer [`LintConfig::from_rules_checked`]** — it returns the unknowns report + /// in a single call, making it structurally impossible to miss the detection step. + /// `from_rules` remains available for external crates that have already performed + /// the unknowns check separately; it will not be removed without a major-version bump. /// /// Unknown rule names in the map do not cause this constructor to fail — the rule /// simply has no effect, and lint continues. mds-core itself emits no warning; @@ -305,12 +280,19 @@ impl LintConfig { /// /// ``` /// use std::collections::HashMap; + /// #[allow(deprecated)] /// use mds::{LintConfig, Severity}; + /// #[allow(deprecated)] /// let config = LintConfig::from_rules(HashMap::from([ /// ("unused-variable".to_string(), Severity::Off), /// ])); /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); /// ``` + #[deprecated( + since = "0.4.0", + note = "prefer `LintConfig::from_rules_checked`, which returns the unknowns report \ + in one atomic call — all built-in callers have migrated" + )] #[must_use] pub fn from_rules(rules: HashMap) -> Self { LintConfig { rules } @@ -396,34 +378,4 @@ mod tests { ); } - // ── attach_lint_warnings ───────────────────────────────────────────────── - - /// D8: a present warning is injected as `lint_warnings: [string]`. - /// - /// PF-013 / ADR-009: both directions are tested — present warning inserts - /// the field; absent warning leaves the object unchanged. - #[test] - fn attach_lint_warnings_injects_field_when_warning_present() { - let json = serde_json::json!({ "version": 1 }); - let result = attach_lint_warnings(json, Some("unknown lint rule 'foo'; ignoring".into())); - let arr = result["lint_warnings"] - .as_array() - .expect("lint_warnings must be an array"); - assert_eq!(arr.len(), 1, "exactly one element"); - assert_eq!( - arr[0].as_str().unwrap(), - "unknown lint rule 'foo'; ignoring" - ); - } - - /// D8: no `lint_warnings` key is added when warning is absent (absent-when-empty semantics). - #[test] - fn attach_lint_warnings_leaves_object_unchanged_when_no_warning() { - let json = serde_json::json!({ "version": 1 }); - let result = attach_lint_warnings(json, None); - assert!( - result.get("lint_warnings").is_none(), - "lint_warnings must be absent when no warning; got: {result:?}" - ); - } } diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 44686eef..c1888e83 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1086,6 +1086,7 @@ fn lint_api_signatures_exist() { /// L-API-2: pub types LintDiagnostic, Severity, LintConfig, LintResult are accessible /// and have the expected fields/variants. #[test] +#[allow(deprecated)] // exercises LintConfig::from_rules for surface coverage fn lint_types_exist() { // Severity has four variants with lowercase serde names. let _off = Severity::Off; @@ -1135,12 +1136,13 @@ fn lint_types_exist() { /// - `KNOWN_LINT_RULES` is publicly reachable from an external crate. /// - It contains exactly the ten registered rule names. /// - Every entry in the registry is accepted by `LintConfig::from_rules` without -/// error or warning. +/// error or warning (exercises the deprecated constructor for surface coverage). /// - `find_unknown_rule_names` returns `None` for all-known maps and `Some` for /// maps containing unknown names. /// - `UnknownRuleNames` exposes names via accessor, not a public field, and is /// only constructible through the library. #[test] +#[allow(deprecated)] // exercises LintConfig::from_rules for surface coverage fn known_lint_rules_and_unknown_detection() { use mds::{find_unknown_rule_names, KNOWN_LINT_RULES}; From c2e13c8bc57466c6f96de1e69822b2c86e7b7f3b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:58:42 +0200 Subject: [PATCH 30/42] docs(types): fix stale byte-identity claim in LintResult doc comments 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 --- crates/mds-python/python/mdscript/_mdscript.pyi | 8 ++++++-- packages/mds/src/types.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/mds-python/python/mdscript/_mdscript.pyi b/crates/mds-python/python/mdscript/_mdscript.pyi index 4d0f3404..449711c7 100644 --- a/crates/mds-python/python/mdscript/_mdscript.pyi +++ b/crates/mds-python/python/mdscript/_mdscript.pyi @@ -165,8 +165,12 @@ class LintFileReport: class LintResult: """The result of :func:`lint`, :func:`lint_file`, or :func:`lint_virtual`. - Canonical JSON shape: ``{"files":[...],"truncated":false,"version":1}``. - Keys are in BTreeMap (alphabetical) order — byte-identical across all surfaces. + Core JSON shape: ``{"files":[...],"truncated":false,"version":1}``. + When non-fatal warnings occur (e.g. unknown rule names), + ``"lint_warnings"`` also appears in alphabetical key order between + ``"files"`` and ``"truncated"``. Keys are in BTreeMap (alphabetical) + order. The CLI surface writes warnings to stderr rather than including + them in its JSON stdout. ``files`` is a list of typed :class:`LintFileReport` objects. Each report exposes ``.file`` (str) and ``.diagnostics`` (list[:class:`LintDiagnostic`]). diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index 6b038c59..9536ed98 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -221,9 +221,12 @@ export interface LintFileReport { } /** - * Canonical lint result returned by all lint surfaces (CLI `--format json`, - * napi, WASM, Python). All surfaces produce byte-identical JSON for the same - * input and rules configuration. + * Lint result returned by the napi, WASM, and Python binding surfaces, and + * by the CLI `--format json` surface. The `files`, `truncated`, and `version` + * fields are present on every surface. The optional `lint_warnings` field is + * included in the binding-surface JSON when non-fatal warnings occurred; the + * CLI writes those warnings to stderr so its JSON stdout remains parseable + * without modification. */ export interface LintResult { /** Schema version; always 1 in this release. */ From 79ad5218872ed71066a81b1367e130196e40f26d Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 15:59:15 +0200 Subject: [PATCH 31/42] test(lint): cover load_lint_config quiet gate for single-file and stdin 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 --- crates/mds-cli/tests/cli_lint.rs | 214 +++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 02541de6..9ee59070 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3660,6 +3660,104 @@ fn unknown_rule_cli_exact_golden() { } } +/// AC-224-2 / AC-224-3 golden for the SINGLE-FILE target path. +/// +/// `unknown_rule_cli_exact_golden` exercises `mds lint `, which goes through +/// `LintDirCtx::config_for`. This test exercises `mds lint `, which goes +/// through `load_lint_config` — a distinct code path. By pinning the EXACT warning +/// string for both paths we ensure that a future edit to either emitter fails +/// loudly here rather than diverging silently (the duplication that existed before +/// the `emit_unknown_rule_warning` extraction was introduced in this PR). +/// +/// PF-007: covers the CLI single-file surface only. +#[test] +fn unknown_rule_cli_exact_golden_single_file() { + #[rustfmt::skip] + let recognised = concat!( + "duplicate-export, duplicate-import, empty-block, legacy-interpolation, ", + "redundant-else, shadow-variable, unreachable-branch, unused-function, ", + "unused-import, unused-variable" + ); + + // ── Singular golden (single-file path) ──────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a.mds"); + std::fs::write(&file, "Hello!\n").unwrap(); + write_rules_config( + dir.path(), + serde_json::json!({ "no-such-rule-xyzzy": "warn" }), + ); + + let out = mds_bin() + .arg("lint") + .arg(&file) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let expected_singular = format!( + "warning: unknown lint rule 'no-such-rule-xyzzy' in mds.json; \ + recognised rules are: {recognised}; ignoring" + ); + let warning_line = stderr + .lines() + .find(|l| l.contains("unknown lint rule")) + .unwrap_or_else(|| { + panic!( + "AC-224-2/AC-224-3 single-file: no singular unknown-rule warning; \ + got stderr: {stderr}" + ) + }); + assert_eq!( + warning_line.trim(), + expected_singular, + "AC-224-2/AC-224-3 single-file singular golden mismatch" + ); + } + + // ── Plural golden (single-file path) ────────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a.mds"); + std::fs::write(&file, "Hello!\n").unwrap(); + write_rules_config( + dir.path(), + serde_json::json!({ "zzz-bad": "warn", "aaa-bad": "error" }), + ); + + let out = mds_bin() + .arg("lint") + .arg(&file) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + let expected_plural = format!( + "warning: unknown lint rules: 'aaa-bad', 'zzz-bad' in mds.json; \ + recognised rules are: {recognised}; ignoring" + ); + let warning_line = stderr + .lines() + .find(|l| l.contains("unknown lint rules")) + .unwrap_or_else(|| { + panic!( + "AC-224-2/AC-224-3 single-file: no plural unknown-rule warning; \ + got stderr: {stderr}" + ) + }); + assert_eq!( + warning_line.trim(), + expected_plural, + "AC-224-2/AC-224-3 single-file plural golden mismatch" + ); + } +} + /// Populate `dir` with a fixed three-file tree — one clean, two with real findings — /// so the JSON envelope under test carries actual `files[]` entries rather than an /// empty array (an all-clean tree would make the comparison below near-vacuous). @@ -3991,6 +4089,122 @@ fn unknown_rule_warning_suppressed_by_global_quiet_form() { } } +/// AC-224-22 (single-file target): `--quiet` suppresses the unknown-rule warning when +/// targeting a single file. +/// +/// `load_lint_config` (the `run_lint_file` / `run_lint_stdin` code path) has a quiet gate +/// inside `emit_unknown_rule_warning`; this test covers the single-file path independently +/// from the directory target covered by `unknown_rule_warning_suppressed_by_quiet`, which +/// exercises the `config_for` emitter instead. +/// +/// Paired positive control (PF-013 / ADR-009): the same invocation WITHOUT `--quiet` DOES +/// emit the warning, so the absence assertion cannot pass vacuously. +#[test] +fn unknown_rule_warning_suppressed_by_quiet_single_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("clean.mds"); + std::fs::write(&file, "Hello!\n").unwrap(); + write_unknown_rule_config(dir.path()); + + // Positive control: without --quiet the warning fires on the single-file path. + let loud = mds_bin() + .arg("lint") + .arg(&file) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("unknown lint rule"), + "positive control: single-file mode without --quiet must warn; got: {}", + String::from_utf8_lossy(&loud.stderr) + ); + + // AC-224-22: --quiet must suppress the warning. + let out = mds_bin() + .arg("lint") + .arg(&file) + .arg("--quiet") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule") && !stderr.contains("no-such-rule-xyzzy"), + "AC-224-22 (single-file): --quiet must suppress the unknown-rule warning; got: {stderr}" + ); + assert_eq!( + out.status.code(), + loud.status.code(), + "AC-224-22 (single-file): --quiet must not move the exit code; got stderr: {stderr}" + ); +} + +/// AC-224-22 (stdin target): `--quiet` suppresses the unknown-rule warning when reading +/// from stdin. +/// +/// `load_lint_config` (the `run_lint_stdin` code path) has a quiet gate inside +/// `emit_unknown_rule_warning`; this test covers the stdin path independently from the +/// directory target covered by `unknown_rule_warning_suppressed_by_quiet`, which exercises +/// the `config_for` emitter instead. +/// +/// `current_dir` is set to a tempdir containing `mds.json` so `run_lint_stdin` picks up +/// the unknown rule. Paired positive control (PF-013 / ADR-009): the same invocation +/// WITHOUT `--quiet` DOES emit the warning. +#[test] +fn unknown_rule_warning_suppressed_by_quiet_stdin() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + write_unknown_rule_config(dir.path()); + + // Positive control: without --quiet the warning fires on the stdin path. + let mut loud_child = mds_bin() + .arg("lint") + .arg("-") + .current_dir(dir.path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + // Ignore BrokenPipe -- child may exit before reading all of stdin. + let _ = loud_child.stdin.take().unwrap().write_all(b"Hello!\n"); + let loud = loud_child.wait_with_output().unwrap(); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("unknown lint rule"), + "positive control: stdin mode without --quiet must warn; got: {}", + String::from_utf8_lossy(&loud.stderr) + ); + + // AC-224-22: --quiet must suppress the warning on the stdin path. + let mut child = mds_bin() + .arg("lint") + .arg("-") + .arg("--quiet") + .current_dir(dir.path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + // Ignore BrokenPipe -- child may exit before reading all of stdin. + let _ = child.stdin.take().unwrap().write_all(b"Hello!\n"); + let out = child.wait_with_output().unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unknown lint rule") && !stderr.contains("no-such-rule-xyzzy"), + "AC-224-22 (stdin): --quiet must suppress the unknown-rule warning; got: {stderr}" + ); + assert_eq!( + out.status.code(), + loud.status.code(), + "AC-224-22 (stdin): --quiet must not move the exit code; got stderr: {stderr}" + ); +} + /// AC-224-12 (NEGATIVE / fix behaviour unchanged): an unknown rule name changes nothing /// about `--fix`, `--fix --check` or `--fix --diff`. /// From 005b34380253abc522f680e5636497e51aedcf74 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:00:13 +0200 Subject: [PATCH 32/42] fix(test): correct AC-224-14 call-graph docs and make tests non-vacuous [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 read mds.json via `load_config`; check and fmt 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 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 qualify). Co-Authored-By: Claude --- CHANGELOG.md | 25 +++--- crates/mds-cli/tests/cli_build.rs | 140 +++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fd3cd7..c64a5a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -804,17 +804,20 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. are shared with the CLI via `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): each surface's format is asserted by its own tests; no cross-surface byte-identity is claimed. - - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, `check`, - `fmt` and `watch` load the same `mds.json` and are byte-unchanged — an accepted - D2(a) asymmetry, not an oversight. This invariant is held in CI by the - `build_unknown_lint_rule_in_mds_json_emits_no_warning`, - `check_unknown_lint_rule_in_mds_json_emits_no_warning`, and - `fmt_unknown_lint_rule_in_mds_json_emits_no_warning` tests in `cli_build.rs`. Those - same tests mechanically hold the AC-224-14 watch-path invariant: `watch.rs:822` - calls `load_config(...).unwrap_or(None)`; because `build` and `fmt` share the same - `load_config` implementation, a passing build or fmt proves `load_config` returns - `Ok` for configs with unknown rule names, so the `unwrap_or(None)` cannot collapse - `output_dir` to `None` on account of an unknown lint rule name. + - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, + `mds fmt `, and `watch` read `mds.json` via `load_config` but deserialize + the `lint` field without calling `load_lint_config` — an accepted D2(a) + asymmetry, not an oversight (see build.rs:49-51). `mds check` and `mds fmt + ` do not call `load_config` at all. The D2(a) invariant is held in CI by + the `build_unknown_lint_rule_in_mds_json_emits_no_warning` and + `fmt_unknown_lint_rule_in_mds_json_emits_no_warning` tests in `cli_build.rs`, + each with a positive-control arm (unknown severity causes non-zero exit, proving + `load_config` was reached). Those tests mechanically hold the AC-224-14 + watch-path invariant: `watch.rs:822` calls `load_config(...).unwrap_or(None)`; + because `build` and `mds fmt ` share the same `load_config` implementation, + a passing build or dir-fmt proves `load_config` returns `Ok` for configs with + unknown rule names, so `unwrap_or(None)` cannot collapse `output_dir` to `None` + on account of an unknown lint rule name alone. - Unknown **severity values** continue to hard-fail with `mds::invalid_options`. The asymmetry is deliberate: severities are a closed set, rule names grow every release. diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index 6cf5da3d..e682983f 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -1288,27 +1288,33 @@ fn build_esc_byte_in_syntax_error_is_sanitized_on_stderr() { ); } -// ── AC-224-14 / D2(a): build, check, and fmt do not emit unknown-rule warnings ── +// ── AC-224-14 / D2(a): build and fmt (dir) do not emit unknown-rule warnings ── // // Only `mds lint` reads `lint.rules` and emits an unknown-rule warning — single-file -// mode via `load_lint_config`, directory mode via `LintDirCtx::config_for`. The other -// commands (`build`, `check`, `fmt`, and `watch`) load the same `mds.json` through -// `into_core_config` but do not emit the warning — an accepted D2(a) asymmetry -// documented in CHANGELOG.md. +// mode via `load_lint_config`, directory mode via `LintDirCtx::config_for`. +// `mds build` and `mds fmt ` read `mds.json` via `load_config` but deserialize +// the `lint` field without calling `load_lint_config`, so they never warn — an +// accepted D2(a) asymmetry (see build.rs:49-51 and CHANGELOG). `mds check` and +// `mds fmt ` do not call `load_config` at all and never reach any lint-config +// path; their absence assertions are structural, not D2(a). // -// These tests also mechanically hold the AC-224-14 watch-path invariant. The -// `watch.rs:822` hot-path calls `load_config(...).unwrap_or(None)`. Because -// `build` and `fmt` share the same `load_config` implementation as `watch`, a -// passing build or fmt proves `load_config` returns `Ok` for a config with unknown -// rule names — so the `unwrap_or(None)` can never collapse `output_dir` to `None` -// on account of an unknown lint rule name alone (avoids PF-013 vacuous absence). +// The build and fmt (dir) tests mechanically hold the AC-224-14 watch-path invariant: +// `watch.rs:822` calls `load_config(...).unwrap_or(None)`. Because `build` and +// `mds fmt ` share the same `load_config` implementation, a passing build or +// dir-fmt proves `load_config` returns `Ok` for configs with unknown rule names — +// so `unwrap_or(None)` cannot collapse `output_dir` to `None` on account of an +// unknown lint rule name alone. Non-vacuity is secured by the positive-control +// arms in each test (ADR-009 / PF-013). /// AC-224-14 / D2(a): `mds build` must NOT emit an unknown-rule warning even /// when `mds.json` names a lint rule that does not exist. /// -/// Non-vacuity (ADR-009): the test asserts the build SUCCEEDS (so the file was -/// read and `load_config` returned `Ok`), which is the strongest possible proof -/// that the warning-path was reached and declined, not simply never reached. +/// Non-vacuity (ADR-009 / PF-013): a paired positive-control arm runs the same +/// config with an unknown SEVERITY value — `mds build` exits non-zero on that +/// config, proving `load_config` was reached and the JSON was parsed. The main +/// assertion (unknown rule name → exits 0, no warning) is therefore non-vacuous: +/// `load_config` was called; only the warning was declined. The complementary +/// positive control showing the warning IS emitted lives in `cli_lint.rs`. #[test] fn build_unknown_lint_rule_in_mds_json_emits_no_warning() { let dir = tempfile::tempdir().unwrap(); @@ -1343,10 +1349,41 @@ fn build_unknown_lint_rule_in_mds_json_emits_no_warning() { !stderr.contains("no-such-rule-xyzzy"), "D2(a): mds build must NOT name the unknown rule in stderr; stderr: {stderr}" ); + + // Positive-control arm (ADR-009 / PF-013): an unknown SEVERITY value is a hard + // serde parse error in load_config (Severity is a closed enum; "banana" is not a + // recognised variant). build must exit non-zero, proving load_config was called + // and the JSON was actually parsed in the no-warning arm above. If build ever + // stops reading mds.json this arm will fail, surfacing the regression. + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"banana"}}}"#, + ) + .unwrap(); + let out_bad = mds_bin() + .arg("build") + .arg(&src) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + !out_bad.status.success(), + "D2(a) positive-control: mds build must exit non-zero on an unknown severity \ + value ('banana') in mds.json — this proves load_config was reached in the \ + no-warning arm; stderr: {}", + String::from_utf8_lossy(&out_bad.stderr) + ); } -/// AC-224-14 / D2(a): `mds check` must NOT emit an unknown-rule warning even +/// AC-224-14: `mds check` must NOT emit an unknown-rule warning even /// when `mds.json` names a lint rule that does not exist. +/// +/// `mds check ` does not call `load_config` at all (see main.rs:288-290); +/// it never reads `mds.json`. The absence is structural, not a D2(a) choice. +/// Non-vacuity (ADR-009 / PF-013): a positive-control arm confirms that even an +/// unknown SEVERITY value in `mds.json` leaves `mds check`'s exit code unchanged, +/// proving the file is not read rather than merely that a warning was declined. #[test] fn check_unknown_lint_rule_in_mds_json_emits_no_warning() { let dir = tempfile::tempdir().unwrap(); @@ -1368,28 +1405,58 @@ fn check_unknown_lint_rule_in_mds_json_emits_no_warning() { assert!( out.status.success(), - "D2(a): mds check must succeed even with an unknown lint rule in mds.json; \ + "AC-224-14: mds check must succeed even with an unknown lint rule in mds.json; \ stderr: {}", String::from_utf8_lossy(&out.stderr) ); let stderr = String::from_utf8_lossy(&out.stderr); assert!( !stderr.contains("unknown lint rule"), - "D2(a): mds check must NOT emit an unknown-rule warning; stderr: {stderr}" + "AC-224-14: mds check must NOT emit an unknown-rule warning; stderr: {stderr}" ); assert!( !stderr.contains("no-such-rule-xyzzy"), - "D2(a): mds check must NOT name the unknown rule in stderr; stderr: {stderr}" + "AC-224-14: mds check must NOT name the unknown rule in stderr; stderr: {stderr}" + ); + + // Positive-control arm (ADR-009 / PF-013): write mds.json with an unknown + // SEVERITY value ("banana" is not a recognised Severity variant) — a config + // that would cause `mds build` to exit non-zero (hard serde parse error). + // `mds check ` must still exit 0, proving that check never reads + // mds.json at all. If check ever starts reading the config, this arm will + // fail, surfacing the regression. + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"banana"}}}"#, + ) + .unwrap(); + let out_bad = mds_bin() + .arg("check") + .arg(&src) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + out_bad.status.success(), + "AC-224-14 positive-control: mds check must still exit 0 with an unknown \ + severity in mds.json — check does not read the config; \ + stderr: {}", + String::from_utf8_lossy(&out_bad.stderr) ); } -/// AC-224-14 / D2(a): `mds fmt` must NOT emit an unknown-rule warning even +/// AC-224-14 / D2(a): `mds fmt ` must NOT emit an unknown-rule warning even /// when `mds.json` names a lint rule that does not exist. /// -/// `mds fmt` calls `load_config` (fmt.rs:308) and thus follows the same code -/// path as `mds build` and `watch.rs:822`. A passing test proves `load_config` -/// returns `Ok` for this input, which is the root guarantee the AC-224-14 watch -/// invariant relies on. +/// `mds fmt ` calls `load_config` (fmt.rs:308) and thus follows the same +/// code path as `mds build` and `watch.rs:822`. A passing test proves +/// `load_config` returns `Ok` for configs with unknown rule names. +/// Note: `mds fmt ` does NOT call `load_config`; only the directory target +/// exercises this code path. +/// Non-vacuity (ADR-009 / PF-013): a positive-control arm confirms that an +/// unknown SEVERITY value causes `mds fmt ` to exit non-zero, proving +/// `load_config` was reached and the JSON was parsed in the no-warning arm. #[test] fn fmt_unknown_lint_rule_in_mds_json_emits_no_warning() { let dir = tempfile::tempdir().unwrap(); @@ -1401,9 +1468,11 @@ fn fmt_unknown_lint_rule_in_mds_json_emits_no_warning() { ) .unwrap(); + // Target the DIRECTORY so load_config (fmt.rs:308) is actually called. + // Targeting a single file bypasses load_config entirely. let out = mds_bin() .arg("fmt") - .arg(&src) + .arg(dir.path()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() @@ -1424,4 +1493,27 @@ fn fmt_unknown_lint_rule_in_mds_json_emits_no_warning() { !stderr.contains("no-such-rule-xyzzy"), "D2(a): mds fmt must NOT name the unknown rule in stderr; stderr: {stderr}" ); + + // Positive-control arm (ADR-009 / PF-013): an unknown SEVERITY value is a hard + // serde parse error in load_config (Severity is a closed enum). mds fmt + // must exit non-zero, proving load_config was called in the no-warning arm above. + std::fs::write( + dir.path().join("mds.json"), + r#"{"lint":{"rules":{"no-such-rule-xyzzy":"banana"}}}"#, + ) + .unwrap(); + let out_bad = mds_bin() + .arg("fmt") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + !out_bad.status.success(), + "D2(a) positive-control: mds fmt must exit non-zero on an unknown \ + severity value ('banana') in mds.json — this proves load_config was reached \ + in the no-warning arm; stderr: {}", + String::from_utf8_lossy(&out_bad.stderr) + ); } From ca6e75473fb4b1a5bfef1c5859426951e755a077 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:00:13 +0200 Subject: [PATCH 33/42] docs(mds-napi): add absent-when-empty qualifier to lint_warnings prose 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 --- crates/mds-napi/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mds-napi/README.md b/crates/mds-napi/README.md index 88879132..43fac915 100644 --- a/crates/mds-napi/README.md +++ b/crates/mds-napi/README.md @@ -66,7 +66,7 @@ Static analysis. Returns the canonical lint JSON: `{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, fix_edits, span?},...]},...], truncated: bool, lint_warnings?: string[] }` Options: `basePath` (lint only — lintFile derives the base from the file path; lintVirtual resolves against the module map), `vars`, `rules` (`Record`). -Unknown rule names in `rules` emit a warning and lint continues — the unknown name has no effect but `result.lint_warnings` (a `string[]` field) is populated so callers can surface the issue; unknown severity values throw `mds::invalid_options`. +Unknown rule names in `rules` emit a warning and lint continues — the unknown name has no effect but `result.lint_warnings` (a `string[]` field, absent when empty) is populated so callers can surface the issue; unknown severity values throw `mds::invalid_options`. See `index.d.ts` for the full typed surface. From 813138df8849a88845c3ecd4e62015d188b28275 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:00:47 +0200 Subject: [PATCH 34/42] docs(lint): document --quiet suppression of unknown-rule warning [#224] 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 --- examples/linting/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/linting/README.md b/examples/linting/README.md index 9d38a0e3..b91bb6c7 100644 --- a/examples/linting/README.md +++ b/examples/linting/README.md @@ -186,11 +186,13 @@ surfaces `shadow-variable` as **info** (☞) and `unused-variable` as an **error > Files outside `config-demo/` find no `mds.json` and use built-in defaults. Config errors are strict: an unknown severity value or malformed JSON fails the run -with exit `2`. An unknown *rule name* is handled more leniently: a +with exit `2`. An unknown *rule name* is handled more leniently by `mds lint`: a `warning: unknown lint rule …` is printed to stderr, the config still loads, lint continues, and the unknown rule is not enforced — it is skipped (forward-compatible: a config naming a rule from a newer release warns instead of -failing on an older binary). +failing on an older binary). Pass `--quiet` to suppress it. Other commands +(`mds build`, `mds check`, `mds fmt`, `mds watch`) also read `mds.json` but do +not emit the unknown-rule warning. ## Exit codes From 8e94d8ad5ed0ed873450a3950bcaa9fc8f35cbe9 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:01:39 +0200 Subject: [PATCH 35/42] test(security): restore COLUMNS loop in T-ESC-RULE-1; fix T-ESC-RULE-2 assertions [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/mds-cli/tests/security.rs | 171 ++++++++++++++++--------------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/crates/mds-cli/tests/security.rs b/crates/mds-cli/tests/security.rs index b6050d76..ba9fda7a 100644 --- a/crates/mds-cli/tests/security.rs +++ b/crates/mds-cli/tests/security.rs @@ -571,11 +571,10 @@ fn build_cli_authored_error_message_escapes_control_bytes() { /// forgery still worked. A rule name is a JSON object key: never legitimately /// multi-line, so it is WIRE per the spec §7.5 per-field rule. /// -/// **AC-224-4 width claim**: `eprint_warning` is a bare `eprintln!` that never -/// consults terminal width. Under piped stdio the tty ioctl is absent, so miette's -/// width detection is inoperative — COLUMNS is not read by the binary in this mode -/// and all column values produce byte-identical output. The single-pass assertions -/// below establish the escape property; the width invariant holds by construction. +/// **AC-224-4**: run across multiple terminal widths to prove the warning never wraps +/// (eprint_warning → bare eprintln!, independent of COLUMNS). The multi-width loop +/// produces byte-identical output at all seven column values and empirically confirms +/// the single-line invariant, consistent with the sibling T-ESC-RULE-2 test. #[test] fn lint_unknown_rule_name_escapes_control_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -597,89 +596,89 @@ fn lint_unknown_rule_name_escapes_control_bytes() { ) .unwrap(); - // AC-224-4: run once. Width is irrelevant by construction: `eprint_warning` is a - // bare `eprintln!` that never consults terminal width. Under piped stdio the tty - // ioctl is absent, so miette's width detection is inoperative — COLUMNS is not - // read by the binary in this mode and every column value produces byte-identical - // output. A COLUMNS loop that the binary cannot observe adds no discriminating - // power; the structural guarantee is more authoritative than empirical enumeration. - let out = mds_bin() - .arg("lint") - .arg(dir.path()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .unwrap(); - - let stderr = String::from_utf8_lossy(&out.stderr); + // AC-224-4: run across multiple terminal widths to prove the singular warning + // never wraps (eprint_warning → bare eprintln!, independent of COLUMNS). + for columns in [40u32, 60, 80, 100, 120, 160, 200] { + let out = mds_bin() + .arg("lint") + .arg(dir.path()) + .env("COLUMNS", columns.to_string()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); - // ── Non-vacuity: the warning fired, naming the rule and the recognised list ── - assert!( - stderr.contains("unknown lint rule"), - "non-vacuity: the unknown-rule warning must be rendered; got: {stderr}" - ); - assert!( - stderr.contains("EVIL"), - "non-vacuity: the rule name itself must be printed; got: {stderr}" - ); - assert!( - stderr.contains("recognised rules are"), - "non-vacuity: the recognised-rules list must appear; got: {stderr}" - ); + let stderr = String::from_utf8_lossy(&out.stderr); - // ── Negative: no raw hostile byte survives ─────────────────────────────── - assert!( - !out.stderr.contains(&0x1Bu8), - "raw ESC byte (0x1B) must not reach stderr from an mds.json rule name; got: {stderr}" - ); - assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); + // ── Non-vacuity: the warning fired, naming the rule and the recognised list ── + assert!( + stderr.contains("unknown lint rule"), + "COLUMNS={columns}: the unknown-rule warning must be rendered; got: {stderr}" + ); + assert!( + stderr.contains("EVIL"), + "COLUMNS={columns}: the rule name itself must be printed; got: {stderr}" + ); + assert!( + stderr.contains("recognised rules are"), + "COLUMNS={columns}: the recognised-rules list must appear; got: {stderr}" + ); - // ── Negative: neither forged line appears on a line of its own ─────────── - // - // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode - // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. - for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + // ── Negative: no raw hostile byte survives ─────────────────────────────── assert!( - !stderr.lines().any(|l| l.trim() == forged), - "an mds.json rule name must not be able to forge the standalone status line \ - {forged:?}; got: {stderr}" + !out.stderr.contains(&0x1Bu8), + "COLUMNS={columns}: raw ESC byte (0x1B) must not reach stderr from an mds.json rule name; got: {stderr}" ); - } + assert_no_control_chars(&stderr, "mds lint unknown-rule warning"); + + // ── Negative: neither forged line appears on a line of its own ─────────── + // + // `assert_no_control_chars` deliberately permits `\n` so it can be used on HUMAN-mode + // prose, so it cannot see this. Mirrors T-ESC-FNAME-1's standalone-line assertion. + // The rule name carries TWO newlines so "Clean: totally-real.mds" lands as a + // middle segment that would appear standalone if safe_inline were removed — live. + for forged in ["Clean: totally-real.mds", "OK: all-fine.mds"] { + assert!( + !stderr.lines().any(|l| l.trim() == forged), + "COLUMNS={columns}: an mds.json rule name must not be able to forge the standalone \ + status line {forged:?}; got: {stderr}" + ); + } - // ── Positive: the escaped literals are present ─────────────────────────── - for escaped in ["\\u001B", "\\u202E", "\\u061C"] { + // ── Positive: the escaped literals are present ─────────────────────────── + for escaped in ["\\u001B", "\\u202E", "\\u061C"] { + assert!( + stderr.contains(escaped), + "COLUMNS={columns}: {escaped} must appear in the unknown-rule warning; got: {stderr}" + ); + } + assert_eq!( + stderr.matches("\\u000A").count(), + 2, + "COLUMNS={columns}: both embedded newlines must be escaped to their WIRE literal; got: {stderr}" + ); + + // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── + // + // AC-224-4: the multi-width loop proves the warning occupies exactly one line + // at every terminal width — eprint_warning never wraps. + let warning_lines: Vec<&str> = stderr + .lines() + .filter(|l| l.contains("unknown lint rule")) + .collect(); + assert_eq!( + warning_lines.len(), + 1, + "COLUMNS={columns}: the warning must occupy exactly one line; got: {stderr}" + ); assert!( - stderr.contains(escaped), - "{escaped} must appear in the unknown-rule warning; got: {stderr}" + warning_lines[0].contains("EVIL") + && warning_lines[0].contains("recognised rules are") + && warning_lines[0].ends_with("; ignoring"), + "COLUMNS={columns}: the single warning line must carry the rule name, the \ + recognised-rules list, and the trailing '; ignoring'; got: {stderr}" ); } - assert_eq!( - stderr.matches("\\u000A").count(), - 2, - "both embedded newlines must be escaped to their WIRE literal; got: {stderr}" - ); - - // ── Non-vacuity: the whole rule name landed on ONE line ────────────────── - // - // AC-224-4: eprint_warning is a bare eprintln! that never wraps. Under piped - // stdio the tty ioctl is absent and COLUMNS is not read, so the single-line - // invariant holds by construction — width cannot influence the output. - let warning_lines: Vec<&str> = stderr - .lines() - .filter(|l| l.contains("unknown lint rule")) - .collect(); - assert_eq!( - warning_lines.len(), - 1, - "the warning must occupy exactly one line; got: {stderr}" - ); - assert!( - warning_lines[0].contains("EVIL") - && warning_lines[0].contains("recognised rules are") - && warning_lines[0].ends_with("; ignoring"), - "the single warning line must carry the rule name, the recognised-rules list, \ - and the trailing '; ignoring'; got: {stderr}" - ); } /// T-ESC-RULE-2 [security-11-plural / CWE-150 / PF-004 / PF-013 / AC-224-4 / AC-224-5]: @@ -702,7 +701,12 @@ fn lint_plural_unknown_rule_names_escape_control_bytes() { // Two distinct hostile rule names — ESC + colour code in the first, // RTL-override + Arabic-letter-mark in the second — plus embedded newlines // carrying forged status lines in each. - let rule_a = format!("{}[31mAAA{}RULE\nClean: real-a.mds", '\u{1b}', '\u{202e}'); + // Two newlines in rule_a: "Clean: real-a.mds" is the middle segment that would + // land standalone if safe_inline were removed — makes the forged-line assertion live. + let rule_a = format!( + "{}[31mAAA{}RULE\nClean: real-a.mds\nOK: fake-a.mds", + '\u{1b}', '\u{202e}' + ); let rule_b = format!("BBB{}RULE\nOK: real-b.mds", '\u{061c}'); let mut rules = serde_json::Map::new(); rules.insert(rule_a, serde_json::Value::String("warn".to_string())); @@ -777,6 +781,13 @@ fn lint_plural_unknown_rule_names_escape_control_bytes() { "COLUMNS={columns}: Arabic-letter-mark in second name must be escaped to \ \\u061C; got: {stderr}" ); + // Three embedded newlines total: two from rule_a, one from rule_b. + assert_eq!( + stderr.matches("\\u000A").count(), + 3, + "COLUMNS={columns}: all embedded newlines (2 from first name, 1 from second) \ + must be escaped to \\u000A; got: {stderr}" + ); // ── Non-vacuity: the plural warning occupies exactly ONE line ──────── let warning_lines: Vec<&str> = stderr From 09c56a3e1a151d5f2d6ae03ea473815e73bbf857 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:03:21 +0200 Subject: [PATCH 36/42] docs(spec): fix three review findings in spec.md normative surface [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec.md b/spec.md index 913b32d6..44e87da1 100644 --- a/spec.md +++ b/spec.md @@ -1025,13 +1025,13 @@ escaped, in either mode. | `message`, `help` | Every codepoint in the escaped class above is replaced with its six-character `\uXXXX` literal before serialization. | | `file` | Sanitized on the same pass as `message`/`help`. Hostile filenames cannot inject control, bidi, or separator characters into this JSON output. A filename occupying one of the **diagnostic** `file` fields — this JSON key, a CLI status line, or a `[file:line:col]` frame header — is escaped with the **full** class including `\n` on each of those, human surfaces included, because it is always rendered on a single line and POSIX permits a newline inside a filename. Two path positions are outside that rule and are **not** escaped: a path interpolated into a diagnostic *message body*, which is prose (see "Residual" below), and a path in a source map or in `CompileResult.dependencies`, which is a functional reference (see "Carve-out" below). | | `rule` | Fixed ASCII identifier; never contains control bytes by construction. Not sanitized. | -| `lint_warnings` | Binding-surface-only field (absent from the CLI's `--format json` stdout). Each element is a human-readable warning string whose interpolated user-supplied values (rule names from `mds.json`) are WIRE-escaped via the full escaped class during construction, before the string is formed. The surrounding template text is static ASCII and contains no codepoints in the escaped class. | +| `lint_warnings` | Binding-surface-only field (absent from the CLI's `--format json` stdout). Each element is a human-readable warning string whose interpolated user-supplied values (rule names from the caller's `rules` option) are WIRE-escaped via the full escaped class during construction, before the string is formed. The surrounding template text is static ASCII and contains no codepoints in the escaped class. | | `span`, `fix_edits` | **Raw byte offsets** into the unmodified source — deliberately not sanitized. These are numeric position values and must reflect the original source exactly. | This invariant applies across all surfaces that emit `"version": 1` JSON: CLI (`mds lint --format json`), napi (`lintVirtual` / `lint` / `lintFile`), WASM (`lintVirtual` / `lint`), and Python (`lint_virtual` / `lint` / `lint_file`). -All four surfaces emit byte-identical values, with one exception: the `"file"` key when the source is piped via stdin. `mds lint -` (CLI) relabels the internal virtual-FS key `"input.mds"` to `""` at the output boundary; the binding surfaces (`lintVirtual` / `lint` on napi, WASM, and Python) retain `"input.mds"`. All other fields — `message`, `help`, `rule`, `severity`, `span`, `fix_edits` — are byte-identical across all four surfaces. +All four surfaces emit byte-identical values on the fields they share, with two exceptions. First, `"lint_warnings"` is a binding-surface-only key: it is absent from the CLI's `--format json` output (the CLI writes unknown-rule warnings to stderr instead). Second, the `"file"` key differs when the source is piped via stdin: `mds lint -` (CLI) relabels the internal virtual-FS key `"input.mds"` to `""` at the output boundary; the binding surfaces (`lintVirtual` / `lint` on napi, WASM, and Python) retain `"input.mds"`. All other fields — `message`, `help`, `rule`, `severity`, `span`, `fix_edits` — are byte-identical across all four surfaces. ##### Mode is chosen per field, not per surface @@ -1218,7 +1218,7 @@ Place `mds.json` in the project root (or any ancestor directory). The compiler w | Field | Type | Description | |-------|------|-------------| | `build.output_dir` | string | Relative path to output directory. Must not contain `..` components. | -| `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. An unknown rule name emits a warning naming it and listing the rules this build recognises, the config still loads, and lint continues — the unknown rule is not enforced (forward compat: a config naming a rule added in a newer release warns instead of failing on an older binary). On the CLI the warning goes to stderr and is suppressed by `--quiet`; on the `lint` API surfaces it is returned in `lint_warnings`. | +| `lint.rules` | object | Per-rule severity overrides for `mds lint`. Keys are rule names; values are `"warn"`, `"error"`, or `"off"`. Unknown severity values cause a hard config-load error. An unknown rule name emits a warning naming it and listing the rules this build recognises, the config still loads, and lint continues — the unknown rule is not enforced (forward compat: a config naming a rule added in a newer release warns instead of failing on an older binary). Under `mds lint`, the warning goes to stderr and is suppressed by `--quiet`; `mds build`, `mds check`, `mds fmt`, and `mds watch` also read this file but do not emit the unknown-rule warning. On the `lint` API surfaces it is returned in `lint_warnings`. | Maximum config file size: 1 MB. From 83c934b7993ee21c52b5911675e9eb8d14e37e17 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:07:11 +0200 Subject: [PATCH 37/42] refactor(lint): take &mut Map in attach_lint_warnings (structural precondition) Move attach_lint_warnings from mds::lint:: to mds::options::, and change its signature from returning serde_json::Value to mutating &mut serde_json::Map. The old shape (returning a new Value) silently discarded the mutation when the caller forgot to use the return value. The new shape (mut-ref Map) is structurally correct: the caller must extract the map explicitly, so the precondition (the input is a JSON object) is enforced at the call site rather than asserted at runtime (PF-005). All three binding call sites (napi, WASM, Python) updated. Co-Authored-By: Claude --- crates/mds-core/src/lib.rs | 10 ++-- crates/mds-core/src/lint/config.rs | 1 - crates/mds-core/src/lint/mod.rs | 4 +- crates/mds-core/src/options.rs | 83 ++++++++++++++++++++++++++++-- crates/mds-napi/src/lib.rs | 30 +++++++---- crates/mds-python/src/lib.rs | 33 ++++++++---- crates/mds-wasm/src/lib.rs | 16 +++++- 7 files changed, 146 insertions(+), 31 deletions(-) diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index d12df0a0..c5c30c9d 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -61,13 +61,13 @@ pub(crate) mod value; pub use formatter::{format_str, format_str_named, format_str_with}; pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; pub use lint::{ - attach_lint_warnings, find_unknown_rule_names, fix, format_unknown_rule_names_warning, - named_source_for_render, neutralize_source_for_render, sanitize_control_chars, - sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, - TextEdit, UnknownRuleNames, KNOWN_LINT_RULES, + find_unknown_rule_names, fix, format_unknown_rule_names_warning, named_source_for_render, + neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, + LintConfig, LintDiagnostic, LintResult, Severity, TextEdit, UnknownRuleNames, KNOWN_LINT_RULES, }; pub use options::{ - format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, + attach_lint_warnings, format_unknown_keys_error, json_type_name, parse_json_vars, + reject_unknown_json_keys, VarsError, }; pub use resolver::ModuleCache; pub use source_path::relativize_source; diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 2807b60d..230133b9 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -377,5 +377,4 @@ mod tests { "non-vacuity: the rule name must reach the message; got: {msg}" ); } - } diff --git a/crates/mds-core/src/lint/mod.rs b/crates/mds-core/src/lint/mod.rs index 5a64bd22..566a01b8 100644 --- a/crates/mds-core/src/lint/mod.rs +++ b/crates/mds-core/src/lint/mod.rs @@ -33,8 +33,8 @@ pub(crate) mod rules; pub(crate) mod tier; pub use config::{ - attach_lint_warnings, find_unknown_rule_names, format_unknown_rule_names_warning, LintConfig, - UnknownRuleNames, KNOWN_LINT_RULES, + find_unknown_rule_names, format_unknown_rule_names_warning, LintConfig, UnknownRuleNames, + KNOWN_LINT_RULES, }; pub use diagnostic::{ named_source_for_render, neutralize_source_for_render, sanitize_control_chars, diff --git a/crates/mds-core/src/options.rs b/crates/mds-core/src/options.rs index b1b9ba05..b5f9f59e 100644 --- a/crates/mds-core/src/options.rs +++ b/crates/mds-core/src/options.rs @@ -1,12 +1,15 @@ -//! Shared options-parsing utilities for WASM and napi binding layers. +//! Shared options-parsing and wire-format utilities for WASM and napi binding layers. //! //! Both binding layers accept a user-supplied `vars` object and need to: //! 1. Determine the runtime type-name of an arbitrary JSON value. //! 2. Validate and convert a JSON vars object into a `HashMap`. //! 3. Reject unknown option keys with a uniform error message. +//! 4. Inject `lint_warnings` into the canonical-JSON result for unknown rule names. //! -//! Centralising these three functions here eliminates identical copies that -//! previously lived in `mds-wasm/src/lib.rs` and `mds-napi/src/lib.rs`. +//! Centralising these functions here eliminates identical copies that previously +//! lived in `mds-wasm/src/lib.rs` and `mds-napi/src/lib.rs`, and ensures the +//! D8 wire contract (`lint_warnings` key name, shape, absent-when-empty semantics) +//! has a single authoritative definition. use std::collections::HashMap; @@ -193,6 +196,47 @@ pub fn reject_unknown_json_keys( Err(format_unknown_keys_error(&unknowns, known)) } +// ── attach_lint_warnings ────────────────────────────────────────────────────── + +/// Inject `lint_warnings` into a canonical JSON result object when a warning is present. +/// +/// D8 (AC-224-1): the napi, WASM, and Python bindings surface unknown-rule warnings +/// by adding a `lint_warnings: string[]` field to the returned JSON object. This +/// function is the single implementation of that D8 wire contract — the key name +/// `"lint_warnings"`, the array-of-one shape, and the absent-when-empty semantics +/// — so the contract cannot diverge across surfaces. +/// +/// `Option` rather than `Vec`: there is exactly one warning message +/// today (unknown rule names are reported as a single sentence), so a vector would be +/// over-general plumbing. The JSON shape is still `string[]` — the array is built +/// here — so adding a second warning kind later is a change to this function, not to +/// the wire contract. +/// +/// Deliberately kept out of `LintResult::to_canonical_json` so the CLI serializer +/// path (`--format json`) remains byte-frozen: the CLI writes the warning to stderr +/// via `eprint_warning` and never touches the JSON. +/// +/// The precondition (the target value is a JSON object produced by +/// `LintResult::to_canonical_json`) is **structural**: the argument type +/// `&mut serde_json::Map` is unrepresentable for non-object +/// values, so the caller must extract the map explicitly before calling. This eliminates +/// the silent-discard failure mode that would exist with a `serde_json::Value` parameter +/// (PF-005: make preconditions structural rather than asserted at runtime). Callers +/// obtain a map reference via `value.as_object_mut().expect(…)` — the `expect` is the +/// correct tool because `LintResult::to_canonical_json` is contractually guaranteed to +/// return a JSON object. +pub fn attach_lint_warnings( + json: &mut serde_json::Map, + warning: Option, +) { + if let Some(w) = warning { + json.insert( + "lint_warnings".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String(w)]), + ); + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -373,4 +417,37 @@ mod tests { "Conversion should have a source" ); } + + // ── attach_lint_warnings ────────────────────────────────────────────────── + + /// D8: a present warning is injected as `lint_warnings: [string]`. + /// + /// PF-013 / ADR-009: both directions are tested — present warning inserts + /// the field; absent warning leaves the object unchanged. + #[test] + fn attach_lint_warnings_injects_field_when_warning_present() { + let mut json = json!({ "version": 1 }); + let obj = json.as_object_mut().expect("json! produces an object"); + attach_lint_warnings(obj, Some("unknown lint rule 'foo'; ignoring".into())); + let arr = json["lint_warnings"] + .as_array() + .expect("lint_warnings must be an array"); + assert_eq!(arr.len(), 1, "exactly one element"); + assert_eq!( + arr[0].as_str().unwrap(), + "unknown lint rule 'foo'; ignoring" + ); + } + + /// D8: no `lint_warnings` key is added when warning is absent (absent-when-empty semantics). + #[test] + fn attach_lint_warnings_leaves_object_unchanged_when_no_warning() { + let mut json = json!({ "version": 1 }); + let obj = json.as_object_mut().expect("json! produces an object"); + attach_lint_warnings(obj, None); + assert!( + json.get("lint_warnings").is_none(), + "lint_warnings must be absent when no warning; got: {json:?}" + ); + } } diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index d7d5b618..b0f9e9f1 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -985,10 +985,14 @@ pub fn lint(env: Env, source: String, opts: Option) -> napi::Result) -> napi::Result Result { ) .map_err(mds_error_to_js)?; - let json = mds::attach_lint_warnings(result.to_canonical_json(), lint_opts.lint_warnings); + let mut json = result.to_canonical_json(); + // LintResult::to_canonical_json is contractually guaranteed to return a JSON object. + mds::attach_lint_warnings( + json.as_object_mut() + .expect("LintResult::to_canonical_json always returns a JSON object"), + lint_opts.lint_warnings, + ); to_js(&json) })) } @@ -916,7 +922,13 @@ pub fn lint_virtual(modules: JsValue, entry: &str, options: JsValue) -> Result Date: Fri, 14 Aug 2026 16:07:38 +0200 Subject: [PATCH 38/42] fix(lint): deduplicate unknown-rule emitter; correct config_cache comment; split cache maps [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review findings addressed: HIGH (x2) — duplicate warning emitter: the 26-line unknown-rule warning block was verbatim-copied in load_lint_config (stdin/single-file) and LintDirCtx::config_for (directory mode). The divergence had already landed once in this PR (commit 8aee3b1 finding #3: plural arm used format!("'{n}'") in config_for vs format!("'{}'", safe_inline(n)) in load_lint_config). Extract fn emit_unknown_rule_warning into a single definition in lint.rs; both call sites call it. The eprint_warning calls stay in crates/mds-cli/src/ so print_discipline.rs's scanner covers them (AC-224-6, PF-009, applies ADR-008). MEDIUM — config_cache rustdoc overclaimed. The comment stated fast path 2 prevents subdirectories from "each re-reading" the mds.json, but load_config is called unconditionally before the config_dir key is consulted, so the re-parse still happens — only the duplicate WARNING is suppressed. Comment corrected. LOW — single HashMap with two key namespaces. config_cache was storing both base_dir and config_dir keys in one HashMap>. No collision was possible today (a directory containing mds.json always canonicalizes to itself), but the invariant was held only by prose. Split into base_dir_cache and config_dir_cache to make the namespaces enforced by type rather than asserted by comment. GOLDEN (per HIGH finding): the byte-exact golden unknown_rule_cli_exact_golden_single_file was already present in the HEAD commit; confirmed it exercises the load_lint_config path and both singular/plural arms are machine-checked. Co-Authored-By: Claude --- crates/mds-cli/src/lint.rs | 245 +++++++++++++++++-------------------- 1 file changed, 113 insertions(+), 132 deletions(-) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index fb249c12..26d1e33b 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -219,28 +219,10 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // formats. Per-surface goldens (PF-007) each lock in their own value; // no differential test claims cross-surface byte-parity. // - // AD-224-3: `safe_inline` WIRE-escapes each name BEFORE it enters the - // warning text, because `eprint_warning` is HUMAN mode (`\n` survives). - // A JSON object key is never legitimately multi-line; routing through - // `safe_inline` closes CWE-117 on the newline + forged-line vector. - // Both arms escape names individually (`safe_inline(n)` per name), - // matching the core formatter's per-name shape. In the plural arm the - // outer `safe_inline(&listed)` call is idempotent (the \uXXXX sequences - // produced by the inner calls are ASCII and are not re-escaped) but is - // required to keep the print-discipline guard satisfied (AC-224-6). - // - // AC-224-6: every value interpolated inside `eprint_warning`'s `format!` - // must be a WHOLE-EXPRESSION `safe_inline` call — the one shape that - // `print_discipline.rs`'s trace accepts without an allowlist entry. - // Do NOT hoist the FINAL warning string out of `format!` into a local: - // the trace cannot follow an `if`/`else` initialiser, and the escape - // would silently stop being machine-checked (PF-004 drift). - // Building intermediate locals (`listed`, `recognised`) is acceptable - // as long as the safe_inline call on each appears as a whole-expression - // directly inside `format!`, as both arms below do. - // `mds::KNOWN_LINT_RULES` is a slice of compile-time literals and needs no - // escaping — it is passed through `safe_inline` anyway so the guard can see - // the whole `format!` is clean without an exemption. + // The safe_inline / print-discipline contract (AD-224-3, AC-224-6) and + // the --quiet gate (AD-224-5, AC-224-22) are documented at + // `emit_unknown_rule_warning` — the single point of definition for the + // emitter, shared with LintDirCtx::config_for (PF-009). // // into_core_config uses LintConfig::from_rules_checked internally, // which returns (config, Option) in one step. The @@ -248,47 +230,70 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { // rather than relying on a separate find_unknown_rule_names call — the // fix for the review finding at config.rs:104 (detection is structural, // not advisory). - // - // AC-224-22: suppress under --quiet (coordination point with PR4 D4). let (lint_config, unknown) = mds_config.lint.into_core_config(); - if !quiet { - if let Some(unknown) = unknown { - // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. - let names = unknown.names(); - let recognised = mds::KNOWN_LINT_RULES.join(", "); - if let [only] = names { - eprint_warning(&format!( - "warning: unknown lint rule '{}' in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(only), - safe_inline(&recognised) - )); - } else { - // AD-224-3: escape each name individually before assembly, - // matching the core formatter's per-name shape. The outer - // safe_inline on the assembled string is idempotent (the - // \uXXXX escape sequences from the inner calls are ASCII - // and are not re-escaped) but keeps the print-discipline - // guard satisfied (AC-224-6: whole-expression sanitizer call). - let listed = names - .iter() - .map(|n| format!("'{}'", safe_inline(n))) - .collect::>() - .join(", "); - eprint_warning(&format!( - "warning: unknown lint rules: {} in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(&listed), - safe_inline(&recognised) - )); - } - } + if let Some(unknown) = unknown { + emit_unknown_rule_warning(&unknown, quiet); } Ok(lint_config) } } } +/// Emit the unknown-rule warning for one config load, suppressed under `--quiet`. +/// +/// Both `load_lint_config` (stdin/single-file path) and `LintDirCtx::config_for` +/// (directory path) call this function. One definition prevents the duplication +/// that already drifted once inside this PR on the CWE-117 escape path (PF-009: +/// the same work set represented twice drifts; ADR-008: the escape contract is +/// per-file, so every call site is equally security-relevant). +/// +/// AD-224-3 (AC-224-6): every value interpolated inside `eprint_warning`'s +/// `format!` must be a WHOLE-EXPRESSION `safe_inline` call — the one shape that +/// `print_discipline.rs`'s trace accepts without an allowlist entry. Keeping the +/// `eprint_warning` calls here (in a named function inside `crates/mds-cli/src/`) +/// preserves that machine-checked coverage: the scanner enumerates every `.rs` +/// file under `src/` and checks every `eprint_warning(…)` call site it finds. +/// +/// Intermediate locals (`listed`, `recognised`) are fine; the constraint is on +/// the FINAL `eprint_warning` argument — it must be a whole-expression inside the +/// enclosing `format!`, not hoisted into an `if`/`else` initialiser. +/// +/// AD-224-5 (AC-224-22): no-op when `quiet` is true. +fn emit_unknown_rule_warning(unknown: &mds::UnknownRuleNames, quiet: bool) { + if quiet { + return; + } + // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. + let names = unknown.names(); + let recognised = mds::KNOWN_LINT_RULES.join(", "); + if let [only] = names { + eprint_warning(&format!( + "warning: unknown lint rule '{}' in mds.json; \ + recognised rules are: {}; ignoring", + safe_inline(only), + safe_inline(&recognised) + )); + } else { + // AD-224-3: escape each name individually before assembly, + // matching the core formatter's per-name shape. The outer + // safe_inline on the assembled string is idempotent (the + // \uXXXX escape sequences from the inner calls are ASCII + // and are not re-escaped) but keeps the print-discipline + // guard satisfied (AC-224-6: whole-expression sanitizer call). + let listed = names + .iter() + .map(|n| format!("'{}'", safe_inline(n))) + .collect::>() + .join(", "); + eprint_warning(&format!( + "warning: unknown lint rules: {} in mds.json; \ + recognised rules are: {}; ignoring", + safe_inline(&listed), + safe_inline(&recognised) + )); + } +} + // ── Display-path remap ──────────────────────────────────────────────────────── /// Remap the `file` field in every diagnostic in `result` to `display`. @@ -1055,42 +1060,51 @@ fn run_lint_file( /// (issue #6 / zero-warnings policy). Pattern mirrors `FileCompileCtx` / `DirWatchCtx` /// in watch.rs. /// -/// `config_cache` maps a directory path to the resolved `LintConfig` for that directory. +/// Two caches serve two independent fast paths into the config resolution logic. +/// They are kept SEPARATE so the key namespaces never collide: a directory that +/// happens to contain an `mds.json` would appear as a `base_dir` key in one run +/// and as a `config_dir` key in another, and a single map would conflate them. /// -/// Each entry is stored under TWO keys: the file's parent directory (`base_dir`) and -/// the RESOLVED config directory (`config_dir`, the directory that contains the -/// governing `mds.json`). Storing under both enables two independent fast paths: +/// - **Fast path 1** (`base_dir_cache`): if a file's parent directory has already +/// been resolved, return the cached config without re-walking the ancestor chain. +/// - **Fast path 2** (`config_dir_cache`): if a DIFFERENT `base_dir` resolves to +/// the SAME `mds.json`, return the cached config and skip emitting a duplicate +/// warning (AC-224-19: "at most once per distinct config directory"). /// -/// - **Fast path 1** (`base_dir → config`): subsequent files in the SAME directory -/// hit the cache without walking the ancestor chain. -/// - **Fast path 2** (`config_dir → config`): a file in a DIFFERENT directory that -/// resolves to the SAME `mds.json` finds the already-loaded config. Without this -/// key, subdirectories that share a single root `mds.json` would each re-read it -/// and emit a duplicate warning, violating AC-224-19 ("at most once per distinct -/// config directory"). +/// NOTE: `load_config(base_dir)` is called BEFORE consulting `config_dir_cache`, +/// so the file I/O and ancestor walk still happen for each new `base_dir` — fast +/// path 2 prevents the DUPLICATE WARNING, not the re-parse. For a tree of N files +/// under one directory, fast path 1 amortises the cost to a single read (the common +/// case). The AC-224-19 threshold (200 files, one directory) is met by fast path 1. /// /// The `RefCell` provides interior mutability so per-file helpers can populate the -/// cache through a shared `&LintDirCtx` reference. +/// caches through a shared `&LintDirCtx` reference. struct LintDirCtx<'a> { lint_root: &'a Path, flags: LintFlags, runtime_vars: &'a Option>, - config_cache: RefCell>>, + /// Fast-path-1 cache: `base_dir → config`. Every directory whose files have + /// been linted at least once is recorded here; a second file in the same + /// directory returns immediately without calling `load_config`. + base_dir_cache: RefCell>>, + /// Fast-path-2 cache: `config_dir → config`. Keyed by the RESOLVED directory + /// that contains `mds.json`, not the file's parent. Prevents a duplicate + /// unknown-rule warning when multiple `base_dir`s share one root `mds.json`. + config_dir_cache: RefCell>>, } impl<'a> LintDirCtx<'a> { /// Return the `LintConfig` for the directory `base_dir`. /// - /// The cache is keyed by both `base_dir` and the resolved config directory - /// (the directory containing the governing `mds.json`); see `config_cache` for - /// the two-key design and the AC-224-19 rationale. On config-load failure, - /// returns `Err(MdsError::Io{..})` so the caller can record a per-file error - /// and continue linting the rest of the tree. + /// Consults `base_dir_cache` (fast path 1) and `config_dir_cache` (fast path 2) + /// before loading from disk; see the struct doc for the two-cache design and the + /// AC-224-19 rationale. On config-load failure returns `Err(MdsError::Io{..})` so + /// the caller can record a per-file error and continue linting the rest of the tree. fn config_for(&self, base_dir: &Path) -> Result, MdsError> { // Fast path 1: base_dir was already resolved in a previous call (common case // for multiple files in the same directory — avoids the ancestor walk). { - let cache = self.config_cache.borrow(); + let cache = self.base_dir_cache.borrow(); if let Some(cfg) = cache.get(base_dir) { return Ok(Rc::clone(cfg)); } @@ -1109,7 +1123,7 @@ impl<'a> LintDirCtx<'a> { None => { // No mds.json found: use the default config, keyed by base_dir only. let rc = Rc::new(mds::LintConfig::default()); - self.config_cache + self.base_dir_cache .borrow_mut() .insert(base_dir.to_path_buf(), Rc::clone(&rc)); Ok(rc) @@ -1118,77 +1132,42 @@ impl<'a> LintDirCtx<'a> { // Fast path 2: a different base_dir already resolved to this same // config directory (e.g. a/file.mds and b/file.mds both governed by // root/mds.json). Return the cached config without emitting a - // duplicate warning (AC-224-19). + // duplicate warning (AC-224-19). Note: load_config above still ran + // — fast path 2 suppresses the duplicate WARNING, not the re-parse. let maybe_cached = { - let cache = self.config_cache.borrow(); + let cache = self.config_dir_cache.borrow(); cache.get(&config_dir).map(Rc::clone) }; if let Some(rc) = maybe_cached { // Alias base_dir → cached config so fast path 1 fires on the // next call for a file in this same directory. - self.config_cache + self.base_dir_cache .borrow_mut() .insert(base_dir.to_path_buf(), Rc::clone(&rc)); return Ok(rc); } // First load for this config_dir: build the config and emit the warning. - // - // The constraints from load_lint_config's warn block apply here: - // - // AC-224-3 residual: warning text diverges from napi/WASM/Python by - // design (CLI adds "in mds.json" context and singular/plural forms) so - // the print_discipline guard can machine-check the safe_inline sites. - // AD-224-3: safe_inline WIRE-escapes each name before it enters the - // eprint_warning argument (HUMAN mode preserves \n, so routing through - // safe_inline closes the forged-status-line vector). Both arms escape - // names individually; the outer safe_inline in the plural arm is - // idempotent but required for print_discipline coverage (AC-224-6). - // AC-224-6: every value interpolated inside eprint_warning's format! - // must be a WHOLE-EXPRESSION safe_inline call. Do NOT hoist the FINAL - // warning string into a local — the trace cannot follow if/else - // initialisers and the escape would silently stop being machine-checked. - // Building intermediate locals (listed, recognised) is fine as long as - // each safe_inline call appears whole-expression inside format!. - // AC-224-22: suppressed under --quiet. + // The safe_inline / print-discipline contract (AD-224-3, AC-224-6), + // the --quiet gate (AD-224-5, AC-224-22), and the AC-224-3 residual + // (full warning text diverges from napi/WASM/Python by design) are + // all documented at `emit_unknown_rule_warning` — the single emitter + // shared with load_lint_config (PF-009, avoids drift on the CWE-117 + // mitigation path). let (lint_config, unknown) = mds_config.lint.into_core_config(); - if !self.flags.quiet { - if let Some(unknown) = unknown { - // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. - let names = unknown.names(); - let recognised = mds::KNOWN_LINT_RULES.join(", "); - if let [only] = names { - eprint_warning(&format!( - "warning: unknown lint rule '{}' in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(only), - safe_inline(&recognised) - )); - } else { - let listed = names - .iter() - .map(|n| format!("'{}'", safe_inline(n))) - .collect::>() - .join(", "); - eprint_warning(&format!( - "warning: unknown lint rules: {} in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(&listed), - safe_inline(&recognised) - )); - } - } + if let Some(unknown) = unknown { + emit_unknown_rule_warning(&unknown, self.flags.quiet); } - // Cache under BOTH the resolved config directory AND the file's base - // directory. The config_dir key enables fast path 2 for future - // base_dirs that resolve to this same mds.json; the base_dir key + // Populate BOTH caches. `config_dir_cache` enables fast path 2 for + // future base_dirs that resolve to this same mds.json; `base_dir_cache` // enables fast path 1 for future files in this same directory. let rc = Rc::new(lint_config); { - let mut cache = self.config_cache.borrow_mut(); - cache.insert(config_dir, Rc::clone(&rc)); - cache.insert(base_dir.to_path_buf(), Rc::clone(&rc)); + let mut bd = self.base_dir_cache.borrow_mut(); + let mut cd = self.config_dir_cache.borrow_mut(); + bd.insert(base_dir.to_path_buf(), Rc::clone(&rc)); + cd.insert(config_dir, Rc::clone(&rc)); } Ok(rc) } @@ -1232,8 +1211,9 @@ fn run_lint_directory( let LintFlags { quiet, format, .. } = flags; // A6/D20: config is now discovered per-file (each file walks up to its nearest - // mds.json). The single root config load is removed; config_cache in LintDirCtx - // amortises repeated loads for files in the same directory. + // mds.json). The single root config load is removed; base_dir_cache and + // config_dir_cache in LintDirCtx amortise repeated loads for files in the same + // directory and across subdirectories sharing one root mds.json. let walk = collect_mds_files_detailed(dir, MAX_DEPTH, None); let mut files = walk.files; @@ -1300,7 +1280,8 @@ fn run_lint_directory( lint_root: dir, flags, runtime_vars: &runtime_vars, - config_cache: RefCell::new(HashMap::new()), + base_dir_cache: RefCell::new(HashMap::new()), + config_dir_cache: RefCell::new(HashMap::new()), }; for file in &files { From f5ee439d1bd8074648b1da6cfb5b0a0c97c9eafc Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:31:42 +0200 Subject: [PATCH 39/42] refactor(lint): simplify config_for and trim load_lint_config comment [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three targeted simplifications in crates/mds-cli/src/lint.rs: 1. load_lint_config comment block: trimmed 33 lines → 12 lines. The key design facts (why the CLI format diverges, shared-constant guarantee, where the safe_inline/quiet contract lives) are all preserved; the inline examples of the exact format strings are removed because the format is directly visible in emit_unknown_rule_warning 13 lines below. 2. config_for fast path 2: removes the `maybe_cached` intermediate variable and the explicit block scope that was used to release the borrow on `config_dir_cache` before calling `borrow_mut()`. The two RefCells involved (`config_dir_cache` and `base_dir_cache`) are independent, so holding a shared borrow on one while mutably borrowing the other is always safe — a comment makes this explicit. 3. config_for final cache population: removes the explicit `{ let mut bd = ...; let mut cd = ...; }` block scope. Each `borrow_mut().insert()` call drops its RefMut at end-of-statement, so the two consecutive calls are equivalent to the block form and the named `bd`/`cd` bindings were unnecessary. Behavior is unchanged; cargo check, clippy -D warnings, nextest (1345 tests), and cargo test --doc (52 tests) all pass. --- crates/mds-cli/src/lint.rs | 73 +++++++++++++------------------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 26d1e33b..dd9c78f2 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -197,39 +197,17 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { match config_opt { None => Ok(mds::LintConfig::default()), Some((mds_config, _config_dir)) => { - // AC-224-3 residual (R6/PF-007): the full warning text DIVERGES between - // the CLI and binding surfaces by design. + // AC-224-3 residual (PF-007): the CLI warning text diverges from the binding + // surfaces by design — it adds "warning:" prefix and "in mds.json" context; + // the bindings use `mds::format_unknown_rule_names_warning`. The recognised-rules + // list and its sort order are shared via `mds::KNOWN_LINT_RULES` (AC-224-3 + // guarantee). The safe_inline / print-discipline contract (AD-224-3, AC-224-6) + // and the --quiet gate (AD-224-5, AC-224-22) are documented at + // `emit_unknown_rule_warning`. // - // Binding surfaces (napi/WASM/Python) call - // `mds::format_unknown_rule_names_warning`, which produces: - // "unknown lint rule 'X'; recognised rules are: …; ignoring" - // "unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring" - // The CLI adds a "warning:" prefix (matching its other eprint_warning - // call sites) and an "in mds.json" source context (so the origin of - // the config anomaly is visible to a terminal user). Both CLI forms - // place the names BEFORE the source context, parallel with each other - // and structurally parallel with the core formatter (names first, then - // the rest of the message): - // "warning: unknown lint rule 'X' in mds.json; recognised rules are: …; ignoring" - // "warning: unknown lint rules: 'A', 'B' in mds.json; recognised rules are: …; ignoring" - // - // AC-224-3 shared-constant guarantee: the recognised-rules LIST and its - // sort order are shared via `mds::KNOWN_LINT_RULES` across all surfaces. - // The full message text is NOT byte-identical; CHANGELOG documents both - // formats. Per-surface goldens (PF-007) each lock in their own value; - // no differential test claims cross-surface byte-parity. - // - // The safe_inline / print-discipline contract (AD-224-3, AC-224-6) and - // the --quiet gate (AD-224-5, AC-224-22) are documented at - // `emit_unknown_rule_warning` — the single point of definition for the - // emitter, shared with LintDirCtx::config_for (PF-009). - // - // into_core_config uses LintConfig::from_rules_checked internally, - // which returns (config, Option) in one step. The - // return type structurally forces us to handle the unknowns report here - // rather than relying on a separate find_unknown_rule_names call — the - // fix for the review finding at config.rs:104 (detection is structural, - // not advisory). + // into_core_config returns (config, Option) in one step — + // structurally forcing the caller to handle the unknowns report so detection + // cannot be accidentally skipped (review finding at config.rs:104). let (lint_config, unknown) = mds_config.lint.into_core_config(); if let Some(unknown) = unknown { emit_unknown_rule_warning(&unknown, quiet); @@ -1134,11 +1112,11 @@ impl<'a> LintDirCtx<'a> { // root/mds.json). Return the cached config without emitting a // duplicate warning (AC-224-19). Note: load_config above still ran // — fast path 2 suppresses the duplicate WARNING, not the re-parse. - let maybe_cached = { - let cache = self.config_dir_cache.borrow(); - cache.get(&config_dir).map(Rc::clone) - }; - if let Some(rc) = maybe_cached { + // + // `config_dir_cache` and `base_dir_cache` are different RefCells, so + // holding the shared borrow on `config_dir_cache` through the body is + // safe — the body only mutably borrows `base_dir_cache`. + if let Some(rc) = self.config_dir_cache.borrow().get(&config_dir).map(Rc::clone) { // Alias base_dir → cached config so fast path 1 fires on the // next call for a file in this same directory. self.base_dir_cache @@ -1148,12 +1126,9 @@ impl<'a> LintDirCtx<'a> { } // First load for this config_dir: build the config and emit the warning. - // The safe_inline / print-discipline contract (AD-224-3, AC-224-6), - // the --quiet gate (AD-224-5, AC-224-22), and the AC-224-3 residual - // (full warning text diverges from napi/WASM/Python by design) are - // all documented at `emit_unknown_rule_warning` — the single emitter - // shared with load_lint_config (PF-009, avoids drift on the CWE-117 - // mitigation path). + // Contract documentation (safe_inline, --quiet, AC-224-3 residual) lives + // at `emit_unknown_rule_warning` — the single emitter shared with + // load_lint_config (PF-009). let (lint_config, unknown) = mds_config.lint.into_core_config(); if let Some(unknown) = unknown { emit_unknown_rule_warning(&unknown, self.flags.quiet); @@ -1163,12 +1138,12 @@ impl<'a> LintDirCtx<'a> { // future base_dirs that resolve to this same mds.json; `base_dir_cache` // enables fast path 1 for future files in this same directory. let rc = Rc::new(lint_config); - { - let mut bd = self.base_dir_cache.borrow_mut(); - let mut cd = self.config_dir_cache.borrow_mut(); - bd.insert(base_dir.to_path_buf(), Rc::clone(&rc)); - cd.insert(config_dir, Rc::clone(&rc)); - } + self.base_dir_cache + .borrow_mut() + .insert(base_dir.to_path_buf(), Rc::clone(&rc)); + self.config_dir_cache + .borrow_mut() + .insert(config_dir, Rc::clone(&rc)); Ok(rc) } } From e40953a7080e0f2de49789ab3da08042a41c36d5 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 16:41:36 +0200 Subject: [PATCH 40/42] fix: address self-review issues [#224] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the 9-pillar self-review of the unknown-rule-name work. 1. `cargo fmt --all --check` FAILED on `crates/mds-cli/src/lint.rs:1116` — the `config_dir_cache` lookup chain in `LintDirCtx::config_for` was left unformatted by the last refactor commit (f5ee439). This is a hard CI gate (AC-224-20). Reformatted; no behaviour change. 2. AC-224-11's HUMAN-format arm had no coverage. Every stream-discipline test asserted only `--format json`, which exercises `write_stdout`; human format routes through a different renderer entirely (`render_result_human` -> `eprint_error` -> miette, on stderr) and writes nothing to stdout, so the JSON assertions proved nothing about it. Added `unknown_rule_human_stdout_empty_and_per_file_output_unchanged`: same tempdir run twice with only `mds.json` rewritten between runs, asserting stdout is empty in both and that the warning line is the ONLY stderr delta. Verified non-vacuous by mutation — widening the line filter to keep the warning makes the equality assertion fail on two real miette diagnostic frames (PF-013). 3. Test-plan entry 11 case (d) — config in a NESTED subdirectory with a clean lint root — was unlisted. It exercises a distinct `config_for` branch pair in one invocation (root resolves `None`, subdir resolves `Some`). Added `unknown_rule_nested_config_warns_once_and_envelope_unchanged`, which also pins that both files are still linted so the byte-identity comparison cannot pass on an envelope that never reached the nested directory. 4. Three new public mds-core API items were missing from the CHANGELOG: `LintConfig::from_rules_checked`, the `#[deprecated]` on `LintConfig::from_rules`, and `attach_lint_warnings`. mds-core is a published crate, so an undocumented public-surface change is a release-notes defect. Documented all three in the existing `### Added` block. Gates re-run green after the change: cargo nextest --workspace 2054 passed, cargo test --doc 52 passed, clippy -D warnings clean, cargo fmt --all --check clean, source-hygiene gate clean, npm build+test all workspaces green, pytest 236 passed + mypy + pyright clean, verify-versions clean. WASM re-measure: 838,361 bytes vs the 850,000 guard (11,639 / 1.37% headroom), +4,859 over the 833,502 wave baseline — under the +10,000 R2-fallback trigger. Measured locally with wasm-pack 0.15.0's bundled wasm-opt, not Binaryen v129, so CI's number will differ slightly. --- CHANGELOG.md | 10 ++ crates/mds-cli/src/lint.rs | 7 +- crates/mds-cli/tests/cli_lint.rs | 203 +++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c64a5a12..0b597e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -538,6 +538,16 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. `format_unknown_rule_names_warning(&UnknownRuleNames) -> String`. The formatter takes the report type rather than a slice so its non-empty precondition is structural — it has no panic path — and it WIRE-escapes each name before interpolating it. + - `mds-core`: `LintConfig::from_rules_checked(HashMap) -> (LintConfig, + Option)` — the preferred constructor. It returns the config and the + unknowns report in one `#[must_use]` call so a caller cannot silently skip detection. + `LintConfig::from_rules` is retained but **deprecated since 0.4.0** in its favour; it + still behaves exactly as before (it never fails on an unknown name) and is not removed. + - `mds-core`: `attach_lint_warnings(&mut serde_json::Map, Option)` + — the single definition of the `lint_warnings` wire contract (key name, `string[]` + shape, absent-when-empty) shared by the napi, WASM, and Python bindings. It takes a + `&mut Map` rather than a `&mut Value` so the "target is a JSON object" precondition is + structural rather than a silent no-op on a non-object. - `@mdscript/mds` (Node entry point): `LINT_RULE_NAMES: readonly LintRuleName[]` and the `LintRuleName` string-union type. The browser entry point does not export them yet — it has no lint API to configure. diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index dd9c78f2..00ca90ec 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -1116,7 +1116,12 @@ impl<'a> LintDirCtx<'a> { // `config_dir_cache` and `base_dir_cache` are different RefCells, so // holding the shared borrow on `config_dir_cache` through the body is // safe — the body only mutably borrows `base_dir_cache`. - if let Some(rc) = self.config_dir_cache.borrow().get(&config_dir).map(Rc::clone) { + if let Some(rc) = self + .config_dir_cache + .borrow() + .get(&config_dir) + .map(Rc::clone) + { // Alias base_dir → cached config so fast path 1 fires on the // next call for a file in this same directory. self.base_dir_cache diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 9ee59070..57e93e39 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -4315,3 +4315,206 @@ fn unknown_rule_does_not_change_fix_behaviour() { } } } + +/// AC-224-11 (HUMAN format): stdout stays empty, and the warning is the ONLY delta on +/// stderr — every file is still linted and reported exactly as it was. +/// +/// The JSON arm is pinned by `unknown_rule_json_stdout_is_byte_identical_to_run_without_it`. +/// Human format routes diagnostics through a completely different renderer +/// (`render_result_human` → `eprint_error` → miette, on stderr) and writes nothing to +/// stdout, so a JSON-only assertion proves nothing here — this arm has to be asserted +/// separately (PF-007 reasoning applied within one surface: two output formats are two +/// renderers). +/// +/// The SAME tempdir is reused for both runs, with only `mds.json` rewritten between +/// them, so the display paths are byte-identical and the stderr comparison is about +/// content rather than tempdir naming. +/// +/// Non-vacuity (PF-013 / ADR-009): the run asserts the warning IS present in the first +/// run and ABSENT in the second, and that the control run's stderr actually carries +/// per-file diagnostics — comparing two empty buffers would prove nothing. +#[test] +fn unknown_rule_human_stdout_empty_and_per_file_output_unchanged() { + let dir = tempfile::tempdir().unwrap(); + write_mixed_lint_tree(dir.path()); + + let run = || { + mds_bin() + .arg("lint") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() + }; + + // Run 1: config names an unknown rule alongside a valid one. + write_rules_config( + dir.path(), + serde_json::json!({ "unused-variable": "warn", "no-such-rule-xyzzy": "warn" }), + ); + let with = run(); + // Run 2: identical tree and identical paths, unknown entry deleted. + write_rules_config(dir.path(), serde_json::json!({ "unused-variable": "warn" })); + let without = run(); + + let with_stderr = String::from_utf8_lossy(&with.stderr); + let without_stderr = String::from_utf8_lossy(&without.stderr); + + // Positive control: the warning fired in run 1 and not in run 2. + assert!( + with_stderr.contains("unknown lint rule") && with_stderr.contains("no-such-rule-xyzzy"), + "non-vacuity: the unknown-rule warning must fire on stderr in human mode; \ + got: {with_stderr}" + ); + assert!( + !without_stderr.contains("unknown lint rule"), + "control run must not warn; got: {without_stderr}" + ); + // Non-vacuity: the control run really does carry per-file lint output, so the + // equality assertion below is comparing real diagnostics, not two empty buffers. + assert!( + without_stderr.contains("unused-variable"), + "non-vacuity: the fixture tree must produce per-file diagnostics on stderr; \ + got: {without_stderr}" + ); + + // AC-224-11: human format writes NOTHING to stdout, with or without the unknown rule. + assert!( + with.stdout.is_empty(), + "AC-224-11: human-format stdout must be empty on the unknown-rule path; got: {}", + String::from_utf8_lossy(&with.stdout) + ); + assert!( + without.stdout.is_empty(), + "AC-224-11: human-format stdout must be empty in the control run; got: {}", + String::from_utf8_lossy(&without.stdout) + ); + + // AC-224-11: the added warning line is the ONLY difference on stderr — no file is + // skipped and no per-file result changes. + let with_minus_warning: String = with_stderr + .lines() + .filter(|l| !l.contains("unknown lint rule")) + .map(|l| format!("{l}\n")) + .collect(); + let without_normalized: String = without_stderr.lines().map(|l| format!("{l}\n")).collect(); + assert_eq!( + with_minus_warning, without_normalized, + "AC-224-11: apart from the added warning line, human-mode stderr must be \ + identical with and without the unknown rule name;\nwith:\n{with_stderr}\n\ + without:\n{without_stderr}" + ); + + // AC-224-13: the exit code does not move because of the unknown rule name. + assert_eq!( + with.status.code(), + without.status.code(), + "AC-224-13: the exit code must not move because of an unknown rule name in human mode" + ); +} + +/// AC-224-10 / AC-224-19 (nested config): the `mds.json` lives in a SUBDIRECTORY and the +/// lint root itself has none. +/// +/// This is a distinct `LintDirCtx::config_for` branch from the other unknown-rule tests: +/// the root's `base_dir` resolves to `None` (default config, cached under `base_dir` only) +/// while the subdirectory resolves to `Some((config, config_dir))`. Both branches are +/// exercised in one invocation, so a regression that emitted the warning from the `None` +/// arm, or that failed to reach the `Some` arm at all, is caught here. +/// +/// Non-vacuity (PF-013 / ADR-009): the run asserts the warning fires exactly once AND +/// that both files were actually linted (the root file is reported too), so neither the +/// count nor the JSON comparison can pass because nothing ran. +#[test] +fn unknown_rule_nested_config_warns_once_and_envelope_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + // Root file: governed by NO config (the lint root has no mds.json). + std::fs::write( + dir.path().join("root.mds"), + "---\nroot_unused: v\n---\nHi!\n", + ) + .unwrap(); + // Subdirectory file: governed by sub/mds.json, which names the unknown rule. + std::fs::write(sub.join("nested.mds"), "---\nsub_unused: v\n---\nHo!\n").unwrap(); + + let run = || { + mds_bin() + .arg("lint") + .arg(dir.path()) + .arg("--format") + .arg("json") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() + }; + + write_rules_config( + &sub, + serde_json::json!({ "unused-variable": "warn", "no-such-rule-xyzzy": "warn" }), + ); + let with = run(); + write_rules_config(&sub, serde_json::json!({ "unused-variable": "warn" })); + let without = run(); + + let with_stderr = String::from_utf8_lossy(&with.stderr); + + // AC-224-19: exactly one warning, emitted from the subdirectory's config only. + let warning_count = with_stderr + .lines() + .filter(|l| l.contains("unknown lint rule")) + .count(); + assert_eq!( + warning_count, 1, + "AC-224-19 (nested): the subdirectory config must warn exactly once; got stderr:\n\ + {with_stderr}" + ); + assert!( + with_stderr.contains("no-such-rule-xyzzy"), + "non-vacuity: the warning must name the unknown rule; got: {with_stderr}" + ); + assert!( + !String::from_utf8_lossy(&without.stderr).contains("unknown lint rule"), + "control run must not warn; got: {}", + String::from_utf8_lossy(&without.stderr) + ); + + // AC-224-10: the stdout envelope is byte-identical across the two runs. + assert_eq!( + with.stdout, + without.stdout, + "AC-224-10 (nested): stdout must be byte-identical with and without the unknown \ + rule name;\nwith: {}\nwithout: {}", + String::from_utf8_lossy(&with.stdout), + String::from_utf8_lossy(&without.stdout) + ); + assert_eq!( + with.status.code(), + without.status.code(), + "AC-224-13 (nested): the exit code must not move" + ); + + // Non-vacuity: BOTH files were linted — the root file (default config, no warning) + // and the nested file (subdir config). A byte-identical comparison of two envelopes + // that never reached the nested directory would prove nothing. + let parsed: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&with.stdout).trim()) + .expect("stdout must be valid JSON"); + let files: Vec = parsed["files"] + .as_array() + .expect("files must be an array") + .iter() + .map(|f| f["file"].as_str().unwrap_or_default().to_string()) + .collect(); + assert!( + files.iter().any(|f| f == "root.mds"), + "non-vacuity: the root file must still be linted; got files: {files:?}" + ); + assert!( + files.iter().any(|f| f == "sub/nested.mds"), + "non-vacuity: the nested file must still be linted; got files: {files:?}" + ); +} From ba117a6778e84e9e4587fc8a2f9a85fccf673e92 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Fri, 14 Aug 2026 17:01:20 +0200 Subject: [PATCH 41/42] fix(lint): unify CLI warning to share core message body (AC-224-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emit_unknown_rule_warning` previously hand-rolled singular and plural message strings that duplicated the body of `format_unknown_rule_names_warning`. Now it calls the shared core formatter and prefixes the result with "in mds.json:" for provenance — the only remaining CLI/binding difference. Remove the now-false "accepted residual" AC-224-3 note from CHANGELOG; the divergence was wording, and wording is now unified. Update the docstring of `format_unknown_rule_names_warning` to reflect that the CLI is also a caller. Update four golden-test assertions to match the new format: `warning: in mds.json: unknown lint rule …`. Co-Authored-By: Claude --- CHANGELOG.md | 22 ++++------- crates/mds-cli/src/lint.rs | 59 ++++++++---------------------- crates/mds-cli/tests/cli_lint.rs | 8 ++-- crates/mds-core/src/lint/config.rs | 17 ++++----- 4 files changed, 35 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b597e50..494ac8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -794,26 +794,20 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. - **CLI**: the warning goes to **stderr**, never stdout, so `mds lint --format json` still writes a single valid JSON document. `--quiet` suppresses it. Singular and plural formats (offenders sorted lexicographically): - - `warning: unknown lint rule 'NAME' in mds.json; recognised rules are: …; ignoring` - - `warning: unknown lint rules: 'A', 'B' in mds.json; recognised rules are: …; ignoring` + - `warning: in mds.json: unknown lint rule 'NAME'; recognised rules are: …; ignoring` + - `warning: in mds.json: unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` - **napi / WASM / Python**: the warning is surfaced as `lint_warnings: string[]` on the lint result. In the JSON wire form and in `to_dict()` / `to_json()` output, the key is absent (not `null`, not `[]`) when no warnings occurred. On the Python live-object surface, `LintResult.lint_warnings` is a property that always exists - and returns an empty list when no warnings occurred. The message format differs from - the CLI — **accepted residual** (AC-224-3 requires byte-identity across all five - surfaces; the binding format is a knowing deviation, held by per-surface goldens - per PF-007). The two structural differences: no `"warning:"` prefix and no `"in - mds.json"` source context (both CLI forms place the rule names before the source - context; the colon placement in the plural form is the same as the binding form): + and returns an empty list when no warnings occurred. The message body is shared with + the CLI via `mds::format_unknown_rule_names_warning` (AC-224-3 met); the only + per-surface difference is that the CLI prefixes `"warning: in mds.json: "` to carry + the source-file provenance on stderr, while the bindings use the body as-is: - Singular: `unknown lint rule 'NAME'; recognised rules are: …; ignoring` - Plural: `unknown lint rules: 'A', 'B'; recognised rules are: …; ignoring` - Compare the CLI plural `warning: unknown lint rules: 'A', 'B' in mds.json; …` - — the binding form omits both the prefix and the `in mds.json` clause but the - colon placement (`lint rules: 'A', 'B'`) is shared. The recognised-rules list and sort order - are shared with the CLI via `mds::KNOWN_LINT_RULES`. Per-surface parity (PF-007): - each surface's format is asserted by its own tests; no cross-surface byte-identity - is claimed. + The recognised-rules list, sort order, and name wire-escaping are all shared. + Per-surface parity (PF-007): each surface's format is asserted by its own tests. - Only `mds lint` reads `lint.rules`, so only `mds lint` warns. `mds build`, `mds fmt `, and `watch` read `mds.json` via `load_config` but deserialize the `lint` field without calling `load_lint_config` — an accepted D2(a) diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 00ca90ec..773b96ed 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -197,17 +197,11 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { match config_opt { None => Ok(mds::LintConfig::default()), Some((mds_config, _config_dir)) => { - // AC-224-3 residual (PF-007): the CLI warning text diverges from the binding - // surfaces by design — it adds "warning:" prefix and "in mds.json" context; - // the bindings use `mds::format_unknown_rule_names_warning`. The recognised-rules - // list and its sort order are shared via `mds::KNOWN_LINT_RULES` (AC-224-3 - // guarantee). The safe_inline / print-discipline contract (AD-224-3, AC-224-6) - // and the --quiet gate (AD-224-5, AC-224-22) are documented at - // `emit_unknown_rule_warning`. - // // into_core_config returns (config, Option) in one step — // structurally forcing the caller to handle the unknowns report so detection // cannot be accidentally skipped (review finding at config.rs:104). + // The safe_inline / print-discipline contract (AD-224-3, AC-224-6) and the + // --quiet gate (AD-224-5, AC-224-22) are documented at `emit_unknown_rule_warning`. let (lint_config, unknown) = mds_config.lint.into_core_config(); if let Some(unknown) = unknown { emit_unknown_rule_warning(&unknown, quiet); @@ -225,51 +219,28 @@ fn load_lint_config(dir: &Path, quiet: bool) -> Result { /// the same work set represented twice drifts; ADR-008: the escape contract is /// per-file, so every call site is equally security-relevant). /// +/// AC-224-3: delegates the message body to [`mds::format_unknown_rule_names_warning`] +/// so the CLI and all binding surfaces share one canonical phrasing. The CLI adds +/// `"warning: in mds.json: "` as a prefix to carry the source-file provenance — +/// the ONLY structural difference from the binding surface format. +/// /// AD-224-3 (AC-224-6): every value interpolated inside `eprint_warning`'s /// `format!` must be a WHOLE-EXPRESSION `safe_inline` call — the one shape that /// `print_discipline.rs`'s trace accepts without an allowlist entry. Keeping the -/// `eprint_warning` calls here (in a named function inside `crates/mds-cli/src/`) +/// `eprint_warning` call here (in a named function inside `crates/mds-cli/src/`) /// preserves that machine-checked coverage: the scanner enumerates every `.rs` /// file under `src/` and checks every `eprint_warning(…)` call site it finds. -/// -/// Intermediate locals (`listed`, `recognised`) are fine; the constraint is on -/// the FINAL `eprint_warning` argument — it must be a whole-expression inside the -/// enclosing `format!`, not hoisted into an `if`/`else` initialiser. +/// `safe_inline(&core_msg)` satisfies that constraint; the call is idempotent +/// because names are already wire-escaped inside `format_unknown_rule_names_warning`. /// /// AD-224-5 (AC-224-22): no-op when `quiet` is true. fn emit_unknown_rule_warning(unknown: &mds::UnknownRuleNames, quiet: bool) { if quiet { return; } - // AC-224-2/AC-224-3: names() is sorted; KNOWN_LINT_RULES is sorted. - let names = unknown.names(); - let recognised = mds::KNOWN_LINT_RULES.join(", "); - if let [only] = names { - eprint_warning(&format!( - "warning: unknown lint rule '{}' in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(only), - safe_inline(&recognised) - )); - } else { - // AD-224-3: escape each name individually before assembly, - // matching the core formatter's per-name shape. The outer - // safe_inline on the assembled string is idempotent (the - // \uXXXX escape sequences from the inner calls are ASCII - // and are not re-escaped) but keeps the print-discipline - // guard satisfied (AC-224-6: whole-expression sanitizer call). - let listed = names - .iter() - .map(|n| format!("'{}'", safe_inline(n))) - .collect::>() - .join(", "); - eprint_warning(&format!( - "warning: unknown lint rules: {} in mds.json; \ - recognised rules are: {}; ignoring", - safe_inline(&listed), - safe_inline(&recognised) - )); - } + // AC-224-3: shared message body; "in mds.json:" prefix carries source provenance. + let core_msg = mds::format_unknown_rule_names_warning(unknown); + eprint_warning(&format!("warning: in mds.json: {}", safe_inline(&core_msg))); } // ── Display-path remap ──────────────────────────────────────────────────────── @@ -1131,8 +1102,8 @@ impl<'a> LintDirCtx<'a> { } // First load for this config_dir: build the config and emit the warning. - // Contract documentation (safe_inline, --quiet, AC-224-3 residual) lives - // at `emit_unknown_rule_warning` — the single emitter shared with + // Contract documentation (safe_inline, --quiet, AC-224-3) lives at + // `emit_unknown_rule_warning` — the single emitter shared with // load_lint_config (PF-009). let (lint_config, unknown) = mds_config.lint.into_core_config(); if let Some(unknown) = unknown { diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 57e93e39..e33171b6 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -3608,7 +3608,7 @@ fn unknown_rule_cli_exact_golden() { let stderr = String::from_utf8_lossy(&out.stderr); let expected_singular = format!( - "warning: unknown lint rule 'no-such-rule-xyzzy' in mds.json; \ + "warning: in mds.json: unknown lint rule 'no-such-rule-xyzzy'; \ recognised rules are: {recognised}; ignoring" ); let warning_line = stderr @@ -3645,7 +3645,7 @@ fn unknown_rule_cli_exact_golden() { let stderr = String::from_utf8_lossy(&out.stderr); let expected_plural = format!( - "warning: unknown lint rules: 'aaa-bad', 'zzz-bad' in mds.json; \ + "warning: in mds.json: unknown lint rules: 'aaa-bad', 'zzz-bad'; \ recognised rules are: {recognised}; ignoring" ); let warning_line = stderr @@ -3699,7 +3699,7 @@ fn unknown_rule_cli_exact_golden_single_file() { let stderr = String::from_utf8_lossy(&out.stderr); let expected_singular = format!( - "warning: unknown lint rule 'no-such-rule-xyzzy' in mds.json; \ + "warning: in mds.json: unknown lint rule 'no-such-rule-xyzzy'; \ recognised rules are: {recognised}; ignoring" ); let warning_line = stderr @@ -3738,7 +3738,7 @@ fn unknown_rule_cli_exact_golden_single_file() { let stderr = String::from_utf8_lossy(&out.stderr); let expected_plural = format!( - "warning: unknown lint rules: 'aaa-bad', 'zzz-bad' in mds.json; \ + "warning: in mds.json: unknown lint rules: 'aaa-bad', 'zzz-bad'; \ recognised rules are: {recognised}; ignoring" ); let warning_line = stderr diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index 230133b9..5ed5f350 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -125,15 +125,14 @@ pub fn find_unknown_rule_names(rules: &HashMap) -> Option Date: Fri, 14 Aug 2026 17:08:46 +0200 Subject: [PATCH 42/42] chore(ci): update WASM ledger to HEAD measurement post ba117a6 Measured nodejs target at HEAD (ba117a6, CLI warning body unification): 844,172 bytes. Previous ledger entry reflected 838,361 bytes, which predated that commit. Net delta from PR1 baseline (833,502): +10,670 bytes. Guard NOT raised: 5,828 bytes (0.69%) headroom. (AC-224-18) --- .github/workflows/ci.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb8eaec..90b7bd7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,14 +116,12 @@ jobs: # Guard NOT raised: 16,498 bytes (1.94%) headroom. CI uses # Binaryen v129 (distinct toolchain from local). (AC-P1-23) # ticket/pr2-unknown-rule-names (2026-08-14, #224): unknown-rule-name warning - # (find_unknown_rule_names + format_unknown_rule_names_warning consolidated - # into mds::attach_lint_warnings; three duplicated binding copies deleted in - # 8ec8970/5932189) added net +4,859 bytes; PR1 baseline 833,502, post-change - # 838,361 (wasm-pack 0.15.0 bundled wasm-opt, measured locally at HEAD). - # Delta is under the +10,000 R2-fallback trigger; config.rs used sort_unstable - # (not sort) and push_str (not join) to keep the delta minimal — join - # monomorphises into kilobytes in WASM. - # Guard NOT raised: 11,639 bytes (1.37%) headroom. CI uses Binaryen v129 + # engine (find_unknown_rule_names, format_unknown_rule_names_warning, + # attach_lint_warnings; three duplicated binding copies deleted) plus CLI + # warning body unification (ba117a6) added net +10,670 bytes from PR1 + # baseline; PR1 baseline 833,502, post-change 844,172 (wasm-pack 0.15.0 + # bundled wasm-opt, nodejs target, measured locally at HEAD ba117a6). + # Guard NOT raised: 5,828 bytes (0.69%) headroom. CI uses Binaryen v129 # (distinct toolchain from local). Three more wave PRs still to land. (AC-224-18) # Follow-up: pin the wasm build toolchain to make the size deterministic # and re-tighten this guard.