Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devflow/features/mds-cli/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ referencedFiles:
- crates/mds-cli/tests/intrinsic_output.rs
- crates/mds-cli/Cargo.toml
created: 2026-06-26
updated: 2026-06-26
updated: 2026-09-15
---

# MDS CLI (mds-cli)
Expand Down Expand Up @@ -168,7 +168,7 @@ let exclude_prefix = match &output_base {
};
```

**AC-M7 path-escape guard** — `output_path_for` has a runtime containment check: if the computed output path somehow escapes `Dir(base)` (e.g. via a malformed strip_prefix result), it falls back to `base/<stem>.<ext>`. A `debug_assert!(false, ...)` fires in debug builds so tests catch regressions.
**AC-M7 path-escape guard and the flatten report** — both `Dir(_)`-mode oracles now defer to one classifier, `mirror_stem(source, root, d) -> MirroredStem`, whose arms are `Mirrored(path)` (the `strip_prefix` succeeded; the subtree mirror survives) and `Flattened(path)` (`strip_prefix` failed; only the stem survives, joined to the out-dir). `output_base_no_ext` is the silent probe oracle; `output_path_for` is the write oracle and is the **only** site that reports the flattened arm — on stderr, **not** gated on `--quiet`, naming the source, the build root and the flat output. No live caller can reach that arm today (build hands the walker's own prefix back; watch gates event paths on `starts_with(&ctx.root)` and uses canonical keys under a canonical root whose walker skips symlinks), so the message is an invariant report, not user-facing advice. Degenerate stems (`/`, `..`, a bare drive prefix — never a `.mds` file) fall back to the relative name `output`, so `d.join(...)` can never re-root out of the out-dir; the old fallback was `source.as_os_str()`, which was exactly the absolute value that escapes. `output_path_for` keeps its runtime containment check with a `debug_assert!(false, ...)` behind it — deliberately debug-only, because its release fallback is already contained. In `watch.rs`, the ghost-external-dep prune (the vanished-dependency branch of the dir-batch loop, `watch.rs:2858`) skips the output probe via the new `DirWatchState::forget_graph` (graph-only) instead of the full `forget`, so pruning a vanished out-of-root dependency can no longer drop the write-dedup entry of an in-root source that shares its file name.

### Resource limits

Expand Down
4 changes: 2 additions & 2 deletions .devflow/features/mds-fmt/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ referencedFiles:
- crates/mds-cli/src/build.rs
- crates/mds-cli/src/watch.rs
created: 2026-07-03
updated: 2026-07-19
updated: 2026-09-15
---

# mds fmt — Opinionated Safety-Gated Formatter
Expand Down Expand Up @@ -186,7 +186,7 @@ Notable divergences worth knowing before touching this file:

## Error Handling and Recovery

`MdsError::FormatterInvariant { message: String }` (`error.rs:335-345`, code `mds::formatter_invariant`, constructed via the private `MdsError::formatter_invariant()` helper at `error.rs:686-692`) means **the formatter itself has a bug** — the CLI must never write the file when this occurs. The field name is `message`, matching all other free-form-string variants in `MdsError`. Both `FormatterInvariant` and `Syntax` map to the generic `_ => 1` arm in `exit_code` (`build.rs:372-382`) — no new exit codes were added for `fmt`. The CLI-level contract: `format_str_named` returns `Err`, never a garbled `Ok(String)`, so `fmt.rs` only ever reaches its write call with a value that already passed the gate.
`MdsError::FormatterInvariant { message: String }` (`error.rs:335-345`, code `mds::formatter_invariant`, constructed via the private `MdsError::formatter_invariant()` helper at `error.rs:686-692`) means **the formatter itself has a bug** — the CLI must never write the file when this occurs. The field name is `message`, matching all other free-form-string variants in `MdsError`. Both `FormatterInvariant` and `Syntax` map to the generic `_ => 1` arm in `exit_code` (`build.rs:372-382`) — no new exit codes were added for `fmt`. Since #217, `mds fmt <file>` on a path that is not valid UTF-8 exits 2 (`mds::io`) like `mds lint` and `mds build`, instead of 1; directory mode is unchanged (per-file failure, exit 1 with the summary). The CLI-level contract: `format_str_named` returns `Err`, never a garbled `Ok(String)`, so `fmt.rs` only ever reaches its write call with a value that already passed the gate.

## Anti-Patterns

Expand Down
8 changes: 4 additions & 4 deletions .devflow/features/mds-lint/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ directories:
- crates/mds-python/src
- packages/mds/src
created: 2026-07-11
updated: 2026-08-31
updated: 2026-09-15
---

# mds lint — Static Analysis Engine and Tiered --fix
Expand Down Expand Up @@ -348,7 +348,7 @@ Source text passed to `NamedSource` uses a different function: `neutralize_sourc
- **C1 (U+0080–U+009F) AND U+061C** (both 2-byte UTF-8) → U+00A0 NBSP (2 bytes). U+061C is in the 2-byte branch.
- **The other 11 format hazards** (U+200E/U+200F, U+2028/U+2029, U+202A–U+202E, U+2066–U+2069, U+FEFF — all 3-byte) → U+FFFD REPLACEMENT CHARACTER (3 bytes).

