Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
356b692
feat(lint): warn on unknown lint rule names, expose binding channel (…
dean0x Aug 14, 2026
c407147
style: apply cargo fmt to resolve validation formatting violations
dean0x Aug 14, 2026
cb980e4
style: extract inject_lint_warnings helper; fix misleading docstring
dean0x Aug 14, 2026
266baf6
fix: address self-review issues
dean0x Aug 14, 2026
abb44f6
docs: fix six Evaluator alignment findings from PR #224 review
dean0x Aug 14, 2026
7259360
docs(lint): add lint_warnings to result shape in napi and mds READMEs
dean0x Aug 14, 2026
fd915c8
chore(ci): record PR2 WASM budget measurement in history comment
dean0x Aug 14, 2026
2dd86e0
docs(linting): remove self-contradictory "silently" from unknown-rule…
dean0x Aug 14, 2026
8ec8970
refactor(mds-core): route lint::config symbols through lint/mod.rs re…
dean0x Aug 14, 2026
5932189
docs(lint): correct "on every surface" docstrings; extract attach_lin…
dean0x Aug 14, 2026
3daa51a
test(security): replace vacuous COLUMNS loop with structural width ar…
dean0x Aug 14, 2026
8086cee
docs(spec): fix three review findings on lint_warnings documentation
dean0x Aug 14, 2026
d0bbf5d
fix(python): extend sanitize_lint_value to lint_warnings; document ab…
dean0x Aug 14, 2026
75e9599
fix(test): restore assertion removed by skim; eliminate U+001B litera…
dean0x Aug 14, 2026
65683c4
test(python): restore WIRE-escape assertion in sanitize test (safe fo…
dean0x Aug 14, 2026
258b40d
test(mds-wasm): pin unknown severity throws mds::invalid_options on W…
dean0x Aug 14, 2026
f6f552f
style: apply cargo fmt + add attach_lint_warnings unit tests [#224]
dean0x Aug 14, 2026
f8d877c
docs(mds-wasm): state all three unknown-rule facts in README (AC-224-17)
dean0x Aug 14, 2026
3ab9d7f
docs(readme): add legacy-interpolation rule and correct rule count to 10
dean0x Aug 14, 2026
99a0da6
test(bindings): ADR-008 per-surface ESC-byte escape tests [#224]
dean0x Aug 14, 2026
70405dd
fix(test): useless_format clippy lint in wasm W-WARN-ESC test
dean0x Aug 14, 2026
8417929
test(lint): add D2(a)/AC-224-14 CI tests; name CLI-binding msg diverg…
dean0x Aug 14, 2026
8aee3b1
fix(lint): resolve five review findings on CLI plural-warning inconsi…
dean0x Aug 14, 2026
f2a2906
chore(ci): correct stale AC-224-18 WASM measurement after attach_lint…
dean0x Aug 14, 2026
d10bfc0
docs(mds-cli): fix two review findings in build.rs and cli_build.rs docs
dean0x Aug 14, 2026
403767b
docs(changelog): fix 9→10 rule count and qualify byte-identity claim …
dean0x Aug 14, 2026
92a7314
docs: correct stale rule-count from 9 to 10 in two docs locations
dean0x Aug 14, 2026
f8b4c07
docs(lint): fix stale module-doc rule count 9→10
dean0x Aug 14, 2026
dab5957
refactor(lint): reduce semver surface of two public API items [#224]
dean0x Aug 14, 2026
c2e13c8
docs(types): fix stale byte-identity claim in LintResult doc comments
dean0x Aug 14, 2026
79ad521
test(lint): cover load_lint_config quiet gate for single-file and std…
dean0x Aug 14, 2026
005b343
fix(test): correct AC-224-14 call-graph docs and make tests non-vacuo…
dean0x Aug 14, 2026
ca6e754
docs(mds-napi): add absent-when-empty qualifier to lint_warnings prose
dean0x Aug 14, 2026
813138d
docs(lint): document --quiet suppression of unknown-rule warning [#224]
dean0x Aug 14, 2026
8e94d8a
test(security): restore COLUMNS loop in T-ESC-RULE-1; fix T-ESC-RULE-…
dean0x Aug 14, 2026
09c56a3
docs(spec): fix three review findings in spec.md normative surface [#…
dean0x Aug 14, 2026
83c934b
refactor(lint): take &mut Map in attach_lint_warnings (structural pre…
dean0x Aug 14, 2026
81c2135
fix(lint): deduplicate unknown-rule emitter; correct config_cache com…
dean0x Aug 14, 2026
f5ee439
refactor(lint): simplify config_for and trim load_lint_config comment…
dean0x Aug 14, 2026
e40953a
fix: address self-review issues [#224]
dean0x Aug 14, 2026
ba117a6
fix(lint): unify CLI warning to share core message body (AC-224-3)
dean0x Aug 14, 2026
a67b7e4
chore(ci): update WASM ledger to HEAD measurement post ba117a6
dean0x Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .devflow/features/mds-lint/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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.
if [ "$raw" -gt 850000 ]; then
Expand Down
76 changes: 73 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,32 @@ 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<String, Severity>) -> Option<UnknownRuleNames>`
(`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.
- `mds-core`: `LintConfig::from_rules_checked(HashMap<String, Severity>) -> (LintConfig,
Option<UnknownRuleNames>)` — 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<String, Value>, Option<String>)`
— 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.
- 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`).
Expand Down Expand Up @@ -560,9 +586,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):
Expand Down Expand Up @@ -755,6 +783,48 @@ 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. Singular and
plural formats (offenders sorted lexicographically):
- `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 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`
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 <DIR>`, 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
<FILE>` 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 <DIR>` 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.

- **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`);
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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) |
Expand Down
28 changes: 23 additions & 5 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +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 are preserved for forward compat (CLI warns and ignores).
/// 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,
}
Expand All @@ -44,17 +47,32 @@ 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: 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)]
pub(crate) rules: HashMap<String, mds::Severity>,
}

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 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<mds::UnknownRuleNames>` 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::UnknownRuleNames>) {
mds::LintConfig::from_rules_checked(self.rules)
}
}

Expand Down
Loading
Loading