From fb8d1b402102fbfe1566235a559b879b4ad60787 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 17 Aug 2026 02:59:18 +0300 Subject: [PATCH] docs: fix pre-tag CHANGELOG and doc blockers before v0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A — Three CHANGELOG claims falsified by wave commits: - A1: browser entry point now exports lint, lintVirtual, LINT_RULE_NAMES; lintFile is intentionally absent (no node:fs in browser environments). - A2: stdin lint code frames use as source label (not "input.mds") since cbb11d4. - A3: check() / checkFile() accept { vars?, basePath? }, not only { vars? }. B — Lift lint JSON wire-contract ledger to its own ### **BREAKING** heading as a sibling of the interpolation section, not a child. Consumers skimming ### headings can now find the wire changes without reading interpolation prose. C — Merge duplicate ### Fixed, ### Added, ### Changed sections in [Unreleased] and reorder into Keep a Changelog order (Added, Changed, Deprecated, BREAKING sections, Fixed, Security). D1 — Widen LintDiagnostic.help and .span to include null (types.ts, READMEs): - packages/mds/src/types.ts: help?: string | null; span?: LintSpan | null - packages/mds/README.md: note that help, span, fix_edits are always-present JSON keys whose value is null when absent - crates/mds-napi/README.md: same correction Semver event flagged in CHANGELOG: consumers checking diag.span !== undefined must now also guard against null. D2 — Fix "required" basePath language (code defaults to cwd): - types.ts CheckOptions and LintOptions JSDoc - packages/mds/README.md option tables User-facing caution added: omitting basePath resolves against cwd, which succeeds silently but may resolve against the wrong directory. E1 — examples/linting/README.md line 3: "nine" -> "ten" rules. E2 — CHANGELOG wasm size figure corrected from "~808 KB" to CI-measured 836,126 bytes (Binaryen v129). E3 — Add CHANGELOG entry for c9265b4 (wasm-opt flag tuning + rustc 1.96.0 pin; the only wave commit previously missing a CHANGELOG entry). E4 — ci.yml budget ledger: replace local estimate 841,937 with CI-measured 836,126 (Binaryen v129, CI run 31946611856); note local wasm-opt v117 reads roughly 2-3 KB higher. F — Replace stale line-number citations in output.rs and cli_lint.rs with symbolic function-name references (avoids citation drift on line insertions). Co-Authored-By: Claude --- .github/workflows/ci.yml | 14 +- CHANGELOG.md | 1987 +++++++++++++++--------------- crates/mds-cli/src/output.rs | 13 +- crates/mds-cli/tests/cli_lint.rs | 2 +- crates/mds-napi/README.md | 4 +- examples/linting/README.md | 2 +- packages/mds/README.md | 11 +- packages/mds/src/types.ts | 27 +- 8 files changed, 1047 insertions(+), 1013 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 636f29a..1e3fcbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,14 +138,12 @@ jobs: # ticket/wasm-size-optimization (2026-08-16): wasm-opt flag tuning # (-Oz --flatten --rereloop -Oz --converge --strip-producers) plus # sort_unstable at four tie-free sites (value.rs, evaluator.rs, - # builtins.rs x2). PR2 baseline 844,172, post-change 841,937 - # (wasm-pack 0.15.0 bundled wasm-opt v117, nodejs target, measured - # locally at HEAD 13cb2a5 before commit). Net local savings: -2,235 bytes. - # NOTE: local uses bundled wasm-opt v117, CI uses Binaryen v129 -- the - # analysis predicted -7,527 bytes (flags) + ~4-6 KB (sort) with v129; - # local savings with v117 are smaller. CI number is authoritative. - # Guard NOT raised: >=5,828 bytes (>=0.69%) headroom on local toolchain; - # CI Binaryen v129 is expected to show a larger reduction. + # builtins.rs x2). PR2 baseline 844,172. CI-measured post-change: + # 836,126 bytes (Binaryen v129, CI run 31946611856). Local wasm-opt + # v117 (bundled by wasm-pack) reads roughly 2-3 KB higher (841,937 + # was the local pre-commit estimate; CI number is authoritative). + # Net CI savings vs PR2 baseline: -8,046 bytes. + # Guard NOT raised: 13,874 bytes (1.63%) headroom. # Toolchain now pinned to 1.96.0 in the wasm job (see companion commit). if [ "$raw" -gt 850000 ]; then echo "::error::WASM binary exceeds 850,000 byte threshold: ${label} is ${raw} bytes" diff --git a/CHANGELOG.md b/CHANGELOG.md index 453eb8c..d4d30af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,27 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- **`basePath` option is now honored on `compile()`, `check()`, and `lint()` (#180).** - Previously `basePath` was accepted by the unknown-option validator (so no error was - thrown) but was silently discarded before reaching the backend: the forwarding builders - (`compileOpt`/`varsOpt`) never included it in what they passed through. Templates - containing `@import` or `@extends` directives compiled with a string-source call and a - `basePath` option would either fail to resolve their imports (native backend) or fail - silently (WASM backend). The fix adds `basePath` to both `CompileOptions` and - `CheckOptions` and propagates it to the backend for the string-source methods - (`compile`, `check`). `compileFile` and `checkFile` deliberately exclude - `basePath` — the base directory for file operations is derived from the file - path itself (see the BREAKING subsection below). - - The WASM backend has no filesystem access and cannot resolve file-relative imports; it - now **rejects** a non-null `basePath` immediately with `mds::invalid_options` instead - of silently ignoring it, so misconfigured callers receive an actionable error rather - than a silent wrong answer. `{basePath: undefined}` is treated as absent on both - backends (`!= null` check; value-is-intent). To use `basePath` with import resolution, - set `MDS_BACKEND=native`. - ### Added - **`lint` and `lintVirtual` are now exported from the browser entry point (#215).** @@ -46,1131 +25,940 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 only re-exported from `index.ts`, which the package `exports` map does not resolve — so consumers could receive the value but not name its type. Purely additive. -### Deprecated - -- **`mds::fix::apply_fixes` is deprecated in favor of `apply_fixes_incremental` (#209).** - The replacement applies the same ADR-004 three-tier reverify-gate safety contract with a - batch-first attempt plus a bounded per-edit fallback, salvaging the safe subset of fixes - when some edits fail the reverify gate rather than refusing the whole batch. - Two behavioral differences require manual migration: +- **`mds lint ` directory-mode summary (#216).** After linting a directory, one summary + line is printed to stderr: + `N clean, N with warnings, N with errors, N resource-limited` + Under `--quiet`, the summary is suppressed when the worst outcome is warnings only (mirrors + `mds fmt`); it is always emitted when any file is in the error or resource-limited bucket. + Scripts or tests that relied on `mds lint ` producing no stderr on a clean tree should + note that this summary line is now always printed on a clean run (to suppress it, pass + `--quiet`). The JSON stdout envelope (`{"files":…,"truncated":…,"version":1}`) is unchanged + — no `"summary"` key is added, so existing consumers of `--format json` are unaffected. + Exception: `mds lint --fix --check --quiet ` exits 1 with zero stderr bytes when + pending fixes exist but no file has errors or hits a resource limit — the exit code is + unexplained on the surface but is intentional and documented in `--help`. - - **Closure bound**: `apply_fixes` takes `F: FnOnce`; `apply_fixes_incremental` requires - `F: Fn` because the reverify closure may be called more than once. A move-once closure - cannot be migrated mechanically. - - **New reachable outcome**: `apply_fixes_incremental` can return - `FixOutcome::PartiallyFixed` when some edits are accepted and some are refused. - `apply_fixes` never returns `PartiallyFixed`. Because `FixOutcome` is - `#[non_exhaustive]`, existing wildcard arms compile unchanged, but a wildcard that - swallows `PartiallyFixed` silently discards partial results. +- **`mds lint --fix` adds stderr parity across all three input modes (#216).** Three status + messages that previously appeared in some modes but not others are now present in all three + input modes, each suppressed under `--quiet`: + - **Stdin diagnostic-cap notice**: `mds lint --fix -` on inputs that exceed the diagnostic cap + now prints `diagnostic cap (N) reached; further findings were suppressed — re-run --fix to + continue` to stderr. This line did not exist in stdin mode before this release. stdout (the + fixed source) is unaffected. + - **`Fixed: ` in `--format json` directory mode**: previously emitted only by + `--format human`; now also printed per fixed file in `--format json`. The JSON document on + stdout is unchanged. + - **`Would fix: ` in `--format json` directory mode**: same — previously human-only, now + also printed in JSON mode under `--fix --check`. + Scripts or tests that assert zero stderr from `mds lint --fix -` or + `mds lint --fix --format json ` on non-quiet runs should note these additions. - Scheduled for removal in v0.5.0; tracked in GitHub issue #304. The six ADR-004 - regression tests pinned only against `apply_fixes` must be ported or retired before - the v0.5.0 tag (see #304 for the enumerated list with line numbers and the behavior - each test pins). +- **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. + - `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) and browser entry point: `LINT_RULE_NAMES: readonly LintRuleName[]` + and the `LintRuleName` string-union type. The browser entry point exports `lint`, `lintVirtual`, + and `LINT_RULE_NAMES`; `lintFile` is intentionally absent (file operations require `node:fs`, + which is unavailable in browser environments). + - 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. -- **`mds::LintConfig::from_rules` is deprecated in favor of `LintConfig::from_rules_checked` (#224).** - The replacement returns both the config and an unknowns report in a single `#[must_use]` - call, making it structurally impossible to silently skip unknown-rule detection. - `from_rules` still accepts any rule name without error; unknown names have no effect. +- **`--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`). + Repeatable. (#152) - Migration: change `LintConfig::from_rules(map)` to `LintConfig::from_rules_checked(map)` - and handle the `Option` second return value. No removal is scheduled - before v1.0.0; this function will remain available throughout the v0.x series (contrast - `apply_fixes` above, which is scheduled for removal at v0.5.0). +- **`mds fmt`** — an opinionated, safety-gated auto-formatter for `.mds` templates. Every + rewrite is guaranteed compile-equivalent: a runtime safety gate re-compiles the formatted + source and refuses to write if it would change compiled output (`mds::formatter_invariant`) + rather than silently corrupting a template. Normalizes CRLF to LF everywhere (including + inside frontmatter and code fences), strips trailing whitespace on directive lines, and + ensures exactly one trailing newline — while leaving interior blank lines, blank-line + structure within frontmatter and code fences, body-text trailing whitespace (Markdown + hard breaks), and the byte-for-byte content of `@message`/`@define` bodies untouched. + Supports a single file, a directory (recursive, including `_`-prefixed partials), or + stdin (`-`, as a filter); `--check` exits non-zero without writing when anything would + change, and `--diff` prints a unified diff (colorized on a TTY) without writing. New + public `mds-core` API: `format_str` / `format_str_with`. (#60) -### **BREAKING** — File-method `basePath` rejection, TypeScript option types, and WASM `basePath` rejection (#180, #213) +- **Native Python bindings** (`crates/mds-python`, PyO3 + maturin), to be distributed + as `mdscript` on PyPI. Seven functions — `compile`, `compile_file`, + `compile_virtual`, `check`, `check_file`, `check_virtual`, and `scan_imports` — + with idiomatic keyword-only signatures. Results are typed, frozen, and picklable + (`CompileResult` / `Message` / `Span` / `CheckResult`), and failures raise a native + `MdsError` carrying `.code` / `.message` / `.help` / `.span`. Ships `.pyi` stubs + + `py.typed` and exposes `__version__`. Output is byte-identical to the Rust, + Node.js, and WASM bindings (shared core serializer). Built as an `abi3-py311` + (`cp311-abi3`) extension; each compile releases the GIL and the module is + free-threading ready (`gil_used = false`), enabling multi-threaded use. + Cross-platform wheel matrix and PyPI publishing are a tracked follow-up (#132) — + for now, install from source: `pip install ./crates/mds-python`. (#59) -#### `compileFile` and `checkFile` now reject `basePath` (#180) +- **`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). -`compileFile(path, options?)` and `checkFile(path, options?)` previously accepted a -`basePath` option and silently discarded it — the option passed the unknown-key -validator but was dropped before the backend was reached, so file resolution always -used the directory containing the path. Both functions now **throw synchronously** -(`Error { code: 'mds::invalid_options' }`) when `basePath` is non-null. The base -directory for file-based operations is always derived from the file path itself. + **Rules** (individually configurable via `mds.json` `lint.rules` or the + `rules` API option; severities differ per rule): + - `unused-variable` (warn): frontmatter key defined but never referenced in the body + - `unused-import` (warn): `@import` never used in the file (Tier B: auto-fixed only for standalone files) + - `unused-function` (warn): `@define` function never called in the file (Tier B: auto-fixed only for standalone files) + - `shadow-variable` (off by default / info when enabled): 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) + - `redundant-else` (warn): `@else` body is structurally identical to the `@if`/`@elseif` then-body (Tier C — never auto-fixed) + - `unreachable-branch` (error): branch condition is always-true or always-false (auto-fixable) + - `duplicate-import` (error): same file imported more than once (auto-fixable) + - `duplicate-export` (error): same export name defined more than once (auto-fixable) -**Migration:** remove `basePath` from any options object passed to `compileFile` or -`checkFile`. This throw is **synchronous** — `.catch()` on the returned promise does -not receive it; wrap the call in `try/catch`. This is also a **compile-time break** -when a variable typed as `CompileOptions` or `CheckOptions` is passed to these -functions — see the compatibility notes below. Audit all call sites. + **CLI** (`mds lint`): file, directory, and stdin input modes; `--fix` for + auto-fixable issues (Tier A always; Tier B for standalone files); `--check` + and `--diff` preview modes for CI; `--format json` for machine-readable output; + `--quiet` to suppress warnings; `--vars`/`--set`/`--set-string` for variable + overrides forwarded to the check gate. -#### `FileOptions` no longer extends `CompileOptions` (#213) + **Exit codes** (lint-specific): `0` = clean, `1` = warnings only, `2` = errors + or analysis failure, `3` = resource limit. -`FileOptions` (used by `compileFile`) was previously declared as -`interface FileOptions extends CompileOptions`. This inheritance was an error: -`CompileOptions` now carries `basePath`, which is not valid for file-based -operations. `FileOptions` is now a standalone interface with its own `vars`, -`sourceMap`, and `sourcesContent` fields. + **Canonical JSON shape** (keys alphabetical, BTreeMap order): + ```json + {"files":[{"diagnostics":[...],"file":"template.mds"}],"truncated":false,"version":1} + ``` -**Compatibility:** this change is a **compile-time break** for code that passes a -`CompileOptions`-typed variable to `compileFile`. After this PR, `CompileOptions` -carries `basePath?: string` while `FileOptions` declares `basePath?: never`; -TypeScript reports `"Types of property 'basePath' are incompatible"` at any such -assignment or call. Code that never reuses a string-surface options variable for -file operations compiles without changes. + **Library API**: new public functions in `mds-core` — `lint`, `lint_str`, + `lint_str_with`, `lint_virtual`; `LintResult`, `LintDiagnostic`, + `LintConfig`, `Severity` types. -**Migration for shared variables:** retype the variable as `FileOptions`, or -destructure only the accepted fields: -```ts -const { vars, sourceMap, sourcesContent } = compileOpts; -compileFile(path, { vars, sourceMap, sourcesContent }); -``` + **napi** (`@mdscript/mds-napi`): `lint`, `lintFile`, `lintVirtual` exports. -#### `checkFile` parameter type changed from `CheckOptions` to `CheckFileOptions` (#213) + **WASM** (`@mdscript/mds-wasm`): `lint`, `lintVirtual` exports. -`checkFile(path, options?)` previously accepted `CheckOptions`. After this PR, -`CheckOptions` carries a `basePath` field that is not valid for file-based operations; -the parameter is now typed as `CheckFileOptions` — a new interface with only -`vars?: Record`. + **Universal TypeScript** (`@mdscript/mds`): `lint()`, `lintFile()`, + `lintVirtual()` with full TypeScript types (`LintResult`, `LintDiagnostic`, + `LintSpan`, `LintFileResult`, `LintOptions`, `LintFileOptions`). Both native + and WASM backends implement the full surface; `lintFile()` on the WASM backend + uses `buildModulesMap` for `@import` resolution. -**Compatibility:** this type narrowing is a **compile-time break** for code that -passes a `CheckOptions`-typed variable to `checkFile`. `CheckOptions` carries -`basePath?: string` while `CheckFileOptions` declares `basePath?: never`; TypeScript -reports `"Types of property 'basePath' are incompatible"` at any such call. Code -that never reuses a string-surface variable for `checkFile` compiles without changes. + **Python** (`mdscript`): `lint()`, `lint_file()`, `lint_virtual()` with keyword-only + `rules` and `base_path` / `vars` options; `LintResult` with `.version`, `.truncated`, + `.files`, `.to_dict()`, `.to_json()`. Stubs shipped in `_mdscript.pyi` / `__init__.pyi`. -**Migration for shared variables:** retype the variable as `CheckFileOptions`, or -restrict it to `{ vars?: Record }` at the call site. + **⚠ TypeScript interface implementers**: `MdsBaseBackend` gained `lint` and + `lintVirtual` as required members; `MdsNodeBackend` gained `lintFile`. Code that + directly implements these interfaces (not just calls them) must add these methods. -#### `LintFileOptions` gained `basePath?: never` (#213) +- **Source Map v3** (#62). Compile calls can now produce a [Source Map v3](https://sourcemaps.info/spec.html) + document alongside the rendered output. -`LintFileOptions` (used by `lintFile` and `lintVirtual`) previously had the shape -`{ vars?, rules? }`. It now declares `basePath?: never`. + **CLI** (`mds build`): `--source-map` writes a `.map` sidecar and leaves + the compiled output byte-identical to a no-flag build (ADR-002). `--inline` embeds + the map as a `` HTML comment at the end of the + output; no sidecar is written (requires `--source-map`). `--no-source-map` suppresses + generation when `build.source_map=true` is set in `mds.json`. `--embed-sources` + (requires `--source-map`) embeds the original source text as `sourcesContent`. -**Compatibility:** this is a **compile-time break** for code that assigns a variable -whose inferred type includes a `basePath` field to a `LintFileOptions`-typed slot. -For example, passing a `LintOptions`-typed variable directly to `lintFile` or -`lintVirtual` now fails with `TS2322` — `LintOptions.basePath` is `string | undefined` -which is not assignable to `never`. Code that passes a fresh object literal without -`basePath`, or a variable that was already typed as `{ vars?, rules? }`, continues to -compile unchanged. + **Rust** (`mds-core`): new `compile_str_with_deps_opts`, `compile_with_deps_opts`, + `compile_virtual_with_deps_opts` functions accept `CompileOptions { source_map: bool, + include_sources_content: bool }`. `CompileResult` carries `source_map: Option`. -**Migration:** at each `lintFile` / `lintVirtual` call site that passes a -`LintOptions`-typed variable, either extract a narrowed copy -(`const { basePath: _unused, ...fileOpts } = opts`) or redeclare the variable as -`LintFileOptions` when `basePath` was never meaningful there. + **Node.js** (`@mdscript/mds-napi`, `@mdscript/mds`): pass `{ sourceMap: true }` (and + optionally `{ sourcesContent: true }`) to `compile()` or `compileFile()`. The returned + result gains a `sourceMap` key when source maps are enabled. -#### WASM backend rejects `basePath` on string-surface methods (#180) + **WASM** (`@mdscript/mds-wasm`): same `sourceMap`/`sourcesContent` options on `compile()`. -`compile(source, { basePath: '/dir' })` and `check(source, { basePath: '/dir' })` -previously silently ignored `basePath` on the WASM backend. They now throw -`Error { code: 'mds::invalid_options' }`. `lint(source, { basePath: '/dir' })` was -already documented as WASM-unsupported; it now enforces this at runtime too. + **Python** (`mdscript`): `compile()`, `compile_file()`, and `compile_virtual()` accept + `source_map=True` and `sources_content=True` keyword arguments. Results expose a + `.source_map` property (`dict | None`). -**Migration:** switch to the native backend (`MDS_BACKEND=native`) when you need -import resolution with a `basePath` in WASM environments. + **Cross-field invariant**: passing `sourcesContent: true` (or `sources_content=True`) + without `sourceMap: true` (or `source_map=True`) is rejected on all surfaces — the + CLI rejects the combination at argument-parse time (clap `requires`; exit code 2); + the library bindings (napi/WASM/Python) raise `mds::invalid_options`. Messages-mode + templates silently degrade: `sourceMap` is absent from the result and a warning is + emitted. -### **BREAKING** — Interpolation syntax: `{x}` → `{{x}}` - -Interpolation now uses **double braces**: `{{variable}}`, `{{obj.field}}`, -`{{func("arg")}}`, `{{alias.func()}}`. Single `{` and `}` are always **literal -text** — no escaping needed for lone braces. + **⚠ Privacy warning**: `--embed-sources` / `sourcesContent: true` / `sources_content=True` + includes the full original template source in the map file — including any hardcoded + secrets or PII. Only use in trusted build environments. -#### Interpolation syntax changed (#236) +- **Partial fix application** for `mds lint --fix`: when a batch of fixes partially + applies (some edits are accepted, some are rejected due to post-fix regression), + the CLI now reports `"N of M fixes applied"` and writes the best accumulated state + to the file. Previously, a partial batch was all-or-nothing (either all or nothing + applied). (#196) -Templates using the old `{var}` form will no longer interpolate — they will emit -the literal text `{var}` instead. This affects every template that uses variable -substitution, function calls, or dynamic message roles. +- **`type_mismatch` errors now carry a source span** (file + line + column) pointing + to the `@if` or `@elseif` directive that triggered the comparison. The span is + propagated through all surfaces (CLI miette code frame, napi `.span`, Python + `.span`, WASM error object). (#196) -**Migration:** run `mds lint --fix` to auto-migrate (the `legacy-interpolation` -lint rule, Tier A, rewrites every `{x}` → `{{x}}` in one pass), then run -`mds fmt` to normalize formatting. +- **Spans on `mds::name_collision` errors** in `@export *` (wildcard), alias-import, + and merge-import paths. The error now points to the collision site instead of the + file root. (#196) -``` -# One-command migration for a project: -mds lint --fix && mds fmt -``` +- **Spans on unclosed-block errors**: `@if`/`@for`/`@define`/`@message` blocks that + are never closed now produce `mds::syntax` errors anchored at the opening directive. + (#196) -#### Escape syntax changed +- **`\{` escape hint on unclosed interpolation brace**: when the compiler encounters + an unclosed `{` (brace without a matching `}`), the error now includes the hint + "to include a literal `{`, escape it as `\{`". (#196) -- Old: `\{` → literal `{`, `\}` → literal `}` — **deleted**; these escapes are - no longer recognized (single braces are ordinary text and need no escaping). -- New: `\{{` → literal `{{` in output. Use this only when you need `{{` as - literal text in the output (e.g. inside a Jinja/Python f-string example). +- **`ArityMismatch` help text**: function-call arity errors now include a help string + pointing users to check the call site and the `@define` signature. (#196) -#### `@message {{role}}:` dynamic roles +- **Per-branch `@elseif` offset** in the AST (`ElseifBranch.offset`): lint diagnostics + for `empty-block`, `unreachable-branch`, and `duplicate-@elseif` now anchor at the + `@elseif` directive span rather than the parent `@if` opener. (#196) -Dynamic message roles now use double braces: `@message {{role}}:` instead of -`@message {role}:`. Bare-word roles (`@message system:`) are unchanged. +- **`format_str_named(source, base_dir, file_name)`** — new public `mds-core` API that + threads a caller-supplied file name through the formatter so that any `mds::syntax` + errors emitted during formatting name the file rather than using a generic sentinel. + `mds-cli`'s `mds fmt` uses this to show the actual file path in error output. (#196) -#### `LintDiagnostic` is now `#[non_exhaustive]`; use the constructor, not struct literals +- **`mds fmt --check` summary now includes unchanged count**: the directory-mode summary + under `--check` is now `"N would reformat, M unchanged, K failed"` (previously `"N + would reformat, K failed"`). (#196) -`LintDiagnostic` is marked `#[non_exhaustive]` so future minor releases can add -fields without a breaking change. External Rust crates can no longer construct it -via a struct literal. Use `LintDiagnostic::new(rule, severity, message)` to create -a diagnostic with required fields and all optional fields defaulting to `None`, then -chain the builder methods `with_help`, `with_span`, `with_file`, `with_fix_removals`, -and `with_fix_edits` to set optional fields. +- **napi workspace `build` script**: `crates/mds-napi/package.json` gains a `build` + script (`napi build --release --no-js`) for local development. (#196) -#### Nine additional public types are now `#[non_exhaustive]`; migrate struct literals to constructors +- **Block-span lint fixes — `--fix` now removes whole blocks** (`@if`/`@for`/`@define` + spans) for the `empty-block`, `unreachable-branch`, and `unused-function` rules. + Previously the fixer could only remove a single directive line, leaving the + matching `@end` orphaned; the reverify gate would catch the resulting parse error and + decline the fix, making these rules report-only in practice (tracked as limitation + in #172). The implementation now threads `end_offset` through the AST + (`IfBlock`, `ForBlock`, `DefineBlock`) and uses a `FixLineSpan` descriptor + (byte range of the full block) to perform whole-block removal. Containment dedup + in the planner coalesces overlapping spans and deduplicates identical fix ranges + across rules. JSON `"fixable"` is now `true` only when an actual `FixLineSpan` + is deliverable and the tier gate passes. The reverify gate still applies + fail-closed — if recompile fails, the fix is reported not applied. -The following types are marked `#[non_exhaustive]` so future minor releases can add -fields without a breaking change. External Rust crates can no longer construct them -via struct literals. Use the named constructor or builder listed for each: +- **Per-file `mds.json` discovery in `mds lint` directory mode**: when linting a + directory, the nearest `mds.json` is now located by walking up from **each input + file** independently (with a cached walk-up per directory). Previously a single + config at the lint-root directory was applied to all files. A malformed config in + a subdirectory now produces a per-file error entry and contributes to exit code 2 + rather than aborting the entire run. -- **`LintResult`** — use `LintResult::new(diagnostics)` (defaults: `truncated=false`, `is_standalone=false`), - then chain `.truncated()` or `.standalone()` to override. -- **`SerializedError`** — not externally constructable by design; obtain via `MdsError::serialize()`. -- **`SerializedSpan`** — use `SerializedSpan::new(offset, length)`, then chain `.with_line(n)` and/or `.with_column(n)`. -- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. Previously this type was `pub` - inside a `pub(crate)` module and was thus unnameable from external crates; this PR re-exports - it at the crate root, making `mds::TextEdit` accessible for the first time. The `fix_edits` - field on `LintDiagnostic` (and the corresponding JSON field) was effectively unusable from - Rust until this change. -- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals, - `FixLineSpan::range_inclusive(from, to)` to remove through the line containing `to`, - or `FixLineSpan::range_exclusive(from, to)` to keep the line containing `to`. -- **`ByteEdit`** — use `ByteEdit::deletion(start, end, rule)` for pure deletions or - `ByteEdit::replacement(start, end, rule, text)` for in-place replacements. -- **`RejectedEdit`** — use `RejectedEdit::new(edit, reason)`. -- **`FixPlan`** — use `FixPlan::default()` for an empty plan; its fields are `pub`, so they - remain directly readable and writable from external crates. -- **`LintConfig`** — use `LintConfig::from_rules_checked(rules)` or `LintConfig::default()` for no overrides. -- **`LintDiagnostic::sanitized_for_render()`** — a new method that returns a sanitized clone - suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to - this method instead of assembling sanitized copies itself, keeping the escape logic co-located - with the struct definition (PF-014). -- **`MdsError::source_name() -> Option<&str>`** — a new method that returns the name embedded - in the error's `NamedSource`, or `None` for errors without a source (e.g. `MdsError::Io`). - `source_name()` is domain-neutral; callers that need to detect the string-source analysis - path should use `MdsError::is_string_source()` rather than comparing the returned name - against the sentinel value themselves — the internal sentinel is `pub(crate)` and is not - reachable from downstream crates. -- **`MdsError::is_string_source() -> bool`** — a new predicate that returns `true` when the - error was produced by the string-source analysis path (`resolve_source_intrinsic`). Use this - instead of comparing `source_name()` against a bare string literal: the internal sentinel - (`SOURCE_LABEL`) is `pub(crate)` and is not accessible from downstream crates. +- **`mds::invalid_vars`** — new error code for malformed or non-object `--vars` + JSON (exits 1). A missing `--vars` file continues to use `mds::file_not_found` + (exits 2). The two failure modes were previously reported as the same generic error. -#### Lint JSON wire contract (#202, #203, #211) +- **Python typed lint result classes** (`crates/mds-python`): `LintDiagnostic` and + `LintFileReport` are now typed, frozen `#[pyclass]` instances. `LintResult.files` + returns a list of `LintFileReport` objects rather than raw dicts. Stubs + (`.pyi` files) and the `mypy`/`pyright` typecheck sample are updated accordingly. -> This block is the **single wire-change ledger** for the lint JSON envelope. -> Later changes to `mds lint --format json` append here rather than opening a -> parallel section, so a consumer has one place to read. +- **Python `CompileResult.to_dict()` always includes `"sourceMap"` key**: the key is + present with value `None` when no source map was generated, and with the map dict + when one was. `to_json()` stays canonical (omits the key when absent) — the + asymmetry is intentional and documented. -**Before / after**, for `mds lint - --format json` on a source with one unused -selective import: +- **WASM CI size guard raised from 800 K to 850 K**: the branch's core growth + (span attribution machinery, `end_offset` fields, `FixLineSpan` planner) pushed + the optimized WASM binary to approximately 808 KB locally. The guard in `ci.yml` was raised + accordingly. CI-measured size after all wave PRs: 836,126 bytes (Binaryen v129; the + local wasm-opt v117 bundled by wasm-pack reads roughly 2-3 KB higher). -```jsonc -// abbreviated — see spec.md for the full schema -// before -{ "files": [ { "diagnostics": [ - { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } }, - { "rule": "unused-import", "span": { "length": 7, "offset": 0 } } - ], "file": "input.mds" } ], "truncated": false, "version": 1 } +- **Code of Conduct** (#38): `CODE_OF_CONDUCT.md` at the repository root, using + Contributor Covenant 2.1 with `deanshrn@gmail.com` as the enforcement contact. + Linked from `CONTRIBUTING.md` and `README.md`. -// after -{ "files": [ { "diagnostics": [ - { "rule": "unused-import", "span": { "length": 5, "offset": 10 } }, - { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } } - ], "file": "" } ], "truncated": false, "version": 1 } -``` +- **Source-hygiene CI gate** (#288): `scripts/verify-no-control-bytes.mjs` scans + every tracked file for hazardous codepoints — C0 control characters (excluding + TAB and LF), DEL, C1 (at codepoint level, catching UTF-8-encoded NEL U+0085), + the twelve `Bidi_Control=Yes` characters (Trojan Source / CVE-2021-42574), the + JavaScript line/paragraph separators U+2028 and U+2029, and U+FEFF (BOM). + Runs in CI on every pull_request and on tag pushes (release.yml). An opt-in + pre-commit hook (`scripts/hooks/pre-commit`) is provided; it reads the staged + blob via `git cat-file`, not the working tree. Also remediates seven live + U+0085 bytes that had been injected into tracked source by the edit tooling + (PF-018). -**A consumer breaks if it** keys off `files[].file == "input.mds"` for CLI stdin -output, matches `` in a rendered diagnostic frame (stderr only — the JSON -`error.message` field cannot carry source identity; no `MdsError` Display template -interpolates `ctx.file_str`, per AD-211-5), relies on -`diagnostics[]` arriving in rule-execution order, assumes `unused-import` -spans have length 7, relies on the `mds lint ` file-group order being -component-wise (`Path::Ord`), or on Windows assumes `files[].file` values use -the native backslash separator. File groups are now ordered by the byte-wise string -of the relative display path (e.g. `api-utils.mds` sorts before `api/x.mds` -because `'-'` (0x2D) < `'/'` (0x2F)). On Windows, `relative_display` normalises -path separators to forward slashes, so a nested path that previously appeared as -`sub\c.mds` in the JSON now appears as `sub/c.mds`; a consumer that string-matches -or splits on `\` in `files[].file` values will silently fail to match. +- **Pre-merge check verifier** (#289): `scripts/verify-pr-checks.mjs` guards + against PF-017 (a cancelled CI run reads as "not failing" to `gh pr merge + --admin`). It evaluates three tiers: Tier A asserts every required + branch-protection context is `completed+success`; Tier B fails on any + non-required check-run that concluded + `failure/cancelled/timed_out/action_required/stale`; Tier C (legacy commit + statuses) is advisory. It emits a `gh pr merge --squash --match-head-commit + ` command pinned to the verified SHA. Exit 0: Tier A and Tier B pass; + exit 1: any Tier A/B failure or zero check-runs found; exit 2: + tool/permission errors. -**1. Diagnostics are sorted by byte offset (#202).** Within each -`files[].diagnostics` array, diagnostics are ordered by ascending `span.offset` -for results produced by the lint engine; a `LintResult` assembled directly via -`LintResult::new` is emitted in the order the caller supplied. -Previously the order was rule-execution order (implementation-defined). -- Diagnostics without a span sort to the end of their file group. -- Equal-offset diagnostics preserve rule-execution order (stable sort). -- File groups have a defined order: `mds lint ` sorts `files[]` by the - byte-wise (lexicographic) string comparison of the relative display path — e.g. - `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) < `'/'` (0x2F). - This is a CLI directory-mode contract only: the binding surfaces (napi / WASM / - Python) lint a single entry source, so their `files[]` array never carries more - than one entry. -- Ordering is established on `LintResult.diagnostics` itself, so the CLI human - path and the napi / WASM / Python surfaces observe the same order. -- **Truncation is unchanged and is NOT offset-ranked.** When `truncated` is - `true`, the retained diagnostics are still the first `MAX_DIAGNOSTICS` (1,000) - in rule-execution order, re-sorted afterwards — not the 1,000 smallest offsets. -- **Sort cost (AC-P1-22):** The sort key is a borrowed tuple `(bool, &str, bool, - usize)` — zero per-comparison heap allocations. The sort runs at most once per - `LintResultBuilder::build` call over n <= `MAX_DIAGNOSTICS` (1,000) items. +### Changed -**2. The stdin source identity is always `` (#211).** Every CLI context -that names a stdin source now uses the single sentinel ``: +- **TypeScript: `LintDiagnostic.help` and `LintDiagnostic.span` widened to include `null`** + (`help?: string | null`, `span?: LintSpan | null`). The JSON wire format has always emitted + these as `null` (not absent keys) when no hint or span is available -- only the TypeScript + declaration was narrower than the runtime value. This is a semver event for consumers who + pattern-matched on `diag.span !== undefined` to detect the no-span case; after this change, + both `undefined` and `null` indicate "no span" and the guard should use `diag.span != null`. + Matches the already-correct `fix_edits?: ... | null` pattern on the same interface. -- the JSON `files[].file` key (previously `"input.mds"`, the internal VFS key); -- human diagnostic frames for `mds lint -` (previously `input.mds`); -- fix-preview status lines and diff headers (previously bare `stdin`); -- the **analysis-failure envelope** — a stdin source that fails the check gate - used to render `:L:C`, the resolver's internal label. `mds check -` and - `mds build -` rendered `` on the same path and now render `` - too, so all four subcommands agree. Note: the analysis-failure JSON envelope - shape is `{"version":1,"error":{"code","message","help","span"}}` — it carries - **no `file` key** (unlike the success envelope which has `files[].file`). A - JSON consumer reading `error` results MUST NOT look for a `file` key there. +- **`mds build --quiet ` no longer prints its summary line on a fully-successful run (#216).** + Previously `mds build --quiet ` printed `N built, 0 failed` even when every file + succeeded. CI jobs that grep for `N built` in their logs should note that this line is now + suppressed under `--quiet` on a clean run. When any file fails the summary is still always + printed, so a non-zero exit under `--quiet` is never unexplained. Exit codes are unaffected. -`mds::STRING_SOURCE_MAP_LABEL` is **unchanged** and remains `"input.mds"`: it is a -virtual-FS entry key, not a display label. The napi, WASM and Python lint APIs -continue to report `"input.mds"` for string-source input. The relabel is applied -only at the CLI output boundary. +- **`mds lint --quiet` now suppresses the remaining `--fix` status messages (#216).** The + `fix rejected: ` notice (emitted when the three-tier safety gate refuses a fix and + leaves the file unchanged) and the `diagnostic cap (N) reached` notice are now suppressed + under `--quiet` in **all three input modes** — directory, single file, and stdin. Previously + the apply-path `fix rejected:` notice was ungated in all three modes, so `mds lint --fix + --quiet` printed it to stderr under directory, single-file, and stdin input alike. The + preview-path copy (under `--fix --check` or without `--fix`) was already `--quiet`-gated in + most modes but remained ungated in `--format json` directory mode. The `diagnostic cap (N) + reached` notice was ungated in single-file and both directory modes; in stdin mode it is newly + added by this release (stdin previously had no cap notice at all — see Added below). + Additionally, `Fixed: ` and `Would fix: ` confirmation lines are newly added to + `--format json` directory mode — previously these lines appeared only in `--format human`; + all three new emitters are `--quiet`-gated from the start. Scripts that grep stderr for + `fix rejected` must drop `--quiet`. Exit codes are unaffected, and error-severity diagnostics + still print under `--quiet` as always. -**Zero-diagnostic behaviour:** when stdin lint completes with no findings, the -JSON is `{"files":[],"truncated":false,"version":1}` — no file entry. The -`` sentinel appears in `files[0].file` only when at least one diagnostic is -emitted. This matches non-stdin zero-diagnostic behaviour and keeps the JSON -identical across the CLI and binding surfaces (napi, WASM, Python) for the clean -case. -**3. `unused-import` spans anchor at the unused name (#203).** For selective -imports (`@import { name1, name2 } from "path"`), the span now covers the unused -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. - -#### New `fix_edits` field on `LintDiagnostic` - -`LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; -an array of `{start, end, new_text}` byte-span edit objects when fixable). This -field is present across all binding surfaces: CLI JSON output, napi -(`LintDiagnostic.fix_edits?: …`), WASM, and Python -(`LintDiagnostic.fix_edits: list[dict] | None`). - -### Security - -- **Source Map v3 `sources[]` no longer leaks absolute filesystem paths** across - all surfaces. Previously, `compileFile` on napi and Python emitted the absolute - filesystem path (e.g. `/home/user/project/src/foo.mds`) as `sources[0]` in the - generated Source Map v3. Shipped source maps and inline maps embedded with - `--inline` could expose the full path of the machine that compiled the template, - a privacy-significant information disclosure. Fixed by the `relativize_source` - choke-point in `crates/mds-core/src/source_path.rs` (ADR-005 Phase A): all - surfaces now emit root-relative paths (e.g. `src/foo.mds`) relative to the - project root (located via `.mdsroot` / `.git` walk-up), and `..`-escaping - references outside the project root fall back to the basename. (#3) - -- **Control-byte injection hardening (CWE-150 / #176):** Raw C0 / DEL / C1 - control bytes in `.mds` source content could reach terminal stderr and - JS / Python / WASM API error messages, enabling terminal escape-sequence - injection. The serialization and diagnostic-render boundaries hardened here are - `MdsError::serialize()` (inherited by all three binding layers), - `LintResult::to_canonical_json()` including the `"file"` group key, - `CompileResult::to_canonical_json()` warnings, and the CLI render path. That is - an audit list, **not a closed set**: the governing rule is the per-field one - below, and the residual it leaves is named there. Enumerating boundaries is - exactly the framing this changelog retires further down. - The CLI render path (PF-014 redesign) sanitizes the renderer's *source-excerpt* - input byte-length-preservingly — hostile C0/DEL/C1 bytes become `?` (C0/DEL) or - NBSP (C1) so span offsets and caret columns stay exact and miette's own SGR - colour codes survive intact on TTY. `message` and `help` are renderer inputs too, - but they are `\uXXXX`-escaped rather than length-preserved; only source text - carries the byte-length invariant. A new - `MdsError::display_sanitized()` public API is provided for Rust consumers; - the raw `Display` impl is preserved with an explicit unsafety contract in - its rustdoc. `span` byte offsets, `fix_edits` byte ranges, and `rule` - identifiers are deliberate exclusions — they carry position data, not - terminal-bound text. (#176) - -- **CLI error *message* text is now escaped too (#176).** The hardening above - covered rendered source excerpts, filenames, and the diagnostic wire boundaries, but a - diagnostic's own message and help text still reached stderr raw. Both CLI error - families interpolate untrusted input into their messages — compiler errors carry - template text (`invalid include alias: ''`) and CLI errors carry `mds.json` - values and filesystem paths (`mds.json output_dir '' must not contain '..'`) - — so a hostile `.mds` file or config value could still emit raw ANSI escape - sequences to a terminal. `mds build`, `check`, `fmt`, `lint`, and `watch` now escape - each report's message, help, and caret-label text at the single `eprint_error` - choke-point, *before* the diagnostic renderer runs. The rendered frame is still never - post-processed, so terminal colour and caret alignment are unaffected, and output for - well-formed input is byte-for-byte unchanged. (#176) - -- **Every CLI print now escapes what it interpolates, and CI enforces it (#176).** - Warning and status prints scattered across `main.rs`, `build.rs`, `fmt.rs`, `lint.rs`, - `watch.rs` and `output.rs` interpolated filenames, `mds.json` rule names, `--format` - arguments and `io::Error` causes into `eprintln!` raw, bypassing the - `sanitize_control_chars` call that `mds-core`'s `emit_warnings` applies on the primary - code paths (a PF-004 parallel-path gap). The two most directly reachable: - - - a rule NAME in `mds.json` is an arbitrary JSON object key, and a JSON `\uXXXX` - escape decodes to a real byte, so any repository could put a raw ESC on a - developer's stderr — or forge whole `Clean: …` / `0 problems found` status lines — - just by being linted; - - the shared directory walker's depth-limit warning named the directory it stopped at, - so one hostile directory name reached `mds build`, `check`, `fmt`, `lint` and - `watch` at once. - - All of them now apply the per-field rule below: the warning *body* goes through - `eprint_warning` (HUMAN), and every value interpolated into it goes through - `safe_path` / `safe_inline` (WIRE). `watch.rs`'s lifecycle status lines - (`Watching {}`, `Removed {}`, `warning: could not remove {}: {e}`) — previously - carved out as a pre-existing gap — are included. - - **This is now a machine-checked invariant, not an enumeration.** A new - `crates/mds-cli/tests/print_discipline.rs` fails CI if *any* print macro under - `crates/mds-cli/src/**` interpolates a value that is not passed through one of the - escape helpers. It applies the same rule to the argument of `eprint_warning` - (HUMAN-mode escaping alone is not sufficient — it preserves `\n`, which is the - line-forgery vector), including when the message has been hoisted into a local: - a bare identifier is traced one hop through its `let` binding and judged the same - way, and an argument the trace cannot resolve is **reported**, not trusted. Because - `let`s are matched file-wide, every `for` variable, function parameter and closure - parameter **poisons** its own name, so a value arriving through one of those is - reported rather than resolved against an unrelated `let` that happens to share the - name. It also - scans `write!` / `writeln!` to a stdout/stderr handle. Deliberate exceptions — the - compiled artefact written to stdout, `&'static str` labels, integer counters, and - whole warning strings produced by `mds-core` — live in explicit allowlists with a - written justification per entry, and a companion test fails if an entry ever stops - matching. The guard is a **lexical** scanner: it catches accidental reintroduction, - and its five known limits (name-matched sanitizers, anti-rot-not-anti-reuse - allowlists, the one-hop single-file trace, name-based stream detection, and the - `if let` / `while let` / `match`-arm binders the poison set does not model) are stated - in its own rustdoc rather than implied away. Four successive reviews of this change - each found a *different* unescaped print; the guard is what ends that. - - The one precondition the guard depends on and cannot check — that `mds-core` WIRE-escapes - the identifiers its warning producers interpolate, since `mds-cli` prints whole - warning strings — is now pinned by `crates/mds-cli/tests/producer_discipline.rs` for - the only producer whose input can carry a hostile character (`resolver.rs`'s - imported-module filename). The other two producers interpolate an `@include` alias, - which the parser restricts to `[A-Za-z_][A-Za-z0-9_]*`, so they are upheld by review - and stated as such rather than claimed to be tested. (#176) - -- **The escape mode is chosen per field, not per surface (#176).** Normative in spec - §7.5: **on the diagnostic surfaces — the `"version": 1` JSON wire, CLI status and - warning lines, `[file:line:col]` frame headers — untrusted identifiers, filenames and - error causes are WIRE-escaped, human terminal output included; prose — a diagnostic - message or help body — stays HUMAN so multi-line frames keep rendering.** The - rule governs *diagnostic* output; the two carve-outs below are not diagnostics and are - not escaped at all. The discriminator is whether the - value is ever legitimately multi-line: a filename, a config key, a `--format` - argument and an `io::Error` never are, so preserving a raw `\n` in one buys nothing - and lets it forge a standalone line byte-identical in form to genuine output - (CWE-117). This supersedes the earlier per-surface framing and the "wire mode at - exactly four boundaries" enumeration. A new `mds::sanitize_control_chars_wire` and - `mds::named_source_for_render` are public in `mds-core` for consumers that need to - apply the same rule. - - **Declared carve-out: functional path references are NOT escaped.** Source-map - documents (the `mds build --source-map` sidecar, and the `sourceMap` embedded in - `CompileResult.to_canonical_json()`) emit their `file`, `sources` and `sourcesContent` - values **verbatim**, as does the `dependencies` array. These are functional references - that devtools, bundlers and IDEs resolve against the filesystem — rewriting a path to a - `\uXXXX` literal would point at a path that does not exist, breaking source-map - resolution and dependency tracking to defend against a pathological filename. That is - the same product-versus-display distinction that keeps compiled output byte-faithful. - **Consumers of a source map or of `dependencies` must treat every path in them as - untrusted** and escape it for whatever destination they render it to; JSON string - encoding is not that escaping, since a decoded `"\n"` is a real newline again. The CLI - does not rely on this: its `Compiled to …` and `Source map written to …` lines print - through `safe_path` and carry the escaped form even though the sidecar does not. - Specified in spec §7.5 ("Carve-out: functional path references"). (#176) - - **Declared residual.** "Identifier / filename / cause" means the value occupies such a - *field* — a CLI status line, a `[file:line:col]` frame header, the JSON `file` key. A - path or identifier interpolated into a diagnostic **message body** is part of prose, - so it follows the message row and stays HUMAN on terminal surfaces. That applies at - both message-construction sites: the CLI's `miette::miette!()` reports **and** - `mds-core`'s `MdsError` message bodies (`fs.rs`'s `cannot read {path}: {e}`, - `parser_helpers.rs`'s `invalid import alias: '{alias}'`). A `\n` in one of those - survives into the rendered frame and takes a line there. It is a weaker surface than a - status line — frame content is indented and `│`-prefixed, and the prefix survives - `strip()`, so it cannot masquerade as genuine bare status output — and no raw control - byte reaches the terminal either way. Closing it means WIRE-escaping over a hundred - `MdsError` construction sites and changing the public message text seen by all three - binding layers; that is a separate change. Disclosed in spec §7.5 ("Residual: paths and - identifiers inside a message body") and in the boundary table in - `crates/mds-core/src/lint/diagnostic.rs`. (#176) - -- **Hostile filenames can no longer forge CLI status lines (CWE-117 / #176).** POSIX - permits a newline inside a filename, and directory-mode commands discover names by - walking the tree — the user never types them. Filename display used HUMAN mode, which - preserves newlines by design so that multi-line diagnostic *messages* keep rendering, - so a file named `evil.mdsClean: real.mdsOK: all-fine.mds` made - `mds build`/`lint`/`fmt`/`check` emit attacker-authored lines byte-identical in form - to genuine status output — unframed, unindented, and indistinguishable. **Diagnostic - filename fields are now escaped in WIRE mode on every surface that renders one, human - included** (source-map paths and `dependencies` are the declared carve-out): `safe_path` - and the status-line printers, and the `[file:line:col]` frame header via a new shared - `mds::named_source_for_render` builder that `MdsError::at()`, the formatter and the - lint renderer all call. Message and help text are unchanged (still HUMAN, still - multi-line). A filename is never legitimately multi-line, so nothing legitimate is - lost. (#176) - -- **Fix-rejection reasons are display-safe by construction (#176).** - `mds::fix::FixOutcome::Rejected.reason` interpolated an `MdsError`'s deliberately-raw - `Display` — whose variants embed template text (`syntax error: {message}`) and - filesystem paths (`file not found: {path}`) — and the CLI prints that value as an - unframed `fix rejected: {reason}` status line. The embedded error is now escaped in - WIRE mode at the single construction site in `fix.rs`, so the field is single-line and - control-byte-free for **every** consumer of the published `mds::fix` API, not just the - CLI's own print sites. (#176) - -- **Widened escape class: bidi / separator / BOM characters (#176).** The - escaped set now covers characters outside C0 / DEL / C1 that are still - display-hazardous, on every surface that escapes: - - **U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069** — the complete - Unicode `Bidi_Control=Yes` set (all twelve codepoints), behind Trojan Source - (CVE-2021-42574). A single U+202E in a filename or diagnostic message - reverses how the rest of the line renders in any bidi-aware terminal, IDE, or - code-review UI. U+061C ARABIC LETTER MARK is the only member outside - U+200E–U+2069 and is easy to miss for exactly that reason. - - **U+2028, U+2029** — LINE / PARAGRAPH SEPARATOR, which terminate a - JavaScript string literal. - - **U+FEFF** — BOM / ZWNBSP, invisible in every renderer. - - Each becomes its uppercase six-character `\uXXXX` literal, exactly like the - existing C0 / DEL / C1 escapes. Source excerpts inside a rendered diagnostic - frame are neutralized to a **same-width** substitute instead, preserving the - byte-length invariant that keeps span offsets and caret columns exact: 1-byte - C0/DEL → `?`, 2-byte C1 and U+061C → U+00A0, 3-byte bidi controls, separators - and BOM → U+FFFD. (#176) - -- **BREAKING (wire format): machine-readable boundaries now escape `\n` (#176).** - `MdsError::serialize()`, `LintResult::to_canonical_json()` (message, help, and - the `"file"` group key), `CompileResult::to_canonical_json()` warnings, and the - Python typed lint surface now emit `\n` as the six-character `\u000A` literal. - A raw newline inside a diagnostic string is a line-forging vector: any consumer - that prints or line-splits the value can be made to render an attacker-authored - line as a genuine second finding. `\t` is unaffected. +- **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 under amended + criterion, repo-owner ruling 2026-08-16: shared body, shared recognised-rules list, + shared sort order; the CLI adds a `"warning: in mds.json: "` provenance prefix that + the bindings cannot provide because their rules arrive in the caller's options + object, not a config file). 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 `, 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. - **Human-render output of diagnostic PROSE is unchanged** — the CLI renderer, - `MdsError::display_sanitized()`, and warning *bodies* on stderr still preserve - raw newlines so multi-line diagnostic frames stay readable. Human output of - diagnostic filenames, identifiers and causes **did** change, by design: under the - per-field rule above those are WIRE-escaped on every surface that renders a - diagnostic, human included, so a newline - in one now renders as the six-character `\u000A` literal instead of forging a - line. Source-map paths and `dependencies` are unaffected: they are the declared - carve-out and stay verbatim. See the two entries below. +- **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`); + now they emit a slash-separated path relative to the project root found via + `.mdsroot` / `.git` walk-up (e.g. `src/foo.mds`). The `@mdscript/mds` + universal package's `compileFile` previously returned different `sources[]` + depending on which backend `init()` loaded (absolute on native, root-relative via + `buildModulesMap` on WASM); both backends now produce identical root-relative paths. + Code that compares `sources[0]` to an absolute path must be updated. (#3) - **Migration:** consumers that split a `message` / `help` / warning string on - `\n` will now see a single line containing the literal `\u000A` where a real - newline used to be. Split on that literal instead, or render the value verbatim. +- **Inline stdout source-map absolute-path leak fixed**: `mds build --source-map + --inline -o -` and `mds build --source-map -o -` no longer leak absolute filesystem + paths in the embedded `sourceMappingURL` data-URI; sources are relativized against + the current working directory. Previously the output path was `None` for stdout + builds, causing the relativization step to short-circuit and leave absolute paths. + (#196) -- **Escaping is one-way — consumers must not un-escape (#176).** The - transformation is lossy and non-injective by design: a template that literally - contains the six characters `\u001B` and one containing an actual ESC byte are - indistinguishable after serialization. **Do not** convert `\uXXXX` sequences - back into bytes — that reconstitutes the injection the escape prevents. Round-tripping is - an explicit non-goal; no backslash-escaping will be added to make the mapping - reversible. Consumers needing original bytes must read them from the source via - the raw `span` / `fix_edits` byte offsets, which stay unsanitized for this - purpose. Documented normatively in spec §7.5. +- **`mds build --inline -o -` for stdin input is now allowed**: previously rejected + with an error. Inline and sidecar source maps now work identically for stdin and + file inputs. The `sources[0]` label is `""` for stdin builds. (#196) -- **`--diff` preview output is TTY-gated (#176).** Applies to both - `mds lint --fix --diff` and `mds fmt --diff`, which share one renderer. - Preview diff text is neutralized when stdout is a terminal (where control bytes - would execute) and emitted **byte-faithful when piped or redirected**, so a - redirected diff remains applicable. Preview output is not part of the - `"version": 1` JSON wire format. +- **lint `--fix --check` and `--fix --diff` are now honest gated previews**: the + preview pass runs through the same reverify gate as apply. Fixes that would be + rejected (overlap, post-fix regression) are reported as `"fix rejected: "` + rather than silently shown as `"would fix"`. Directory mode `--fix --check` exits 1 + when any file has fixable issues. (#196) -### **BREAKING** — Strict cross-type comparisons, merged `@extends` frontmatter, interior-verbatim whitespace, filesystem API +- **Overlap-rejected fix plans are now surfaced**: when `lint --fix` finds overlapping + byte ranges (two rules targeting the same span), the plan is no longer silently + abandoned. The overlap is reported so users know a fix exists but could not be auto- + applied. (#196) -These changes alter observable runtime behavior and compiled output. Templates relying -on the previous (buggy) behavior must be updated. +- **`mds fmt` errors name the file**: formatting errors emitted to stderr now include + the file path as a prefix (e.g. `"src/foo.mds: formatter_invariant: …"`). Previously + file context was absent, making batch `mds fmt .` errors hard to trace. (#196) -#### Cross-type comparisons are now errors (#152) +- **`--vars` JSON errors name the file**: when a `--vars` JSON file is malformed or + does not contain a top-level object, the error message now includes the file path. + (#196) -`@if a == b:` or `@if a != b:` where `a` and `b` are different types (e.g. a number -vs. a string, or a boolean vs. null) now raises `mds::type_mismatch` at runtime -instead of silently returning `false` (for `==`) or `true` (for `!=`). +- **stdin `mds lint` code frames**: lint diagnostics for stdin input now include a + miette code frame with `` as the source label. Previously stdin lint + diagnostics lacked source context. (#196) -**Migration:** add an explicit conversion before comparing: -- `@if string(count) == "3":` — convert number to string -- `@if count == 3:` — compare number to number literal +- **Bare relative filenames now work** for all subcommands and the `compile_str` + binding family. Running `mds build foo.mds` (without a `./` prefix) from the file's + directory previously failed on some platforms because the parent-path resolution + produced an empty path instead of `.`. Fixed by `effective_parent` in `fs.rs`. (#196) -#### Cross-flag duplicate keys in `--set` / `--set-string` are now a hard error (#152) +- **`mds fmt` formatter-invariant gate false positive on trailing blank lines is + fixed**: templates containing trailing blank lines (e.g. `@if … @end\n\n`) were + incorrectly rejected by the safety gate with `mds::formatter_invariant` after being + formatted. The gate now correctly ignores insignificant trailing whitespace. (#196) -Supplying the same variable key via both `--set KEY=VALUE` and `--set-string KEY=VALUE` -in a single invocation is now rejected at startup with an explicit error. +- **Lint diagnostic messages now consistently end with a period** (G3 message-copy + consistency): all `empty-block` and `unreachable-branch` rule messages are + punctuated uniformly. (#196) -**Migration:** remove the duplicate key from one flag. +- **Messages-mode source-map warning reworded and deduplicated**: the warning emitted + when `sourceMap: true` is requested on a messages-mode template now reads "source + maps are not supported for messages-mode templates (@message blocks); no source map + will be generated" across all surfaces. The warning is emitted exactly once per + compilation (previously it could appear twice for some template shapes). (#196) -#### `@extends` emits deep-merged frontmatter (#154) +- **`mds::syntax` error label no longer duplicates the message**: the miette diagnostic + label was previously set to `{message}` (same as the headline), producing redundant + output in code-frame renderings. It now reads `"syntax error occurred here"`. (#196) -Compiled output for a child template now contains the **deep-merged** frontmatter -(base keys + child keys, child wins on collision, reserved keys `imports`/`type`/`extends` -excluded) rather than only the child's raw frontmatter. Base-only frontmatter keys -now appear in the compiled output. +- **Imported-macro `type_mismatch` errors now point to the defining file**: when a + type mismatch is raised inside a `@define` body that was imported from another file, + the error frame now names the helper file and shows the relevant line (e.g. + `helper.mds:3:5`) rather than pointing at the call site in the importing file. + Implemented via `FunctionDef.origin` (always-populated, one `Arc::from(source)` per + module) and `EvalContext.body_origin` (LIFO swap around body evaluation). The + performance trade-off (one Arc per module instead of zero) is an explicit + AC-PERF-01 relaxation accepted for span correctness. -**Migration:** if your pipeline depends on base frontmatter keys being absent from the -compiled output, strip them downstream or move them to a non-frontmatter location. +- **Parser syntax errors now carry directive-line spans**: approximately 20 previously + spanless `mds::syntax` errors now include a source span pointing at the directive + line that caused the error (implemented via `MdsError::or_span`). This affects + common mistakes such as unclosed strings in frontmatter and malformed directive + arguments. -#### Interior-verbatim whitespace contract for `@block` bodies and `mds fmt` (#150, #151) +- **`ArityMismatch` help now shows the expected signature**: the help text for a + wrong-argument-count error now includes the function's expected call form (e.g. + `pair(a, b)` or `f(x, y="admin")`), rendered from the `@define` parameter list. + Previously it only said to check the call site. -Leading blank lines and interior blank runs inside `@block` bodies and -`mds fmt` output are now preserved verbatim; previously they were collapsed or stripped. -The `mds fmt` blank-line collapsing rule (R3) has been removed to maintain compile -equivalence with the updated evaluator behavior. Only the trailing edge normalizes (to -exactly one final newline). `@message` and `@define` bodies continue to edge-trim — -leading and trailing blank lines are stripped — and are not covered by this contract. +- **`TypeMismatch` help text rewritten**: the help message now distinguishes three + scenarios — comparing a variable to a literal of a different type (suggests an + explicit conversion), using a non-boolean value in an `@if` truthiness check (notes + that any non-null, non-false value is truthy), and confusion from `--set` type + coercion (suggests `--set-string` to keep a string value byte-for-byte). -**Migration:** compiled outputs may gain blank lines that were previously collapsed or -stripped; templates relying on this collapse must remove the extra blank lines at the -source level. +- **`--vars` missing file exits 2 with `mds::file_not_found`**: previously a missing + `--vars` path produced a generic I/O error. It now exits 2 (I/O error) with a + structured `mds::file_not_found` diagnostic including a help note. -#### `FileSystem` trait now requires `normalize_in_dir` and `parent_dir` (#146) +- **Config-sourced `source_map=true` degrades gracefully on stdout output**: when + `build.source_map=true` is set in `mds.json` and the output is stdout (`-o -`), + the build now proceeds with exit 0 and a single warning naming the config file + (suggesting `--no-source-map` or `-o `). Previously this combination produced + a confusing double-error. An explicit `--source-map -o -` flag combination still + hard-errors with an extended message (`-o ` / `--out-dir` / `--inline` / + `--no-source-map`). -`FileSystem` now requires two new methods — `normalize_in_dir` and `parent_dir` — that -replace the internal `` path-sentinel pattern. String-source `@import`/`@extends` -resolution is now directly directory-anchored: `ctx.base_dir` carries the importing -directory explicitly, with no synthetic filename appended. No behavior change for -`compile`/`check` users; only affects code that implements the `FileSystem` trait -directly via `ModuleCache::with_fs`. +- **Config `embed_sources=true` without `source_map=true` now warns** at all merge + sites (mds.json merge, `--vars` merge, CLI flag merge). Previously the warning was + emitted inconsistently. -### **BREAKING** — Options validation, directory walker, source-map labels, check API (#196) +- **`mds fmt` and `mds lint` check path existence before checking the `.mds` + extension**: a path that does not exist now exits 2 with `mds::file_not_found` + (rather than the "not an MDS file" extension error). `mds lint` preserves the JSON + envelope for a missing-file error in `--format json` mode. -- **`@mdscript/mds` now rejects unknown option keys** with - `Error { code: 'mds::invalid_options' }` before forwarding to the backend. Previously - unrecognized keys were silently passed through (napi and WASM backends would reject - them, but the universal JS wrapper did not validate). Callers with typos in option - objects will now get immediate, accurate error messages. (#196) +- **WASM `check()` rejects `sourceMap`, `sourcesContent`, and unknown option keys** + with `mds::invalid_options`. Previously these keys were silently ignored by the + WASM backend's `check` function. -- **`CheckOptions` is now split from `CompileOptions`** in `@mdscript/mds`. - `check()` and `checkFile()` accept only `{ vars? }` — source-map options - (`sourceMap`, `sourcesContent`) are not valid for check calls and are rejected with - `mds::invalid_options`. `CompileOptions` retains `sourceMap`/`sourcesContent`. - TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196) +- **Python `check()` rejects `source_map` and `sources_content` options** with + `mds::invalid_options`. These options are valid for `compile()` but not for + `check()`. -- **String-source `sourceMap` label changed from `""` to `"input.mds"`** - across all surfaces (CLI, napi, WASM, Python). The `sources[0]` entry in Source Map v3 - output for `compile(src, {sourceMap:true})` / `compile_str*` / WASM `compile` now reads - `"input.mds"` instead of `""`. CLI stdin builds use `""` (unchanged). - Code inspecting `sources[0]` for the string `""` must be updated. (#196) +- **Fix-rejection message is now actionable**: when the reverify gate declines a fix + (because the edited source fails to reparse or produces different output), the + message now reads "could not verify fix — the edited source did not re-parse + cleanly (reason); leaving the file unchanged" rather than a generic internal note. -- **Directory walker now excludes hidden directories and `node_modules` by default** - across all subcommands (`mds build`, `mds check`, `mds watch`, `mds fmt`, - `mds lint`). Directories whose name starts with `.` (e.g. `.git`, `.venv`) and - `node_modules` are silently skipped during recursive traversal. Templates inside these - directories are no longer compiled, formatted, or linted in directory mode. (#196) +- **`unused-import` documented as report-only in practice**: the JSON `"fixable"` key + for `unused-import` findings is always `false`. A file that triggers this rule has + at least one `@import` directive, which makes it non-standalone; Tier B fixes + require a standalone file, so the fix is never delivered. The rule is worth keeping + for awareness — it clears as a side effect of applying other fixes (e.g. removing a + duplicate import that was also the unused one). -- **`mds check` summary wording changed** from `N checked` to `N passed, M - failed`. Scripts parsing CLI output must be updated. (#196) +- **WASM binary size reduced via wasm-opt flag tuning and rustc toolchain pin**: the wasm job + now pins rustc to 1.96.0 so the size guard measures code changes rather than compiler drift; + wasm-opt flags were tuned (`-Oz --flatten --rereloop -Oz --converge --strip-producers`) and + four tie-free sort sites switched to `sort_unstable`. CI-measured size: 836,126 bytes + (Binaryen v129) against the 850,000-byte guard. -- **lint `--format json` `"file"` keys are now full relative paths** in directory mode. - When running `mds lint --format json .`, the `"file"` key in each JSON result is now - the path relative to the lint root (e.g. `"src/template.mds"`) rather than just the - basename (e.g. `"template.mds"`). This prevents key collisions when two different - files have the same filename. (#196) +### Deprecated -- **`mds-core::CompileOptions` gained `source_map_base: Option`**. Rust code - that initializes `CompileOptions` with a struct literal must either add - `source_map_base: None` or use the `..Default::default()` tail. Binding surfaces - (napi, Python, WASM) are not affected. (#3) +- **`mds::fix::apply_fixes` is deprecated in favor of `apply_fixes_incremental` (#209).** + The replacement applies the same ADR-004 three-tier reverify-gate safety contract with a + batch-first attempt plus a bounded per-edit fallback, salvaging the safe subset of fixes + when some edits fail the reverify gate rather than refusing the whole batch. -### **BREAKING** — Error/lint messages now carry `\uXXXX` literals for embedded control bytes (#176) + Two behavioral differences require manual migration: -Across the JS / Python / WASM API surfaces, `err.message`, `err.help`, and lint -`LintDiagnostic.message` / `LintDiagnostic.help` now contain six-character `\uXXXX` -Unicode escape literals (e.g. `\u001B`, `\u007F`, `\u0085`) wherever MDS source -content caused raw C0-minus-`\n`/`\t`, DEL (U+007F), or C1 (U+0080–U+009F) control -bytes to appear in error or diagnostic messages. + - **Closure bound**: `apply_fixes` takes `F: FnOnce`; `apply_fixes_incremental` requires + `F: Fn` because the reverify closure may be called more than once. A move-once closure + cannot be migrated mechanically. + - **New reachable outcome**: `apply_fixes_incremental` can return + `FixOutcome::PartiallyFixed` when some edits are accepted and some are refused. + `apply_fixes` never returns `PartiallyFixed`. Because `FixOutcome` is + `#[non_exhaustive]`, existing wildcard arms compile unchanged, but a wildcard that + swallows `PartiallyFixed` silently discards partial results. -**Not affected:** `span.offset`, `span.length`, and `fix_edits` byte ranges are raw -byte offsets and are never sanitized. The `rule` field is a fixed ASCII identifier. -The `"file"` key in lint JSON output is sanitized on the same pass as `message`/`help`. + Scheduled for removal in v0.5.0; tracked in GitHub issue #304. The six ADR-004 + regression tests pinned only against `apply_fixes` must be ported or retired before + the v0.5.0 tag (see #304 for the enumerated list with line numbers and the behavior + each test pins). -**Migration:** consumers that test for exact control byte sequences in error or -diagnostic messages must update to check for the `\uXXXX` literal form instead. +- **`mds::LintConfig::from_rules` is deprecated in favor of `LintConfig::from_rules_checked` (#224).** + The replacement returns both the config and an unknowns report in a single `#[must_use]` + call, making it structurally impossible to silently skip unknown-rule detection. + `from_rules` still accepts any rule name without error; unknown names have no effect. -### Changed + Migration: change `LintConfig::from_rules(map)` to `LintConfig::from_rules_checked(map)` + and handle the `Option` second return value. No removal is scheduled + before v1.0.0; this function will remain available throughout the v0.x series (contrast + `apply_fixes` above, which is scheduled for removal at v0.5.0). -- **`mds build --quiet ` no longer prints its summary line on a fully-successful run (#216).** - Previously `mds build --quiet ` printed `N built, 0 failed` even when every file - succeeded. CI jobs that grep for `N built` in their logs should note that this line is now - suppressed under `--quiet` on a clean run. When any file fails the summary is still always - printed, so a non-zero exit under `--quiet` is never unexplained. Exit codes are unaffected. +### **BREAKING** — File-method `basePath` rejection, TypeScript option types, and WASM `basePath` rejection (#180, #213) -- **`mds lint --quiet` now suppresses the remaining `--fix` status messages (#216).** The - `fix rejected: ` notice (emitted when the three-tier safety gate refuses a fix and - leaves the file unchanged) and the `diagnostic cap (N) reached` notice are now suppressed - under `--quiet` in **all three input modes** — directory, single file, and stdin. Previously - the apply-path `fix rejected:` notice was ungated in all three modes, so `mds lint --fix - --quiet` printed it to stderr under directory, single-file, and stdin input alike. The - preview-path copy (under `--fix --check` or without `--fix`) was already `--quiet`-gated in - most modes but remained ungated in `--format json` directory mode. The `diagnostic cap (N) - reached` notice was ungated in single-file and both directory modes; in stdin mode it is newly - added by this release (stdin previously had no cap notice at all — see Added below). - Additionally, `Fixed: ` and `Would fix: ` confirmation lines are newly added to - `--format json` directory mode — previously these lines appeared only in `--format human`; - all three new emitters are `--quiet`-gated from the start. Scripts that grep stderr for - `fix rejected` must drop `--quiet`. Exit codes are unaffected, and error-severity diagnostics - still print under `--quiet` as always. +#### `compileFile` and `checkFile` now reject `basePath` (#180) -### Added +`compileFile(path, options?)` and `checkFile(path, options?)` previously accepted a +`basePath` option and silently discarded it — the option passed the unknown-key +validator but was dropped before the backend was reached, so file resolution always +used the directory containing the path. Both functions now **throw synchronously** +(`Error { code: 'mds::invalid_options' }`) when `basePath` is non-null. The base +directory for file-based operations is always derived from the file path itself. -- **`mds lint ` directory-mode summary (#216).** After linting a directory, one summary - line is printed to stderr: - `N clean, N with warnings, N with errors, N resource-limited` - Under `--quiet`, the summary is suppressed when the worst outcome is warnings only (mirrors - `mds fmt`); it is always emitted when any file is in the error or resource-limited bucket. - Scripts or tests that relied on `mds lint ` producing no stderr on a clean tree should - note that this summary line is now always printed on a clean run (to suppress it, pass - `--quiet`). The JSON stdout envelope (`{"files":…,"truncated":…,"version":1}`) is unchanged - — no `"summary"` key is added, so existing consumers of `--format json` are unaffected. - Exception: `mds lint --fix --check --quiet ` exits 1 with zero stderr bytes when - pending fixes exist but no file has errors or hits a resource limit — the exit code is - unexplained on the surface but is intentional and documented in `--help`. +**Migration:** remove `basePath` from any options object passed to `compileFile` or +`checkFile`. This throw is **synchronous** — `.catch()` on the returned promise does +not receive it; wrap the call in `try/catch`. This is also a **compile-time break** +when a variable typed as `CompileOptions` or `CheckOptions` is passed to these +functions — see the compatibility notes below. Audit all call sites. -- **`mds lint --fix` adds stderr parity across all three input modes (#216).** Three status - messages that previously appeared in some modes but not others are now present in all three - input modes, each suppressed under `--quiet`: - - **Stdin diagnostic-cap notice**: `mds lint --fix -` on inputs that exceed the diagnostic cap - now prints `diagnostic cap (N) reached; further findings were suppressed — re-run --fix to - continue` to stderr. This line did not exist in stdin mode before this release. stdout (the - fixed source) is unaffected. - - **`Fixed: ` in `--format json` directory mode**: previously emitted only by - `--format human`; now also printed per fixed file in `--format json`. The JSON document on - stdout is unchanged. - - **`Would fix: ` in `--format json` directory mode**: same — previously human-only, now - also printed in JSON mode under `--fix --check`. - Scripts or tests that assert zero stderr from `mds lint --fix -` or - `mds lint --fix --format json ` on non-quiet runs should note these additions. +#### `FileOptions` no longer extends `CompileOptions` (#213) -- **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. - - `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. - - 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. +`FileOptions` (used by `compileFile`) was previously declared as +`interface FileOptions extends CompileOptions`. This inheritance was an error: +`CompileOptions` now carries `basePath`, which is not valid for file-based +operations. `FileOptions` is now a standalone interface with its own `vars`, +`sourceMap`, and `sourcesContent` fields. -- **`--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`). - Repeatable. (#152) +**Compatibility:** this change is a **compile-time break** for code that passes a +`CompileOptions`-typed variable to `compileFile`. After this PR, `CompileOptions` +carries `basePath?: string` while `FileOptions` declares `basePath?: never`; +TypeScript reports `"Types of property 'basePath' are incompatible"` at any such +assignment or call. Code that never reuses a string-surface options variable for +file operations compiles without changes. -- **`mds fmt`** — an opinionated, safety-gated auto-formatter for `.mds` templates. Every - rewrite is guaranteed compile-equivalent: a runtime safety gate re-compiles the formatted - source and refuses to write if it would change compiled output (`mds::formatter_invariant`) - rather than silently corrupting a template. Normalizes CRLF to LF everywhere (including - inside frontmatter and code fences), strips trailing whitespace on directive lines, and - ensures exactly one trailing newline — while leaving interior blank lines, blank-line - structure within frontmatter and code fences, body-text trailing whitespace (Markdown - hard breaks), and the byte-for-byte content of `@message`/`@define` bodies untouched. - Supports a single file, a directory (recursive, including `_`-prefixed partials), or - stdin (`-`, as a filter); `--check` exits non-zero without writing when anything would - change, and `--diff` prints a unified diff (colorized on a TTY) without writing. New - public `mds-core` API: `format_str` / `format_str_with`. (#60) +**Migration for shared variables:** retype the variable as `FileOptions`, or +destructure only the accepted fields: +```ts +const { vars, sourceMap, sourcesContent } = compileOpts; +compileFile(path, { vars, sourceMap, sourcesContent }); +``` -- **Native Python bindings** (`crates/mds-python`, PyO3 + maturin), to be distributed - as `mdscript` on PyPI. Seven functions — `compile`, `compile_file`, - `compile_virtual`, `check`, `check_file`, `check_virtual`, and `scan_imports` — - with idiomatic keyword-only signatures. Results are typed, frozen, and picklable - (`CompileResult` / `Message` / `Span` / `CheckResult`), and failures raise a native - `MdsError` carrying `.code` / `.message` / `.help` / `.span`. Ships `.pyi` stubs + - `py.typed` and exposes `__version__`. Output is byte-identical to the Rust, - Node.js, and WASM bindings (shared core serializer). Built as an `abi3-py311` - (`cp311-abi3`) extension; each compile releases the GIL and the module is - free-threading ready (`gil_used = false`), enabling multi-threaded use. - Cross-platform wheel matrix and PyPI publishing are a tracked follow-up (#132) — - for now, install from source: `pip install ./crates/mds-python`. (#59) +#### `checkFile` parameter type changed from `CheckOptions` to `CheckFileOptions` (#213) -- **`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). +`checkFile(path, options?)` previously accepted `CheckOptions`. After this PR, +`CheckOptions` carries a `basePath` field that is not valid for file-based operations; +the parameter is now typed as `CheckFileOptions` — a new interface with only +`vars?: Record`. - **Rules** (individually configurable via `mds.json` `lint.rules` or the - `rules` API option; severities differ per rule): - - `unused-variable` (warn): frontmatter key defined but never referenced in the body - - `unused-import` (warn): `@import` never used in the file (Tier B: auto-fixed only for standalone files) - - `unused-function` (warn): `@define` function never called in the file (Tier B: auto-fixed only for standalone files) - - `shadow-variable` (off by default / info when enabled): 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) - - `redundant-else` (warn): `@else` body is structurally identical to the `@if`/`@elseif` then-body (Tier C — never auto-fixed) - - `unreachable-branch` (error): branch condition is always-true or always-false (auto-fixable) - - `duplicate-import` (error): same file imported more than once (auto-fixable) - - `duplicate-export` (error): same export name defined more than once (auto-fixable) +**Compatibility:** this type narrowing is a **compile-time break** for code that +passes a `CheckOptions`-typed variable to `checkFile`. `CheckOptions` carries +`basePath?: string` while `CheckFileOptions` declares `basePath?: never`; TypeScript +reports `"Types of property 'basePath' are incompatible"` at any such call. Code +that never reuses a string-surface variable for `checkFile` compiles without changes. - **CLI** (`mds lint`): file, directory, and stdin input modes; `--fix` for - auto-fixable issues (Tier A always; Tier B for standalone files); `--check` - and `--diff` preview modes for CI; `--format json` for machine-readable output; - `--quiet` to suppress warnings; `--vars`/`--set`/`--set-string` for variable - overrides forwarded to the check gate. +**Migration for shared variables:** retype the variable as `CheckFileOptions`, or +restrict it to `{ vars?: Record }` at the call site. - **Exit codes** (lint-specific): `0` = clean, `1` = warnings only, `2` = errors - or analysis failure, `3` = resource limit. +#### `LintFileOptions` gained `basePath?: never` (#213) - **Canonical JSON shape** (keys alphabetical, BTreeMap order): - ```json - {"files":[{"diagnostics":[...],"file":"template.mds"}],"truncated":false,"version":1} - ``` +`LintFileOptions` (used by `lintFile` and `lintVirtual`) previously had the shape +`{ vars?, rules? }`. It now declares `basePath?: never`. - **Library API**: new public functions in `mds-core` — `lint`, `lint_str`, - `lint_str_with`, `lint_virtual`; `LintResult`, `LintDiagnostic`, - `LintConfig`, `Severity` types. +**Compatibility:** this is a **compile-time break** for code that assigns a variable +whose inferred type includes a `basePath` field to a `LintFileOptions`-typed slot. +For example, passing a `LintOptions`-typed variable directly to `lintFile` or +`lintVirtual` now fails with `TS2322` — `LintOptions.basePath` is `string | undefined` +which is not assignable to `never`. Code that passes a fresh object literal without +`basePath`, or a variable that was already typed as `{ vars?, rules? }`, continues to +compile unchanged. - **napi** (`@mdscript/mds-napi`): `lint`, `lintFile`, `lintVirtual` exports. +**Migration:** at each `lintFile` / `lintVirtual` call site that passes a +`LintOptions`-typed variable, either extract a narrowed copy +(`const { basePath: _unused, ...fileOpts } = opts`) or redeclare the variable as +`LintFileOptions` when `basePath` was never meaningful there. + +#### WASM backend rejects `basePath` on string-surface methods (#180) - **WASM** (`@mdscript/mds-wasm`): `lint`, `lintVirtual` exports. +`compile(source, { basePath: '/dir' })` and `check(source, { basePath: '/dir' })` +previously silently ignored `basePath` on the WASM backend. They now throw +`Error { code: 'mds::invalid_options' }`. `lint(source, { basePath: '/dir' })` was +already documented as WASM-unsupported; it now enforces this at runtime too. - **Universal TypeScript** (`@mdscript/mds`): `lint()`, `lintFile()`, - `lintVirtual()` with full TypeScript types (`LintResult`, `LintDiagnostic`, - `LintSpan`, `LintFileResult`, `LintOptions`, `LintFileOptions`). Both native - and WASM backends implement the full surface; `lintFile()` on the WASM backend - uses `buildModulesMap` for `@import` resolution. +**Migration:** switch to the native backend (`MDS_BACKEND=native`) when you need +import resolution with a `basePath` in WASM environments. - **Python** (`mdscript`): `lint()`, `lint_file()`, `lint_virtual()` with keyword-only - `rules` and `base_path` / `vars` options; `LintResult` with `.version`, `.truncated`, - `.files`, `.to_dict()`, `.to_json()`. Stubs shipped in `_mdscript.pyi` / `__init__.pyi`. +### **BREAKING** — Interpolation syntax: `{x}` → `{{x}}` - **⚠ TypeScript interface implementers**: `MdsBaseBackend` gained `lint` and - `lintVirtual` as required members; `MdsNodeBackend` gained `lintFile`. Code that - directly implements these interfaces (not just calls them) must add these methods. +Interpolation now uses **double braces**: `{{variable}}`, `{{obj.field}}`, +`{{func("arg")}}`, `{{alias.func()}}`. Single `{` and `}` are always **literal +text** — no escaping needed for lone braces. -- **Source Map v3** (#62). Compile calls can now produce a [Source Map v3](https://sourcemaps.info/spec.html) - document alongside the rendered output. +#### Interpolation syntax changed (#236) - **CLI** (`mds build`): `--source-map` writes a `.map` sidecar and leaves - the compiled output byte-identical to a no-flag build (ADR-002). `--inline` embeds - the map as a `` HTML comment at the end of the - output; no sidecar is written (requires `--source-map`). `--no-source-map` suppresses - generation when `build.source_map=true` is set in `mds.json`. `--embed-sources` - (requires `--source-map`) embeds the original source text as `sourcesContent`. +Templates using the old `{var}` form will no longer interpolate — they will emit +the literal text `{var}` instead. This affects every template that uses variable +substitution, function calls, or dynamic message roles. - **Rust** (`mds-core`): new `compile_str_with_deps_opts`, `compile_with_deps_opts`, - `compile_virtual_with_deps_opts` functions accept `CompileOptions { source_map: bool, - include_sources_content: bool }`. `CompileResult` carries `source_map: Option`. +**Migration:** run `mds lint --fix` to auto-migrate (the `legacy-interpolation` +lint rule, Tier A, rewrites every `{x}` → `{{x}}` in one pass), then run +`mds fmt` to normalize formatting. - **Node.js** (`@mdscript/mds-napi`, `@mdscript/mds`): pass `{ sourceMap: true }` (and - optionally `{ sourcesContent: true }`) to `compile()` or `compileFile()`. The returned - result gains a `sourceMap` key when source maps are enabled. +``` +# One-command migration for a project: +mds lint --fix && mds fmt +``` - **WASM** (`@mdscript/mds-wasm`): same `sourceMap`/`sourcesContent` options on `compile()`. +#### Escape syntax changed - **Python** (`mdscript`): `compile()`, `compile_file()`, and `compile_virtual()` accept - `source_map=True` and `sources_content=True` keyword arguments. Results expose a - `.source_map` property (`dict | None`). +- Old: `\{` → literal `{`, `\}` → literal `}` — **deleted**; these escapes are + no longer recognized (single braces are ordinary text and need no escaping). +- New: `\{{` → literal `{{` in output. Use this only when you need `{{` as + literal text in the output (e.g. inside a Jinja/Python f-string example). - **Cross-field invariant**: passing `sourcesContent: true` (or `sources_content=True`) - without `sourceMap: true` (or `source_map=True`) is rejected on all surfaces — the - CLI rejects the combination at argument-parse time (clap `requires`; exit code 2); - the library bindings (napi/WASM/Python) raise `mds::invalid_options`. Messages-mode - templates silently degrade: `sourceMap` is absent from the result and a warning is - emitted. +#### `@message {{role}}:` dynamic roles - **⚠ Privacy warning**: `--embed-sources` / `sourcesContent: true` / `sources_content=True` - includes the full original template source in the map file — including any hardcoded - secrets or PII. Only use in trusted build environments. +Dynamic message roles now use double braces: `@message {{role}}:` instead of +`@message {role}:`. Bare-word roles (`@message system:`) are unchanged. -- **Partial fix application** for `mds lint --fix`: when a batch of fixes partially - applies (some edits are accepted, some are rejected due to post-fix regression), - the CLI now reports `"N of M fixes applied"` and writes the best accumulated state - to the file. Previously, a partial batch was all-or-nothing (either all or nothing - applied). (#196) +#### `LintDiagnostic` is now `#[non_exhaustive]`; use the constructor, not struct literals -- **`type_mismatch` errors now carry a source span** (file + line + column) pointing - to the `@if` or `@elseif` directive that triggered the comparison. The span is - propagated through all surfaces (CLI miette code frame, napi `.span`, Python - `.span`, WASM error object). (#196) +`LintDiagnostic` is marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct it +via a struct literal. Use `LintDiagnostic::new(rule, severity, message)` to create +a diagnostic with required fields and all optional fields defaulting to `None`, then +chain the builder methods `with_help`, `with_span`, `with_file`, `with_fix_removals`, +and `with_fix_edits` to set optional fields. -- **Spans on `mds::name_collision` errors** in `@export *` (wildcard), alias-import, - and merge-import paths. The error now points to the collision site instead of the - file root. (#196) +#### Nine additional public types are now `#[non_exhaustive]`; migrate struct literals to constructors -- **Spans on unclosed-block errors**: `@if`/`@for`/`@define`/`@message` blocks that - are never closed now produce `mds::syntax` errors anchored at the opening directive. - (#196) +The following types are marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct them +via struct literals. Use the named constructor or builder listed for each: -- **`\{` escape hint on unclosed interpolation brace**: when the compiler encounters - an unclosed `{` (brace without a matching `}`), the error now includes the hint - "to include a literal `{`, escape it as `\{`". (#196) +- **`LintResult`** — use `LintResult::new(diagnostics)` (defaults: `truncated=false`, `is_standalone=false`), + then chain `.truncated()` or `.standalone()` to override. +- **`SerializedError`** — not externally constructable by design; obtain via `MdsError::serialize()`. +- **`SerializedSpan`** — use `SerializedSpan::new(offset, length)`, then chain `.with_line(n)` and/or `.with_column(n)`. +- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. Previously this type was `pub` + inside a `pub(crate)` module and was thus unnameable from external crates; this PR re-exports + it at the crate root, making `mds::TextEdit` accessible for the first time. The `fix_edits` + field on `LintDiagnostic` (and the corresponding JSON field) was effectively unusable from + Rust until this change. +- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals, + `FixLineSpan::range_inclusive(from, to)` to remove through the line containing `to`, + or `FixLineSpan::range_exclusive(from, to)` to keep the line containing `to`. +- **`ByteEdit`** — use `ByteEdit::deletion(start, end, rule)` for pure deletions or + `ByteEdit::replacement(start, end, rule, text)` for in-place replacements. +- **`RejectedEdit`** — use `RejectedEdit::new(edit, reason)`. +- **`FixPlan`** — use `FixPlan::default()` for an empty plan; its fields are `pub`, so they + remain directly readable and writable from external crates. +- **`LintConfig`** — use `LintConfig::from_rules_checked(rules)` or `LintConfig::default()` for no overrides. +- **`LintDiagnostic::sanitized_for_render()`** — a new method that returns a sanitized clone + suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to + this method instead of assembling sanitized copies itself, keeping the escape logic co-located + with the struct definition (PF-014). +- **`MdsError::source_name() -> Option<&str>`** — a new method that returns the name embedded + in the error's `NamedSource`, or `None` for errors without a source (e.g. `MdsError::Io`). + `source_name()` is domain-neutral; callers that need to detect the string-source analysis + path should use `MdsError::is_string_source()` rather than comparing the returned name + against the sentinel value themselves — the internal sentinel is `pub(crate)` and is not + reachable from downstream crates. +- **`MdsError::is_string_source() -> bool`** — a new predicate that returns `true` when the + error was produced by the string-source analysis path (`resolve_source_intrinsic`). Use this + instead of comparing `source_name()` against a bare string literal: the internal sentinel + (`SOURCE_LABEL`) is `pub(crate)` and is not accessible from downstream crates. -- **`ArityMismatch` help text**: function-call arity errors now include a help string - pointing users to check the call site and the `@define` signature. (#196) +### **BREAKING** — lint JSON wire contract -- **Per-branch `@elseif` offset** in the AST (`ElseifBranch.offset`): lint diagnostics - for `empty-block`, `unreachable-branch`, and `duplicate-@elseif` now anchor at the - `@elseif` directive span rather than the parent `@if` opener. (#196) +#### Lint JSON wire contract (#202, #203, #211) -- **`format_str_named(source, base_dir, file_name)`** — new public `mds-core` API that - threads a caller-supplied file name through the formatter so that any `mds::syntax` - errors emitted during formatting name the file rather than using a generic sentinel. - `mds-cli`'s `mds fmt` uses this to show the actual file path in error output. (#196) +> This block is the **single wire-change ledger** for the lint JSON envelope. +> Later changes to `mds lint --format json` append here rather than opening a +> parallel section, so a consumer has one place to read. -- **`mds fmt --check` summary now includes unchanged count**: the directory-mode summary - under `--check` is now `"N would reformat, M unchanged, K failed"` (previously `"N - would reformat, K failed"`). (#196) +**Before / after**, for `mds lint - --format json` on a source with one unused +selective import: -- **napi workspace `build` script**: `crates/mds-napi/package.json` gains a `build` - script (`napi build --release --no-js`) for local development. (#196) +```jsonc +// abbreviated — see spec.md for the full schema +// before +{ "files": [ { "diagnostics": [ + { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } }, + { "rule": "unused-import", "span": { "length": 7, "offset": 0 } } + ], "file": "input.mds" } ], "truncated": false, "version": 1 } -- **Block-span lint fixes — `--fix` now removes whole blocks** (`@if`/`@for`/`@define` - spans) for the `empty-block`, `unreachable-branch`, and `unused-function` rules. - Previously the fixer could only remove a single directive line, leaving the - matching `@end` orphaned; the reverify gate would catch the resulting parse error and - decline the fix, making these rules report-only in practice (tracked as limitation - in #172). The implementation now threads `end_offset` through the AST - (`IfBlock`, `ForBlock`, `DefineBlock`) and uses a `FixLineSpan` descriptor - (byte range of the full block) to perform whole-block removal. Containment dedup - in the planner coalesces overlapping spans and deduplicates identical fix ranges - across rules. JSON `"fixable"` is now `true` only when an actual `FixLineSpan` - is deliverable and the tier gate passes. The reverify gate still applies - fail-closed — if recompile fails, the fix is reported not applied. +// after +{ "files": [ { "diagnostics": [ + { "rule": "unused-import", "span": { "length": 5, "offset": 10 } }, + { "rule": "duplicate-export", "span": { "length": 7, "offset": 59 } } + ], "file": "" } ], "truncated": false, "version": 1 } +``` -- **Per-file `mds.json` discovery in `mds lint` directory mode**: when linting a - directory, the nearest `mds.json` is now located by walking up from **each input - file** independently (with a cached walk-up per directory). Previously a single - config at the lint-root directory was applied to all files. A malformed config in - a subdirectory now produces a per-file error entry and contributes to exit code 2 - rather than aborting the entire run. +**A consumer breaks if it** keys off `files[].file == "input.mds"` for CLI stdin +output, matches `` in a rendered diagnostic frame (stderr only — the JSON +`error.message` field cannot carry source identity; no `MdsError` Display template +interpolates `ctx.file_str`, per AD-211-5), relies on +`diagnostics[]` arriving in rule-execution order, assumes `unused-import` +spans have length 7, relies on the `mds lint ` file-group order being +component-wise (`Path::Ord`), or on Windows assumes `files[].file` values use +the native backslash separator. File groups are now ordered by the byte-wise string +of the relative display path (e.g. `api-utils.mds` sorts before `api/x.mds` +because `'-'` (0x2D) < `'/'` (0x2F)). On Windows, `relative_display` normalises +path separators to forward slashes, so a nested path that previously appeared as +`sub\c.mds` in the JSON now appears as `sub/c.mds`; a consumer that string-matches +or splits on `\` in `files[].file` values will silently fail to match. -- **`mds::invalid_vars`** — new error code for malformed or non-object `--vars` - JSON (exits 1). A missing `--vars` file continues to use `mds::file_not_found` - (exits 2). The two failure modes were previously reported as the same generic error. +**1. Diagnostics are sorted by byte offset (#202).** Within each +`files[].diagnostics` array, diagnostics are ordered by ascending `span.offset` +for results produced by the lint engine; a `LintResult` assembled directly via +`LintResult::new` is emitted in the order the caller supplied. +Previously the order was rule-execution order (implementation-defined). -- **Python typed lint result classes** (`crates/mds-python`): `LintDiagnostic` and - `LintFileReport` are now typed, frozen `#[pyclass]` instances. `LintResult.files` - returns a list of `LintFileReport` objects rather than raw dicts. Stubs - (`.pyi` files) and the `mypy`/`pyright` typecheck sample are updated accordingly. +- Diagnostics without a span sort to the end of their file group. +- Equal-offset diagnostics preserve rule-execution order (stable sort). +- File groups have a defined order: `mds lint ` sorts `files[]` by the + byte-wise (lexicographic) string comparison of the relative display path — e.g. + `api-utils.mds` sorts before `api/x.mds` because `'-'` (0x2D) < `'/'` (0x2F). + This is a CLI directory-mode contract only: the binding surfaces (napi / WASM / + Python) lint a single entry source, so their `files[]` array never carries more + than one entry. +- Ordering is established on `LintResult.diagnostics` itself, so the CLI human + path and the napi / WASM / Python surfaces observe the same order. +- **Truncation is unchanged and is NOT offset-ranked.** When `truncated` is + `true`, the retained diagnostics are still the first `MAX_DIAGNOSTICS` (1,000) + in rule-execution order, re-sorted afterwards — not the 1,000 smallest offsets. +- **Sort cost (AC-P1-22):** The sort key is a borrowed tuple `(bool, &str, bool, + usize)` — zero per-comparison heap allocations. The sort runs at most once per + `LintResultBuilder::build` call over n <= `MAX_DIAGNOSTICS` (1,000) items. -- **Python `CompileResult.to_dict()` always includes `"sourceMap"` key**: the key is - present with value `None` when no source map was generated, and with the map dict - when one was. `to_json()` stays canonical (omits the key when absent) — the - asymmetry is intentional and documented. +**2. The stdin source identity is always `` (#211).** Every CLI context +that names a stdin source now uses the single sentinel ``: -- **WASM CI size guard raised from 800 K to 850 K**: the branch's core growth - (span attribution machinery, `end_offset` fields, `FixLineSpan` planner) pushed - the optimized WASM binary to ~808 KB. The guard in `ci.yml` was raised accordingly. +- the JSON `files[].file` key (previously `"input.mds"`, the internal VFS key); +- human diagnostic frames for `mds lint -` (previously `input.mds`); +- fix-preview status lines and diff headers (previously bare `stdin`); +- the **analysis-failure envelope** — a stdin source that fails the check gate + used to render `:L:C`, the resolver's internal label. `mds check -` and + `mds build -` rendered `` on the same path and now render `` + too, so all four subcommands agree. Note: the analysis-failure JSON envelope + shape is `{"version":1,"error":{"code","message","help","span"}}` — it carries + **no `file` key** (unlike the success envelope which has `files[].file`). A + JSON consumer reading `error` results MUST NOT look for a `file` key there. -- **Code of Conduct** (#38): `CODE_OF_CONDUCT.md` at the repository root, using - Contributor Covenant 2.1 with `deanshrn@gmail.com` as the enforcement contact. - Linked from `CONTRIBUTING.md` and `README.md`. +`mds::STRING_SOURCE_MAP_LABEL` is **unchanged** and remains `"input.mds"`: it is a +virtual-FS entry key, not a display label. The napi, WASM and Python lint APIs +continue to report `"input.mds"` for string-source input. The relabel is applied +only at the CLI output boundary. -- **Source-hygiene CI gate** (#288): `scripts/verify-no-control-bytes.mjs` scans - every tracked file for hazardous codepoints — C0 control characters (excluding - TAB and LF), DEL, C1 (at codepoint level, catching UTF-8-encoded NEL U+0085), - the twelve `Bidi_Control=Yes` characters (Trojan Source / CVE-2021-42574), the - JavaScript line/paragraph separators U+2028 and U+2029, and U+FEFF (BOM). - Runs in CI on every pull_request and on tag pushes (release.yml). An opt-in - pre-commit hook (`scripts/hooks/pre-commit`) is provided; it reads the staged - blob via `git cat-file`, not the working tree. Also remediates seven live - U+0085 bytes that had been injected into tracked source by the edit tooling - (PF-018). +**Zero-diagnostic behaviour:** when stdin lint completes with no findings, the +JSON is `{"files":[],"truncated":false,"version":1}` — no file entry. The +`` sentinel appears in `files[0].file` only when at least one diagnostic is +emitted. This matches non-stdin zero-diagnostic behaviour and keeps the JSON +identical across the CLI and binding surfaces (napi, WASM, Python) for the clean +case. -- **Pre-merge check verifier** (#289): `scripts/verify-pr-checks.mjs` guards - against PF-017 (a cancelled CI run reads as "not failing" to `gh pr merge - --admin`). It evaluates three tiers: Tier A asserts every required - branch-protection context is `completed+success`; Tier B fails on any - non-required check-run that concluded - `failure/cancelled/timed_out/action_required/stale`; Tier C (legacy commit - statuses) is advisory. It emits a `gh pr merge --squash --match-head-commit - ` command pinned to the verified SHA. Exit 0: Tier A and Tier B pass; - exit 1: any Tier A/B failure or zero check-runs found; exit 2: - tool/permission errors. +**3. `unused-import` spans anchor at the unused name (#203).** For selective +imports (`@import { name1, name2 } from "path"`), the span now covers the unused +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. -### Changed +#### New `fix_edits` field on `LintDiagnostic` -- **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 under amended - criterion, repo-owner ruling 2026-08-16: shared body, shared recognised-rules list, - shared sort order; the CLI adds a `"warning: in mds.json: "` provenance prefix that - the bindings cannot provide because their rules arrive in the caller's options - object, not a config file). 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 `, 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. +`LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; +an array of `{start, end, new_text}` byte-span edit objects when fixable). This +field is present across all binding surfaces: CLI JSON output, napi +(`LintDiagnostic.fix_edits?: …`), WASM, and Python +(`LintDiagnostic.fix_edits: list[dict] | None`). -- **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`); - now they emit a slash-separated path relative to the project root found via - `.mdsroot` / `.git` walk-up (e.g. `src/foo.mds`). The `@mdscript/mds` - universal package's `compileFile` previously returned different `sources[]` - depending on which backend `init()` loaded (absolute on native, root-relative via - `buildModulesMap` on WASM); both backends now produce identical root-relative paths. - Code that compares `sources[0]` to an absolute path must be updated. (#3) +### **BREAKING** — Strict cross-type comparisons, merged `@extends` frontmatter, interior-verbatim whitespace, filesystem API -- **Inline stdout source-map absolute-path leak fixed**: `mds build --source-map - --inline -o -` and `mds build --source-map -o -` no longer leak absolute filesystem - paths in the embedded `sourceMappingURL` data-URI; sources are relativized against - the current working directory. Previously the output path was `None` for stdout - builds, causing the relativization step to short-circuit and leave absolute paths. - (#196) +These changes alter observable runtime behavior and compiled output. Templates relying +on the previous (buggy) behavior must be updated. -- **`mds build --inline -o -` for stdin input is now allowed**: previously rejected - with an error. Inline and sidecar source maps now work identically for stdin and - file inputs. The `sources[0]` label is `""` for stdin builds. (#196) +#### Cross-type comparisons are now errors (#152) -- **lint `--fix --check` and `--fix --diff` are now honest gated previews**: the - preview pass runs through the same reverify gate as apply. Fixes that would be - rejected (overlap, post-fix regression) are reported as `"fix rejected: "` - rather than silently shown as `"would fix"`. Directory mode `--fix --check` exits 1 - when any file has fixable issues. (#196) +`@if a == b:` or `@if a != b:` where `a` and `b` are different types (e.g. a number +vs. a string, or a boolean vs. null) now raises `mds::type_mismatch` at runtime +instead of silently returning `false` (for `==`) or `true` (for `!=`). -- **Overlap-rejected fix plans are now surfaced**: when `lint --fix` finds overlapping - byte ranges (two rules targeting the same span), the plan is no longer silently - abandoned. The overlap is reported so users know a fix exists but could not be auto- - applied. (#196) +**Migration:** add an explicit conversion before comparing: +- `@if string(count) == "3":` — convert number to string +- `@if count == 3:` — compare number to number literal -- **`mds fmt` errors name the file**: formatting errors emitted to stderr now include - the file path as a prefix (e.g. `"src/foo.mds: formatter_invariant: …"`). Previously - file context was absent, making batch `mds fmt .` errors hard to trace. (#196) +#### Cross-flag duplicate keys in `--set` / `--set-string` are now a hard error (#152) -- **`--vars` JSON errors name the file**: when a `--vars` JSON file is malformed or - does not contain a top-level object, the error message now includes the file path. - (#196) +Supplying the same variable key via both `--set KEY=VALUE` and `--set-string KEY=VALUE` +in a single invocation is now rejected at startup with an explicit error. -- **stdin `mds lint` code frames**: lint diagnostics for stdin input now include a - miette code frame with `"input.mds"` as the source label. Previously stdin lint - diagnostics lacked source context. (#196) +**Migration:** remove the duplicate key from one flag. -- **Bare relative filenames now work** for all subcommands and the `compile_str` - binding family. Running `mds build foo.mds` (without a `./` prefix) from the file's - directory previously failed on some platforms because the parent-path resolution - produced an empty path instead of `.`. Fixed by `effective_parent` in `fs.rs`. (#196) +#### `@extends` emits deep-merged frontmatter (#154) -- **`mds fmt` formatter-invariant gate false positive on trailing blank lines is - fixed**: templates containing trailing blank lines (e.g. `@if … @end\n\n`) were - incorrectly rejected by the safety gate with `mds::formatter_invariant` after being - formatted. The gate now correctly ignores insignificant trailing whitespace. (#196) +Compiled output for a child template now contains the **deep-merged** frontmatter +(base keys + child keys, child wins on collision, reserved keys `imports`/`type`/`extends` +excluded) rather than only the child's raw frontmatter. Base-only frontmatter keys +now appear in the compiled output. -- **Lint diagnostic messages now consistently end with a period** (G3 message-copy - consistency): all `empty-block` and `unreachable-branch` rule messages are - punctuated uniformly. (#196) +**Migration:** if your pipeline depends on base frontmatter keys being absent from the +compiled output, strip them downstream or move them to a non-frontmatter location. -- **Messages-mode source-map warning reworded and deduplicated**: the warning emitted - when `sourceMap: true` is requested on a messages-mode template now reads "source - maps are not supported for messages-mode templates (@message blocks); no source map - will be generated" across all surfaces. The warning is emitted exactly once per - compilation (previously it could appear twice for some template shapes). (#196) +#### Interior-verbatim whitespace contract for `@block` bodies and `mds fmt` (#150, #151) -- **`mds::syntax` error label no longer duplicates the message**: the miette diagnostic - label was previously set to `{message}` (same as the headline), producing redundant - output in code-frame renderings. It now reads `"syntax error occurred here"`. (#196) +Leading blank lines and interior blank runs inside `@block` bodies and +`mds fmt` output are now preserved verbatim; previously they were collapsed or stripped. +The `mds fmt` blank-line collapsing rule (R3) has been removed to maintain compile +equivalence with the updated evaluator behavior. Only the trailing edge normalizes (to +exactly one final newline). `@message` and `@define` bodies continue to edge-trim — +leading and trailing blank lines are stripped — and are not covered by this contract. -- **Imported-macro `type_mismatch` errors now point to the defining file**: when a - type mismatch is raised inside a `@define` body that was imported from another file, - the error frame now names the helper file and shows the relevant line (e.g. - `helper.mds:3:5`) rather than pointing at the call site in the importing file. - Implemented via `FunctionDef.origin` (always-populated, one `Arc::from(source)` per - module) and `EvalContext.body_origin` (LIFO swap around body evaluation). The - performance trade-off (one Arc per module instead of zero) is an explicit - AC-PERF-01 relaxation accepted for span correctness. +**Migration:** compiled outputs may gain blank lines that were previously collapsed or +stripped; templates relying on this collapse must remove the extra blank lines at the +source level. -- **Parser syntax errors now carry directive-line spans**: approximately 20 previously - spanless `mds::syntax` errors now include a source span pointing at the directive - line that caused the error (implemented via `MdsError::or_span`). This affects - common mistakes such as unclosed strings in frontmatter and malformed directive - arguments. +#### `FileSystem` trait now requires `normalize_in_dir` and `parent_dir` (#146) -- **`ArityMismatch` help now shows the expected signature**: the help text for a - wrong-argument-count error now includes the function's expected call form (e.g. - `pair(a, b)` or `f(x, y="admin")`), rendered from the `@define` parameter list. - Previously it only said to check the call site. +`FileSystem` now requires two new methods — `normalize_in_dir` and `parent_dir` — that +replace the internal `` path-sentinel pattern. String-source `@import`/`@extends` +resolution is now directly directory-anchored: `ctx.base_dir` carries the importing +directory explicitly, with no synthetic filename appended. No behavior change for +`compile`/`check` users; only affects code that implements the `FileSystem` trait +directly via `ModuleCache::with_fs`. -- **`TypeMismatch` help text rewritten**: the help message now distinguishes three - scenarios — comparing a variable to a literal of a different type (suggests an - explicit conversion), using a non-boolean value in an `@if` truthiness check (notes - that any non-null, non-false value is truthy), and confusion from `--set` type - coercion (suggests `--set-string` to keep a string value byte-for-byte). +### **BREAKING** — Options validation, directory walker, source-map labels, check API (#196) -- **`--vars` missing file exits 2 with `mds::file_not_found`**: previously a missing - `--vars` path produced a generic I/O error. It now exits 2 (I/O error) with a - structured `mds::file_not_found` diagnostic including a help note. +- **`@mdscript/mds` now rejects unknown option keys** with + `Error { code: 'mds::invalid_options' }` before forwarding to the backend. Previously + unrecognized keys were silently passed through (napi and WASM backends would reject + them, but the universal JS wrapper did not validate). Callers with typos in option + objects will now get immediate, accurate error messages. (#196) -- **Config-sourced `source_map=true` degrades gracefully on stdout output**: when - `build.source_map=true` is set in `mds.json` and the output is stdout (`-o -`), - the build now proceeds with exit 0 and a single warning naming the config file - (suggesting `--no-source-map` or `-o `). Previously this combination produced - a confusing double-error. An explicit `--source-map -o -` flag combination still - hard-errors with an extended message (`-o ` / `--out-dir` / `--inline` / - `--no-source-map`). +- **`CheckOptions` is now split from `CompileOptions`** in `@mdscript/mds`. + `check()` and `checkFile()` accept `{ vars?, basePath? }` — source-map options + (`sourceMap`, `sourcesContent`) are not valid for check calls and are rejected with + `mds::invalid_options`. `CompileOptions` retains `sourceMap`/`sourcesContent`. + TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196) -- **Config `embed_sources=true` without `source_map=true` now warns** at all merge - sites (mds.json merge, `--vars` merge, CLI flag merge). Previously the warning was - emitted inconsistently. +- **String-source `sourceMap` label changed from `""` to `"input.mds"`** + across all surfaces (CLI, napi, WASM, Python). The `sources[0]` entry in Source Map v3 + output for `compile(src, {sourceMap:true})` / `compile_str*` / WASM `compile` now reads + `"input.mds"` instead of `""`. CLI stdin builds use `""` (unchanged). + Code inspecting `sources[0]` for the string `""` must be updated. (#196) -- **`mds fmt` and `mds lint` check path existence before checking the `.mds` - extension**: a path that does not exist now exits 2 with `mds::file_not_found` - (rather than the "not an MDS file" extension error). `mds lint` preserves the JSON - envelope for a missing-file error in `--format json` mode. +- **Directory walker now excludes hidden directories and `node_modules` by default** + across all subcommands (`mds build`, `mds check`, `mds watch`, `mds fmt`, + `mds lint`). Directories whose name starts with `.` (e.g. `.git`, `.venv`) and + `node_modules` are silently skipped during recursive traversal. Templates inside these + directories are no longer compiled, formatted, or linted in directory mode. (#196) -- **WASM `check()` rejects `sourceMap`, `sourcesContent`, and unknown option keys** - with `mds::invalid_options`. Previously these keys were silently ignored by the - WASM backend's `check` function. +- **`mds check` summary wording changed** from `N checked` to `N passed, M + failed`. Scripts parsing CLI output must be updated. (#196) -- **Python `check()` rejects `source_map` and `sources_content` options** with - `mds::invalid_options`. These options are valid for `compile()` but not for - `check()`. +- **lint `--format json` `"file"` keys are now full relative paths** in directory mode. + When running `mds lint --format json .`, the `"file"` key in each JSON result is now + the path relative to the lint root (e.g. `"src/template.mds"`) rather than just the + basename (e.g. `"template.mds"`). This prevents key collisions when two different + files have the same filename. (#196) -- **Fix-rejection message is now actionable**: when the reverify gate declines a fix - (because the edited source fails to reparse or produces different output), the - message now reads "could not verify fix — the edited source did not re-parse - cleanly (reason); leaving the file unchanged" rather than a generic internal note. +- **`mds-core::CompileOptions` gained `source_map_base: Option`**. Rust code + that initializes `CompileOptions` with a struct literal must either add + `source_map_base: None` or use the `..Default::default()` tail. Binding surfaces + (napi, Python, WASM) are not affected. (#3) -- **`unused-import` documented as report-only in practice**: the JSON `"fixable"` key - for `unused-import` findings is always `false`. A file that triggers this rule has - at least one `@import` directive, which makes it non-standalone; Tier B fixes - require a standalone file, so the fix is never delivered. The rule is worth keeping - for awareness — it clears as a side effect of applying other fixes (e.g. removing a - duplicate import that was also the unused one). +### **BREAKING** — Error/lint messages now carry `\uXXXX` literals for embedded control bytes (#176) + +Across the JS / Python / WASM API surfaces, `err.message`, `err.help`, and lint +`LintDiagnostic.message` / `LintDiagnostic.help` now contain six-character `\uXXXX` +Unicode escape literals (e.g. `\u001B`, `\u007F`, `\u0085`) wherever MDS source +content caused raw C0-minus-`\n`/`\t`, DEL (U+007F), or C1 (U+0080–U+009F) control +bytes to appear in error or diagnostic messages. + +**Not affected:** `span.offset`, `span.length`, and `fix_edits` byte ranges are raw +byte offsets and are never sanitized. The `rule` field is a fixed ASCII identifier. +The `"file"` key in lint JSON output is sanitized on the same pass as `message`/`help`. + +**Migration:** consumers that test for exact control byte sequences in error or +diagnostic messages must update to check for the `\uXXXX` literal form instead. ### Fixed +- **`basePath` option is now honored on `compile()`, `check()`, and `lint()` (#180).** + Previously `basePath` was accepted by the unknown-option validator (so no error was + thrown) but was silently discarded before reaching the backend: the forwarding builders + (`compileOpt`/`varsOpt`) never included it in what they passed through. Templates + containing `@import` or `@extends` directives compiled with a string-source call and a + `basePath` option would either fail to resolve their imports (native backend) or fail + silently (WASM backend). The fix adds `basePath` to both `CompileOptions` and + `CheckOptions` and propagates it to the backend for the string-source methods + (`compile`, `check`). `compileFile` and `checkFile` deliberately exclude + `basePath` — the base directory for file operations is derived from the file + path itself (see the BREAKING subsection below). + + The WASM backend has no filesystem access and cannot resolve file-relative imports; it + now **rejects** a non-null `basePath` immediately with `mds::invalid_options` instead + of silently ignoring it, so misconfigured callers receive an actionable error rather + than a silent wrong answer. `{basePath: undefined}` is treated as absent on both + backends (`!= null` check; value-is-intent). To use `basePath` with import resolution, + set `MDS_BACKEND=native`. + + - **`mds build -o build/out.md` with sources in `src/` again emits map-relative paths** (e.g. `../src/foo.mds`) in the sidecar `.map` file and inline source map. The `source_map_base` field added to `CompileOptions` tells `relativize_source` @@ -1227,6 +1015,235 @@ diagnostic messages must update to check for the `\uXXXX` literal form instead. `PartiallyFixed` match arms of `run_lint_file`, mirroring the existing relabel in directory mode (which was already correct). +### Security + +- **Source Map v3 `sources[]` no longer leaks absolute filesystem paths** across + all surfaces. Previously, `compileFile` on napi and Python emitted the absolute + filesystem path (e.g. `/home/user/project/src/foo.mds`) as `sources[0]` in the + generated Source Map v3. Shipped source maps and inline maps embedded with + `--inline` could expose the full path of the machine that compiled the template, + a privacy-significant information disclosure. Fixed by the `relativize_source` + choke-point in `crates/mds-core/src/source_path.rs` (ADR-005 Phase A): all + surfaces now emit root-relative paths (e.g. `src/foo.mds`) relative to the + project root (located via `.mdsroot` / `.git` walk-up), and `..`-escaping + references outside the project root fall back to the basename. (#3) + +- **Control-byte injection hardening (CWE-150 / #176):** Raw C0 / DEL / C1 + control bytes in `.mds` source content could reach terminal stderr and + JS / Python / WASM API error messages, enabling terminal escape-sequence + injection. The serialization and diagnostic-render boundaries hardened here are + `MdsError::serialize()` (inherited by all three binding layers), + `LintResult::to_canonical_json()` including the `"file"` group key, + `CompileResult::to_canonical_json()` warnings, and the CLI render path. That is + an audit list, **not a closed set**: the governing rule is the per-field one + below, and the residual it leaves is named there. Enumerating boundaries is + exactly the framing this changelog retires further down. + The CLI render path (PF-014 redesign) sanitizes the renderer's *source-excerpt* + input byte-length-preservingly — hostile C0/DEL/C1 bytes become `?` (C0/DEL) or + NBSP (C1) so span offsets and caret columns stay exact and miette's own SGR + colour codes survive intact on TTY. `message` and `help` are renderer inputs too, + but they are `\uXXXX`-escaped rather than length-preserved; only source text + carries the byte-length invariant. A new + `MdsError::display_sanitized()` public API is provided for Rust consumers; + the raw `Display` impl is preserved with an explicit unsafety contract in + its rustdoc. `span` byte offsets, `fix_edits` byte ranges, and `rule` + identifiers are deliberate exclusions — they carry position data, not + terminal-bound text. (#176) + +- **CLI error *message* text is now escaped too (#176).** The hardening above + covered rendered source excerpts, filenames, and the diagnostic wire boundaries, but a + diagnostic's own message and help text still reached stderr raw. Both CLI error + families interpolate untrusted input into their messages — compiler errors carry + template text (`invalid include alias: ''`) and CLI errors carry `mds.json` + values and filesystem paths (`mds.json output_dir '' must not contain '..'`) + — so a hostile `.mds` file or config value could still emit raw ANSI escape + sequences to a terminal. `mds build`, `check`, `fmt`, `lint`, and `watch` now escape + each report's message, help, and caret-label text at the single `eprint_error` + choke-point, *before* the diagnostic renderer runs. The rendered frame is still never + post-processed, so terminal colour and caret alignment are unaffected, and output for + well-formed input is byte-for-byte unchanged. (#176) + +- **Every CLI print now escapes what it interpolates, and CI enforces it (#176).** + Warning and status prints scattered across `main.rs`, `build.rs`, `fmt.rs`, `lint.rs`, + `watch.rs` and `output.rs` interpolated filenames, `mds.json` rule names, `--format` + arguments and `io::Error` causes into `eprintln!` raw, bypassing the + `sanitize_control_chars` call that `mds-core`'s `emit_warnings` applies on the primary + code paths (a PF-004 parallel-path gap). The two most directly reachable: + + - a rule NAME in `mds.json` is an arbitrary JSON object key, and a JSON `\uXXXX` + escape decodes to a real byte, so any repository could put a raw ESC on a + developer's stderr — or forge whole `Clean: …` / `0 problems found` status lines — + just by being linted; + - the shared directory walker's depth-limit warning named the directory it stopped at, + so one hostile directory name reached `mds build`, `check`, `fmt`, `lint` and + `watch` at once. + + All of them now apply the per-field rule below: the warning *body* goes through + `eprint_warning` (HUMAN), and every value interpolated into it goes through + `safe_path` / `safe_inline` (WIRE). `watch.rs`'s lifecycle status lines + (`Watching {}`, `Removed {}`, `warning: could not remove {}: {e}`) — previously + carved out as a pre-existing gap — are included. + + **This is now a machine-checked invariant, not an enumeration.** A new + `crates/mds-cli/tests/print_discipline.rs` fails CI if *any* print macro under + `crates/mds-cli/src/**` interpolates a value that is not passed through one of the + escape helpers. It applies the same rule to the argument of `eprint_warning` + (HUMAN-mode escaping alone is not sufficient — it preserves `\n`, which is the + line-forgery vector), including when the message has been hoisted into a local: + a bare identifier is traced one hop through its `let` binding and judged the same + way, and an argument the trace cannot resolve is **reported**, not trusted. Because + `let`s are matched file-wide, every `for` variable, function parameter and closure + parameter **poisons** its own name, so a value arriving through one of those is + reported rather than resolved against an unrelated `let` that happens to share the + name. It also + scans `write!` / `writeln!` to a stdout/stderr handle. Deliberate exceptions — the + compiled artefact written to stdout, `&'static str` labels, integer counters, and + whole warning strings produced by `mds-core` — live in explicit allowlists with a + written justification per entry, and a companion test fails if an entry ever stops + matching. The guard is a **lexical** scanner: it catches accidental reintroduction, + and its five known limits (name-matched sanitizers, anti-rot-not-anti-reuse + allowlists, the one-hop single-file trace, name-based stream detection, and the + `if let` / `while let` / `match`-arm binders the poison set does not model) are stated + in its own rustdoc rather than implied away. Four successive reviews of this change + each found a *different* unescaped print; the guard is what ends that. + + The one precondition the guard depends on and cannot check — that `mds-core` WIRE-escapes + the identifiers its warning producers interpolate, since `mds-cli` prints whole + warning strings — is now pinned by `crates/mds-cli/tests/producer_discipline.rs` for + the only producer whose input can carry a hostile character (`resolver.rs`'s + imported-module filename). The other two producers interpolate an `@include` alias, + which the parser restricts to `[A-Za-z_][A-Za-z0-9_]*`, so they are upheld by review + and stated as such rather than claimed to be tested. (#176) + +- **The escape mode is chosen per field, not per surface (#176).** Normative in spec + §7.5: **on the diagnostic surfaces — the `"version": 1` JSON wire, CLI status and + warning lines, `[file:line:col]` frame headers — untrusted identifiers, filenames and + error causes are WIRE-escaped, human terminal output included; prose — a diagnostic + message or help body — stays HUMAN so multi-line frames keep rendering.** The + rule governs *diagnostic* output; the two carve-outs below are not diagnostics and are + not escaped at all. The discriminator is whether the + value is ever legitimately multi-line: a filename, a config key, a `--format` + argument and an `io::Error` never are, so preserving a raw `\n` in one buys nothing + and lets it forge a standalone line byte-identical in form to genuine output + (CWE-117). This supersedes the earlier per-surface framing and the "wire mode at + exactly four boundaries" enumeration. A new `mds::sanitize_control_chars_wire` and + `mds::named_source_for_render` are public in `mds-core` for consumers that need to + apply the same rule. + + **Declared carve-out: functional path references are NOT escaped.** Source-map + documents (the `mds build --source-map` sidecar, and the `sourceMap` embedded in + `CompileResult.to_canonical_json()`) emit their `file`, `sources` and `sourcesContent` + values **verbatim**, as does the `dependencies` array. These are functional references + that devtools, bundlers and IDEs resolve against the filesystem — rewriting a path to a + `\uXXXX` literal would point at a path that does not exist, breaking source-map + resolution and dependency tracking to defend against a pathological filename. That is + the same product-versus-display distinction that keeps compiled output byte-faithful. + **Consumers of a source map or of `dependencies` must treat every path in them as + untrusted** and escape it for whatever destination they render it to; JSON string + encoding is not that escaping, since a decoded `"\n"` is a real newline again. The CLI + does not rely on this: its `Compiled to …` and `Source map written to …` lines print + through `safe_path` and carry the escaped form even though the sidecar does not. + Specified in spec §7.5 ("Carve-out: functional path references"). (#176) + + **Declared residual.** "Identifier / filename / cause" means the value occupies such a + *field* — a CLI status line, a `[file:line:col]` frame header, the JSON `file` key. A + path or identifier interpolated into a diagnostic **message body** is part of prose, + so it follows the message row and stays HUMAN on terminal surfaces. That applies at + both message-construction sites: the CLI's `miette::miette!()` reports **and** + `mds-core`'s `MdsError` message bodies (`fs.rs`'s `cannot read {path}: {e}`, + `parser_helpers.rs`'s `invalid import alias: '{alias}'`). A `\n` in one of those + survives into the rendered frame and takes a line there. It is a weaker surface than a + status line — frame content is indented and `│`-prefixed, and the prefix survives + `strip()`, so it cannot masquerade as genuine bare status output — and no raw control + byte reaches the terminal either way. Closing it means WIRE-escaping over a hundred + `MdsError` construction sites and changing the public message text seen by all three + binding layers; that is a separate change. Disclosed in spec §7.5 ("Residual: paths and + identifiers inside a message body") and in the boundary table in + `crates/mds-core/src/lint/diagnostic.rs`. (#176) + +- **Hostile filenames can no longer forge CLI status lines (CWE-117 / #176).** POSIX + permits a newline inside a filename, and directory-mode commands discover names by + walking the tree — the user never types them. Filename display used HUMAN mode, which + preserves newlines by design so that multi-line diagnostic *messages* keep rendering, + so a file named `evil.mdsClean: real.mdsOK: all-fine.mds` made + `mds build`/`lint`/`fmt`/`check` emit attacker-authored lines byte-identical in form + to genuine status output — unframed, unindented, and indistinguishable. **Diagnostic + filename fields are now escaped in WIRE mode on every surface that renders one, human + included** (source-map paths and `dependencies` are the declared carve-out): `safe_path` + and the status-line printers, and the `[file:line:col]` frame header via a new shared + `mds::named_source_for_render` builder that `MdsError::at()`, the formatter and the + lint renderer all call. Message and help text are unchanged (still HUMAN, still + multi-line). A filename is never legitimately multi-line, so nothing legitimate is + lost. (#176) + +- **Fix-rejection reasons are display-safe by construction (#176).** + `mds::fix::FixOutcome::Rejected.reason` interpolated an `MdsError`'s deliberately-raw + `Display` — whose variants embed template text (`syntax error: {message}`) and + filesystem paths (`file not found: {path}`) — and the CLI prints that value as an + unframed `fix rejected: {reason}` status line. The embedded error is now escaped in + WIRE mode at the single construction site in `fix.rs`, so the field is single-line and + control-byte-free for **every** consumer of the published `mds::fix` API, not just the + CLI's own print sites. (#176) + +- **Widened escape class: bidi / separator / BOM characters (#176).** The + escaped set now covers characters outside C0 / DEL / C1 that are still + display-hazardous, on every surface that escapes: + - **U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069** — the complete + Unicode `Bidi_Control=Yes` set (all twelve codepoints), behind Trojan Source + (CVE-2021-42574). A single U+202E in a filename or diagnostic message + reverses how the rest of the line renders in any bidi-aware terminal, IDE, or + code-review UI. U+061C ARABIC LETTER MARK is the only member outside + U+200E–U+2069 and is easy to miss for exactly that reason. + - **U+2028, U+2029** — LINE / PARAGRAPH SEPARATOR, which terminate a + JavaScript string literal. + - **U+FEFF** — BOM / ZWNBSP, invisible in every renderer. + + Each becomes its uppercase six-character `\uXXXX` literal, exactly like the + existing C0 / DEL / C1 escapes. Source excerpts inside a rendered diagnostic + frame are neutralized to a **same-width** substitute instead, preserving the + byte-length invariant that keeps span offsets and caret columns exact: 1-byte + C0/DEL → `?`, 2-byte C1 and U+061C → U+00A0, 3-byte bidi controls, separators + and BOM → U+FFFD. (#176) + +- **BREAKING (wire format): machine-readable boundaries now escape `\n` (#176).** + `MdsError::serialize()`, `LintResult::to_canonical_json()` (message, help, and + the `"file"` group key), `CompileResult::to_canonical_json()` warnings, and the + Python typed lint surface now emit `\n` as the six-character `\u000A` literal. + A raw newline inside a diagnostic string is a line-forging vector: any consumer + that prints or line-splits the value can be made to render an attacker-authored + line as a genuine second finding. `\t` is unaffected. + + **Human-render output of diagnostic PROSE is unchanged** — the CLI renderer, + `MdsError::display_sanitized()`, and warning *bodies* on stderr still preserve + raw newlines so multi-line diagnostic frames stay readable. Human output of + diagnostic filenames, identifiers and causes **did** change, by design: under the + per-field rule above those are WIRE-escaped on every surface that renders a + diagnostic, human included, so a newline + in one now renders as the six-character `\u000A` literal instead of forging a + line. Source-map paths and `dependencies` are unaffected: they are the declared + carve-out and stay verbatim. See the two entries below. + + **Migration:** consumers that split a `message` / `help` / warning string on + `\n` will now see a single line containing the literal `\u000A` where a real + newline used to be. Split on that literal instead, or render the value verbatim. + +- **Escaping is one-way — consumers must not un-escape (#176).** The + transformation is lossy and non-injective by design: a template that literally + contains the six characters `\u001B` and one containing an actual ESC byte are + indistinguishable after serialization. **Do not** convert `\uXXXX` sequences + back into bytes — that reconstitutes the injection the escape prevents. Round-tripping is + an explicit non-goal; no backslash-escaping will be added to make the mapping + reversible. Consumers needing original bytes must read them from the source via + the raw `span` / `fix_edits` byte offsets, which stay unsanitized for this + purpose. Documented normatively in spec §7.5. + +- **`--diff` preview output is TTY-gated (#176).** Applies to both + `mds lint --fix --diff` and `mds fmt --diff`, which share one renderer. + Preview diff text is neutralized when stdout is a terminal (where control bytes + would execute) and emitted **byte-faithful when piped or redirected**, so a + redirected diff remains applicable. Preview output is not part of the + `"version": 1` JSON wire format. + ## [0.3.0] — 2026-06-28 ### **BREAKING** — Intrinsic output format (removes `--format` flag and `compileMessages` API) diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index 039cfd1..74e4a82 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -153,13 +153,12 @@ impl miette::Diagnostic for StdinRelabeledError { /// inner diagnostic that already carries a `NamedSource` (which these do) wins /// and the replacement is ignored. /// -/// Call sites (verified line numbers, §5 step 1a of the implementation plan): -/// - `mds check -`: `crates/mds-cli/src/main.rs:280` -/// - `mds build -` (single-file path): `crates/mds-cli/src/build.rs:714` -/// - `mds build -` (directory stdin path): `crates/mds-cli/src/build.rs:1176` -/// - `mds lint -`: `crates/mds-cli/src/lint.rs` via -/// `emit_analysis_failure_json_or_stderr` (indirect; symbol cited instead of -/// a line number — avoids stale citations after line insertions) +/// Call sites (symbolic references; prefer these over line numbers to avoid stale citations): +/// - `mds check -`: `run_check` in `crates/mds-cli/src/main.rs` +/// - `mds build -` (single-file path): `compile_to_content` in `crates/mds-cli/src/build.rs` +/// - `mds build -` (directory stdin path): `run_build` in `crates/mds-cli/src/build.rs` +/// - `mds lint -`: `run_lint_stdin` in `crates/mds-cli/src/lint.rs` (direct call) +/// and `run_lint_file` via `emit_analysis_failure_json_or_stderr` (indirect) /// /// Any new CLI boundary that renders a stdin analysis failure must call this /// function; skipping it renders `` and breaks the uniform-sentinel rule. diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 4938a9d..f00f97b 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -1058,7 +1058,7 @@ fn dir_fix_json_residuals_keyed_by_relative_path_not_input_mds() { // diag.file to STRING_SOURCE_MAP_LABEL ("input.mds"). Without set_diag_display_path // in the single-file Fixed/PartiallyFixed arms, `mds lint --fix --format json ` // emitted "input.mds" instead of the real basename. Directory mode already had the -// correct relabel (lint.rs:1176/1197); this test pins the single-file parity. +// correct relabel (`run_lint_file` in lint.rs); this test pins the single-file parity. // // Fixture: a file with duplicate-export (Tier A, auto-fixed) + unused-variable // (Tier C, residual after fix). After --fix, the residual must appear under diff --git a/crates/mds-napi/README.md b/crates/mds-napi/README.md index 43fac91..7b9c3bc 100644 --- a/crates/mds-napi/README.md +++ b/crates/mds-napi/README.md @@ -63,7 +63,9 @@ 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, lint_warnings?: string[] }` +`{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help: string | null, fixable, fix_edits: ... | null, span: LintSpan | null},...]},...], truncated: bool, lint_warnings?: string[] }` + +`help`, `span`, and `fix_edits` are always-present keys in the JSON object; their value is `null` when the rule emits no hint, produces no source span, or carries no fix edits respectively. 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, absent when empty) is populated so callers can surface the issue; unknown severity values throw `mds::invalid_options`. diff --git a/examples/linting/README.md b/examples/linting/README.md index b91bb6c..fcdff1f 100644 --- a/examples/linting/README.md +++ b/examples/linting/README.md @@ -1,6 +1,6 @@ # Linting demo -`mds lint` runs nine static-analysis rules over a template *without executing it*, +`mds lint` runs ten static-analysis rules over a template *without executing it*, catching correctness and style problems that `mds check` does not. This directory demonstrates the rules, the JSON output, the auto-fixer, and per-rule severity configuration via `mds.json`. diff --git a/packages/mds/README.md b/packages/mds/README.md index 5877bcd..253ae66 100644 --- a/packages/mds/README.md +++ b/packages/mds/README.md @@ -119,7 +119,9 @@ file-path methods derive the base directory from the file argument. ```ts // CheckOptions — accepted by check() (string source) -// basePath: required when the source contains @import or @extends. +// basePath: defaults to process cwd when omitted. Caution: omitting it resolves +// imports against cwd, which may be the wrong directory. Provide an explicit +// path when the source contains @import or @extends. // WASM backend: basePath throws mds::invalid_options (no filesystem access); // set MDS_BACKEND=native to use the native backend with import resolution. // {basePath: undefined} is treated as absent on both backends. @@ -157,7 +159,9 @@ interface CheckFileOptions { } // LintOptions — accepted by lint() (string-source) -// basePath: required when the source contains @import or @extends. +// basePath: defaults to process cwd when omitted. Caution: omitting it resolves +// imports against cwd, which may be the wrong directory. Provide an explicit +// path when the source contains @import or @extends. // WASM backend: basePath throws mds::invalid_options — rejects instead of // silently ignoring so misconfigured callers see an actionable error. // Set MDS_BACKEND=native to use the native backend, or use lintVirtual with @@ -204,5 +208,6 @@ surfaces it appears in `lint_warnings`. **Lint result shape:** ```ts { version: 1, files: [{ file: string, diagnostics: LintDiagnostic[] }], truncated: boolean, lint_warnings?: string[] } -// LintDiagnostic: { rule, severity, message, help?, fixable, fix_edits, span? } +// LintDiagnostic: { rule, severity, message, help?: string | null, fixable, fix_edits?: ... | null, span?: LintSpan | null } +// help, span, and fix_edits are always-present keys in the JSON wire format; their value is null when absent. ``` diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index fd72e97..ee21ef9 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -97,9 +97,14 @@ export interface CheckOptions { vars?: Record; /** * Base directory for resolving `@import` directives in the source string. - * Required when the source contains `@import` or `@extends`. + * Defaults to the process cwd when omitted. * - * **WASM backend:** rejected at runtime with `mds::invalid_options` — the WASM + * **Caution:** omitting `basePath` resolves imports against the process cwd, + * which succeeds silently but resolves against the wrong directory if the + * source string was not loaded from cwd. Provide an explicit path when the + * source contains `@import` or `@extends`. + * + * **WASM backend:** rejected at runtime with `mds::invalid_options` -- the WASM * backend has no filesystem access. Set `MDS_BACKEND=native` to force the native * backend. */ @@ -198,12 +203,15 @@ export interface LintDiagnostic { severity: 'error' | 'warn' | 'info'; /** Human-readable description of the finding. */ message: string; - /** Optional guidance on how to resolve the finding. */ - help?: string; + /** Optional guidance on how to resolve the finding. `null` when the rule emits no hint. */ + help?: string | null; /** Whether the lint engine can auto-fix this diagnostic (`--fix`). */ fixable: boolean; - /** Source location of the finding, if available. */ - span?: LintSpan; + /** + * Source location of the finding. `null` when the rule produces no source span. + * The key is always present in the JSON wire format; only the value is `null`. + */ + span?: LintSpan | null; /** * Byte-range replacement edits the fix engine would apply, if available. * Each edit is `{ start, end, new_text }` with byte offsets into the source. @@ -314,7 +322,12 @@ export interface LintOptions { rules?: Record; /** * Base directory for resolving `@import` directives in the source string. - * Required when the source contains `@import` or `@extends`. + * Defaults to the process cwd when omitted. + * + * **Caution:** omitting `basePath` resolves imports against the process cwd, + * which succeeds silently but resolves against the wrong directory if the + * source string was not loaded from cwd. Provide an explicit path when the + * source contains `@import` or `@extends`. * * The WASM backend **rejects** a non-null `basePath` with * `mds::invalid_options` rather than silently ignoring it. This surfaces the