The split is implemented via two private predicates: `is_two_byte_format_hazard(ch)` (only U+061C) and `is_three_byte_format_hazard(ch)` (the remaining 11). A `debug_assert_eq!` in `neutralize_source_for_render` catches byte-length violations immediately during development.
The split is implemented via two private predicates: `is_two_byte_format_hazard(ch)` (only U+061C) and `is_three_byte_format_hazard(ch)` (the remaining 11). An unconditional `assert_eq!` (promoted from `debug_assert_eq!`, #220) in `neutralize_source_for_render` catches byte-length violations immediately, in release builds too.

### `named_source_for_render` — The Single NamedSource Builder

Expand Down Expand Up @@ -491,7 +491,7 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit)

- **Post-processing a rendered miette frame with any sanitizer** (PF-014): Sanitizing the rendered output escapes miette's own ANSI SGR colour codes into `\u001B[33m` noise on TTYs. CI uses `NO_COLOR=1` and piped stderr so this regression would stay green indefinitely. Pre-sanitize inputs before constructing the `Report`.

- **Putting U+061C in the 3-byte neutralization branch**: U+061C is 2 bytes in UTF-8. Routing it through the 3-byte branch (`U+FFFD`) fires the byte-length `debug_assert_eq!` (13 vs 12 bytes). This was proven, not theorized, during the #176 development. U+061C belongs in `is_two_byte_format_hazard`.
- **Putting U+061C in the 3-byte neutralization branch**: U+061C is 2 bytes in UTF-8. Routing it through the 3-byte branch (`U+FFFD`) fires the byte-length `assert_eq!` (13 vs 12 bytes). This was proven, not theorized, during the #176 development. U+061C belongs in `is_two_byte_format_hazard`.

- **Omitting `0xD8` from the fast-path byte scan in `sanitize_with`**: U+061C is encoded as `0xD8 0x9C`. Without `0xD8` in the fast-path, `sanitize_control_chars("a\u{061C}b")` returns `Borrowed` and skips the character entirely.

Expand Down Expand Up @@ -569,7 +569,7 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit)

**`packages/mds` prefers the dev WASM artifact**: `packages/mds/src/backend/wasm.ts` resolves to `crates/mds-wasm/pkg/` (the `wasm-pack` dev output) rather than `packages/mds-wasm/dist/node/`. Rebuilding only the `packages/mds-wasm` npm package leaves a STALE backend active, and the cross-surface differential test fails with convincing-looking divergence that isn't a real bug. Always rebuild via `wasm-pack build crates/mds-wasm` when working on WASM output.

**Directory ordering is byte-wise over `/`-normalized paths**: `relative_display` normalizes path separators to `/` via `components().join("/")` before sorting. The fix was declared BREAKING with zero Windows CI executions; it is now covered by a platform-independent ordering test on Ubuntu and a directory case in `packages/mds/__test__/lint.spec.mjs` (runs on windows-latest). The ordering fixture is separator-sensitive by construction (`/` = 0x2F < `[` = 0x5B < `\` = 0x5C) — any separator regression breaks the fixture on Windows.
**Directory ordering is byte-wise over `/`-normalized paths**: `relative_display` normalizes path separators to `/` via `components().join("/")` before sorting. The fix was declared BREAKING with zero Windows CI executions; it is now covered by a platform-independent ordering test on Ubuntu and a directory case in `packages/mds/__test__/lint.spec.mjs` (runs on windows-latest). The ordering fixture is separator-sensitive by construction (`/` = 0x2F < `[` = 0x5B < `\` = 0x5C) — any separator regression breaks the fixture on Windows. Since #217 `relative_display` returns `Result<String, MdsError>` (Io on strip_prefix failure or a non-UTF-8 component); `run_lint_directory` computes all display paths into `Vec<(PathBuf, String)>` before sorting and fails the run (envelope + exit 2) on the first `Err`; the per-file helpers receive the precomputed `&str` and `LintDirCtx` no longer carries `lint_root`.

**`FixOutcome::PartiallyFixed` is silently discarded by `_ => {}`**: `PartiallyFixed` is returned only by `apply_fixes_incremental`. A `_ => {}` wildcard arm compiles clean and discards it without warning. `#[must_use]` does NOT catch this — it fires on a dropped value, not a wildcard arm. Always match `PartiallyFixed` explicitly.

Expand Down
8 changes: 5 additions & 3 deletions .devflow/features/source-map-security/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ directories:
- crates/mds-cli/src
- packages/mds/src
created: 2026-07-19
updated: 2026-08-31
updated: 2026-09-15
---

# Source Map Security and Path Containment
Expand Down Expand Up @@ -166,7 +166,7 @@ The `Origin.display` field is populated eagerly at module-load time so no absolu
- `sources: Vec<String>` — canonical keys; emitted verbatim into SMv3 `sources[]` (byte-identical to ADR-005 contract)
- `display_names: Vec<String>` — root-relative display paths; used only for diagnostics, never emitted into `sources[]`

Both `MapBuilder::new(source_name, display_name, source_content)` and `MapBuilder::source_index(file, display, content)` are 3-argument; callers must supply both the canonical key and the display path. A `debug_assert_eq!` enforces strict length parity between the two vecs after every insertion.
Both `MapBuilder::new(source_name, display_name, source_content)` and `MapBuilder::source_index(file, display, content)` are 3-argument; callers must supply both the canonical key and the display path. A `debug_assert_eq!` enforces strict length parity between the two vecs after every insertion; the evaluator's cursor invariant is an unconditional `assert_eq!` since #220.

The `sources[]` bytes emitted into produced source maps are **byte-identical** to what they were before R3 — only the diagnostic display path changes. ADR-005 is preserved.

Expand Down Expand Up @@ -208,7 +208,9 @@ The Windows verbatim lesson is the same on both sides: native backend emits `\\?

**Windows verbatim UNC root** (`path_to_unified` fix): `std::fs::canonicalize` on Windows returns verbatim UNC paths (`\\?\C:\proj`). After `replace('\\', "/")` this becomes `//?/C:/proj`, and `normalize_abs` yields components `["?", "C:", "proj", ...]`. But the source path after the same treatment yields `["C:", "proj", ...]`. The prefix `"?"` causes the first-component comparison to fail → containment always fails → EVERY source map entry degrades to its basename. `path_to_unified` now strips `//?/UNC/` then `//?/` before normalizing, so root components match source components. This bug is invisible on Unix CI.

**`source_root()` returns `None` before any `normalize()` call**: `NativeFs::source_root()` returns `None` until at least one `normalize()` or explicit `set_root()` call establishes the project root. The defense-in-depth guard in `resolver.rs` catches this, but external callers that skip `normalize()` and jump straight to `compile_with_deps_opts` will land on the `root = None` branch.
**Empty/non-UTF-8 anchor hazard**: `path_to_unified` is `Option`; `None` root → basename, `None` base → root anchor; `starts_with_comps(x, [])` is vacuously true; root `/` is deliberately still a real root. The `Option` return exists so an unusable anchor can never reach `starts_with_comps` as an empty component list that every path matches — the degradation is chosen at the choke-point (step 6b), not inferred later.

**`source_root()` returns `None` before any `normalize()` call**: `NativeFs::source_root()` returns `None` until at least one `normalize()` or explicit `set_root()` call establishes the project root. The defense-in-depth guard in `resolver.rs` catches this, but external callers that skip `normalize()` and jump straight to `compile_with_deps_opts` will land on the `root = None` branch — and returns `None` for a non-UTF-8 root (lossy strings are not anchors).

**Directory-mode `opts` must be per-file**: In directory mode (`run_build_directory`), each file has a different output directory, so `source_map_base` differs per file. Constructing `opts` as loop-invariant (outside the per-file loop) would give every file the same anchor, producing incorrect relative paths for all but one file.

Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
parent directory is watched non-recursively, so a sibling scratch write by an editor
extends a window a real edit has already opened. Either way `npm install` churn or a
noisy editor can delay a real edit and the idle tick by up to the cap.
- **`TextEdit::new`, `FixLineSpan::range_inclusive` and `FixLineSpan::range_exclusive`
now panic on a reversed range in every build profile (#220).** The `start <= end` /
`from <= to` precondition was a debug-only assertion; a release caller passing a
reversed range got a value the fix planner later skipped in silence. The rustdoc
`# Panics` sections say so. No in-tree caller constructs a reversed range.

### Fixed

Expand Down Expand Up @@ -114,6 +119,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`crates/mds-python/tests/test_parity.py:201` still says "9 lint rules" — deliberately
left as-is here since fixing it would touch the release-surface Python test path;
tracked for a later step.
- **Release builds no longer panic on a non-boundary import/extends span offset, and a
missing skeleton block is an internal error instead of silently rendering the base
default (#220).** `attach_import_span` and the `@extends` child-only-blocks check now
compute the underline length through the existing char-boundary-safe helper: a byte
offset that does not land on a UTF-8 character boundary (a compiler defect, not
something a template can cause) yields a zero-length span with the numeric offset and
no source snippet, instead of a `byte index … is not a char boundary` panic in
release. The skeleton-splice walk asserts in every build profile that each `@block`
placeholder has an effective-blocks entry; release builds used to splice the base
default in silence — dropping a child's override — where debug builds panicked. The
three source-map cursor checks in the evaluator and the byte-length check in
`neutralize_source_for_render` are enforced in release now as well; none can be
triggered by template input, only by a defect, and the messages carry no source text.
On the CLI a tripped invariant is a Rust panic (exit code 101); the napi, WASM and
Python bindings convert it to `mds::internal` as before.
- **`mds lint <dir>` fails closed on a path it cannot name (#217).** The directory-mode
`files[].file` key and the sort key are the entry's path relative to the lint root. A
path that is not valid UTF-8, or that is not under the lint root, previously produced
a lossy (U+FFFD) or absolute key silently; it is now `mds::io` (`path is not valid
UTF-8: …` / `path escapes lint root …`) reported before any file is linted — exit 2,
with the analysis-failure envelope under `--format json`. Such a file already exited 2
as a per-file error; the difference is that the rest of the tree is no longer linted
around it and no lossy key is ever emitted.
- **`mds fmt <file>` on a path that is not valid UTF-8 exits 2 (#217).** It was a generic
error (exit 1); it is an I/O error (`mds::io`) like `mds lint` and `mds build`.
Directory mode is unchanged (per-file failure, exit 1 with the summary).
- **Source-map `sources[]` anchors fail closed (#217).** An empty project root, or one
that is not valid UTF-8, can no longer make the containment check vacuous:
`NativeFs::source_root()` reports no root for a non-UTF-8 root directory, and the
relativization choke-point treats an unusable root as "not contained" (basename) and
an unusable `source_map_base` as "anchor on the root" — never as an empty prefix every
path matches. No change for any UTF-8 root; not reachable from the CLI or the public
API today (non-UTF-8 entry paths are rejected before compilation) — closed as a latent
hazard.
- **`mds build`/`mds watch` say so when a source is written flat instead of mirrored
(#217).** In `--out-dir` mode a source that is not under the build root is written to
`<out>/<stem>.<ext>` (contained, unchanged) and now prints `warning: <source> is
outside the build root <root>; its output is written flat as <out> (…)`, not suppressed
by `--quiet`. No walked source can trigger it; it is a tripwire for a future caller.
Two degenerate fallbacks that could have joined an absolute path into the output
directory now use a fixed relative name. `mds watch` no longer probes output paths for
a vanished out-of-root dependency, which could drop the write-dedup entry of an in-root
source with the same file name.

### Internal

Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ input. The compiler enforces several defense-in-depth controls:
boundary rather than being passed to the OS.
- **Non-UTF-8 paths** are rejected at the public API boundary with an explicit
error instead of producing corrupted output.
- **Source-map anchors are byte-faithful**: the project root and `source_map_base`
used to decide whether a `sources[]` entry is inside the project are never
derived from a lossy string. A root that is empty or not valid UTF-8 is treated
as "no root" — entries degrade to basenames — so it can never make the
containment check vacuous.
- **Replace-by-rename writes**: `mds fmt`, `mds lint --fix`, and `mds build`/`mds
watch` outputs and `.map` sidecars are written to a same-directory temp file and
renamed over the target after a final symlink re-check (`mds-cli/src/output.rs`,
Expand Down
10 changes: 9 additions & 1 deletion crates/mds-cli/src/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,17 @@ pub(crate) fn run_fmt(args: FmtArgs) -> Result<()> {
/// TOCTOU-safe read-then-size-check instead of a bare `std::fs::read`.
fn read_source_file(path: &Path) -> Result<String> {
let canonical = mds::NativeFs::check_symlink(path).map_err(miette::Error::from)?;
// `MdsError::Io`, not a bare `miette::miette!`: a `miette!` report does not downcast
// to `MdsError`, so `exit_code` fell through to 1 while `check_symlink` one line above
// — the same class of failure on the same argument — already exited 2. The message
// text is identical to `lint.rs`'s `read_source_file` so the two subcommands report
// an undecodable path the same way (#217).
let path_str = canonical
.to_str()
.ok_or_else(|| miette::miette!("path is not valid UTF-8: {}", path.display()))?;
.ok_or_else(|| mds::MdsError::Io {
message: format!("path is not valid UTF-8: {}", path.display()),
})
.map_err(miette::Error::from)?;
let fs = mds::NativeFs::new();
// R3 / CWE-209: anchor the display root (project-root walk-up from the
// file's directory) BEFORE read(), so read-error messages show a
Expand Down
Loading
Loading