fix(core,cli): enforce internal invariants in release builds (degrade non-boundary spans, assert skeleton/width/range/cursor invariants); path helpers fail closed on strip_prefix and non-UTF-8 paths; fmt non-UTF-8 exits 2 (#220, #217) - #388
Merged
Merged
Conversation
…nge checks, neutralize width parity and source-map cursor asserts pinned (#220) Fifteen tests, added BEFORE the fix, that fail for the reason #220 exists. Nothing in production code changes in this commit. Behavioural tests (14, lib target): resolver_tests.rs T-A1..A3 attach_import_span_* (3) resolver_tests.rs T-B1..B3 check_child_only_blocks_* (3) resolver_tests.rs T-C1,C2 spliced_regions_* (2) lint/diagnostic.rs T-D1 neutralize_replacement_width_matches_for_every_hazard_codepoint lint/diagnostic.rs T-E1..E4 text_edit_new_reversed / fix_line_span_range_* / range_constructors_accept evaluator.rs T-F1 evaluate_with_map_cursor_tracks_output_length_across_node_kinds Shape test (1, integration target): tests/assert_promotions.rs T-L1 promoted_asserts_are_unconditional_prose_and_justified — a fixed ten-row table (8 promoted asserts + 2 degrade sites), NOT a repo-wide sweep: every other debug_assert! in the workspace is deliberately debug-only. Per promoted row it pins the macro form (no debug_ prefix), the exact message literal (with backslash-continuations joined), the absence of any '{' in it, and a justification comment naming #220 within 12 lines above. Per degrade row it pins that line_len_at( is called and that neither debug_assert nor [offset..] survives in the function body (comments and string literals masked first). DEV-profile RED run (cargo nextest, --no-fail-fast, 1445 skipped): Summary 15 tests run: 6 passed, 9 failed Failing, with the panic text that proves the premise: attach_import_span_non_boundary_offset_degrades_to_zero_len_span resolver.rs:2513 "attach_import_span: offset 1 is not a UTF-8 char boundary in source (len=3)" — the site's OWN debug assert fires, not the MdsError::at() canary the degraded path must reach. attach_import_span_out_of_range_offset_on_empty_source_degrades resolver.rs:2513 "... (len=0)" — fires even though MdsError::at() exempts an empty source. check_child_only_blocks_non_boundary_offset_degrades inheritance.rs:77 "check_child_only_blocks: offset 1 is not a UTF-8 char boundary in source (len=3)" check_child_only_blocks_out_of_range_offset_on_empty_source_degrades inheritance.rs:77, same shape. spliced_regions_missing_effective_block_panics_in_every_profile should_panic expected "override map was not built for this skeleton"; the current debug_assert!(false, ...) panics with different text. text_edit_new_reversed_range_panics panic message "TextEdit::new: start (5) > end (4)"; expected substring "TextEdit::new: start is greater than end". fix_line_span_range_inclusive_reversed_panics "FixLineSpan::range_inclusive: from (5) > to (4)" vs expected "FixLineSpan::range_inclusive: from is greater than to". fix_line_span_range_exclusive_reversed_panics "FixLineSpan::range_exclusive: from (5) > to (4)" vs expected "FixLineSpan::range_exclusive: from is greater than to". promoted_asserts_are_unconditional_prose_and_justified assert_promotions.rs:153 left: "debug_assert!" right: "assert!" at resolver/inheritance.rs:175 — the scanner really reads the macro form. Passing controls (6): attach_import_span_valid_offset_spans_whole_directive_ and_passes_other_errors, check_child_only_blocks_valid_offset_spans_stray_ line_and_accepts_blocks, spliced_regions_pairs_every_skeleton_block_with_its_ effective_entry, range_constructors_accept_equal_and_ordered_bounds, neutralize_replacement_width_matches_for_every_hazard_codepoint, evaluate_with_map_cursor_tracks_output_length_across_node_kinds. G1 RELEASE-profile RED run (the proof CI cannot give — CI runs Rust tests in dev only). Form: plain `cargo nextest ... --cargo-profile release --lib`, no LTO override needed; 31 s wall for list + build + run (30.45 s of that is the release compile). `cargo nextest list -p mds-core --cargo-profile release --lib -E 'test(/attach_import_span_|check_child_only_blocks_|spliced_regions_| text_edit_new_reversed|fix_line_span_range_|range_constructors_accept| neutralize_replacement_width|evaluate_with_map_cursor/)'` listed exactly 14 tests. Summary 14 tests run: 6 passed, 8 failed, 1149 skipped The four degrade tests fail here for a DIFFERENT and worse reason than in debug — the raw slice, not a compiled-out assert: resolver.rs:2518 "start byte index 1 is not a char boundary; it is inside 'e-acute' (bytes 0..2 of string)" resolver.rs:2518 "start byte index 1 is out of bounds for string of length 0" inheritance.rs:83 both of the above, same two inputs. This is the measured refutation of the issue's premise for sites 1 and 2: they do not "silently degrade" in release, they panic one line below the assert that was supposed to catch the condition. The four promotion tests fail with "test did not panic as expected": diagnostic.rs:2606, :2612, :2618 (the three range constructors) and resolver_tests.rs:3531 (spliced_regions) — i.e. in release the invariant is not enforced at all. T-D1 non-vacuity: the codepoint walk found exactly 78 members of is_control_char (30 C0 + DEL + 32 C1 + U+061C + 14 three-byte format hazards); the pin matches the real count, no adjustment needed. Gates on this commit: cargo fmt --all --check clean; cargo clippy --workspace --all-targets -- -D warnings clean (exit 0).
… enforce the skeleton-block, neutralize-width, range-check and source-map-cursor invariants in release (#220) Two remedies, chosen per site by what a defect there actually costs. DEGRADE (2 sites). Both computed a span length by slicing source[offset..] one line below a debug_assert! on the very condition the slice needs. In release the assert vanishes and the slice panics; in debug the site's own assert fires first and hides the real one. Both now call the existing private helper line_len_at, which returns 0 for a non-boundary or out-of-range offset. MdsError::at then keeps the numeric offset and drops the snippet, so the diagnostic degrades instead of mis-attributing or crashing. resolver.rs attach_import_span resolver/inheritance.rs check_child_only_blocks (super::line_len_at) line_len_at stays private and in place; parser.rs directive_line_len is untouched. ENFORCE (8 asserts). Each is a pure code-shape invariant that no template input can reach, and each had a release behaviour strictly worse than a panic. Every message is prose with no interpolation: a promoted assert is a release panic whose text reaches the terminal through the CLI's default handler (the CLI installs no panic hook), so no block name, offset, or source text may appear in it. resolver/inheritance.rs spliced_regions was debug_assert!(false, "... block '{name}' ...") + a release fallback that spliced the base default in SILENCE — i.e. dropped a child's @block override from the compiled output with no diagnostic at all. Now assert!(eff_block.is_some(), ...) on the real predicate; the fallback is gone. rustdoc gains a # Panics section; the pre-existing (PF-004) text is kept verbatim. lint/diagnostic.rs neutralize_source_for_render byte-length parity: a width mismatch does not fail, it shifts every span offset and caret column after the first substitution. lint/diagnostic.rs TextEdit::new, FixLineSpan::range_inclusive, FixLineSpan::range_exclusive public API range checks; every applier in the fix pipeline skips a reversed range rather than failing, so the lint would report a fix it never made. rustdoc "# Panics (debug)" -> "# Panics"; TextEdit::new gains a # Panics section it did not have. evaluator.rs evaluate_nodes, Text / EscapedBrace / Interpolation arms source-map cursor: a desynchronised cursor mis-attributes the whole rest of the map silently. sourcemap.rs MapBuilder rustdoc updated to say an unconditional assert_eq! checks it. Everything else stays debug_assert! — deliberately, one line each: resolver.rs:1654 tautology pin for a planned refactor, not an invariant error.rs:175 debug-only canary BY DESIGN: promoting it would make the degraded release path panic, undoing this fix sourcemap.rs 775/786/827/886 encoder-internal, hot per-segment evaluator.rs 960/977/1005/1432 evaluator-internal, hot per-node parser_helpers.rs 641/865, parser.rs 76/91, lexer.rs 181 parse-time internals lint/fix.rs 618/653/660/664 planner internals; :631 is already an unconditional assert and stays as it is formatter.rs 312/359/376 formatter internals behind a safety gate lib.rs:720, options.rs:144 API-surface internals mds-cli output.rs:279, lint.rs:1488, watch.rs 1476/2498 CLI internals mds-wasm lib.rs:80 binding internal GATES (all three measured, numbers verbatim). G1 — release-profile proof. CI runs Rust tests in dev only, so this is local and mandatory. Form: plain `cargo nextest ... --cargo-profile release --lib`; no CARGO_PROFILE_RELEASE_LTO override was needed and no [profile.release-test] was added. Wall time 31 s for list + build + run. list -> exactly 14 tests run -> Summary 14 tests run: 14 passed, 1149 skipped (the same 14 were 6 passed / 8 failed at the RED commit). Dev profile, same 14 plus the T-L1 shape test: 15 passed, 1445 skipped. Whole workspace: 2335 tests run: 2335 passed (2320 at the RED commit's parent + 15 new). Doc-tests mds: 53 passed. G2 — evaluator cost, the gate on the three source-map cursor asserts. Fixture: 180,000 lines of "Hello {{name}} \{{ tail text" over a frontmatter name: World (5,220,020 bytes; 4,500,020-byte output, 3,600,083-byte map). Binaries: target/release/mds built at the RED commit (production code then byte-identical to main) vs built from this commit. Command, x5 each, back-to-back: /usr/bin/time -p <bin> build big.mds --source-map -o out.md round 1, wall seconds before 0.24 0.21 0.22 0.21 0.21 median 0.21 after 0.69 0.21 0.21 0.22 0.21 median 0.21 (the 0.69 is the first touch of a freshly copied binary; it does not recur) round 2, wall / user seconds before 0.21/0.16 x5 median 0.21 / 0.16 after 0.21/0.16 x5 median 0.21 / 0.16 noise control, x3 each, no --source-map (the asserts are unreachable) before 0.17 0.17 0.17 median 0.17 after 0.17 0.17 0.16 median 0.17 Threshold: median wall(after) <= 1.05 x median wall(before) 0.21 <= 0.2205 PASS (ratio 1.000) Supplementary, higher resolution (the specified fixture runs in ~0.21 s, so /usr/bin/time's 10 ms granularity is 4.8% of the measurement): 320,000 lines, 9,280,020 bytes, 960,000 segments — just under MAX_SOURCEMAP_SEGMENTS. before 0.29 x5 median wall 0.29, median user 0.22 after 0.29 0.29 0.30 0.30 0.31 median wall 0.30, median user 0.22 ratio 1.034 <= 1.05 PASS (user-CPU median unchanged) Verdict: PASS on two independent rounds. The three evaluator assert_eq!s are KEPT; no revert to debug_assert_eq! was needed, and the three evaluator rows stay in the shape-test table. G3 — WASM size. Measured with CI's own command (wasm-pack build crates/mds-wasm --target nodejs --out-dir pkg, and --target web --out-dir pkg-web) against the same two trees as G2. Local wasm-opt is wasm-pack's cached v117, not CI's pinned Binaryen v129, so only the DELTA transfers. before pkg 850,313 pkg-web 850,313 after pkg 850,971 pkg-web 850,971 delta +658 bytes (+0.077%) on both — the eight new message strings. Local sits 6,865 bytes above the CI-measured 843,448 at 33380d7, inside the known 3-7 KB local/CI offset. Projected CI: ~844,106 bytes, leaving ~5,894 bytes under the 850,000 guard. Far below the 5 KB trigger that would have forced shorter messages. ci.yml and the guard are untouched. Gates on this commit: cargo fmt --all --check clean; cargo clippy --workspace --all-targets -- -D warnings clean.
`mds lint <dir>` builds every `files[].file` wire key (and the directory sort
key) with `relative_display`, which fails OPEN twice: `strip_prefix(root)
.unwrap_or(path)` rebuilds a key out of an out-of-root path's own components,
and `to_string_lossy` turns an entry name that is not valid UTF-8 into U+FFFD
replacement characters. Either way a key that names no file under the linted
directory reaches stdout.
Three tests pin the fail-closed contract; each pairs its rejections with a
positive control so it cannot pass by rejecting everything.
RED observations (this commit, macOS):
- relative_display_rejects_path_outside_root — two rejection arms. The absolute
arm `/other/x.mds` under root `/lint-root` expected Err("escapes lint root")
and got Ok("other/x.mds"): the leading `/` is dropped and the host path is
re-emitted as if it were relative to the lint root. The relative arm
`other/x.mds` under root `lint-root` is the arm that is sensitive to the
`strip_prefix` result alone — every component there is `Normal`, so a
component-shape guard cannot see it — and it likewise got Ok("other/x.mds").
Controls: both roots must still yield Ok("a/b.mds") for an in-root path.
- relative_display_rejects_non_utf8_component — expected Err("not valid
UTF-8"), got Ok of a key whose first two characters are U+FFFD replacement
characters followed by ".mds".
- lint_directory_non_utf8_entry_is_an_io_error_exit_2 — the on-disk half is a
Linux-CI gate: macOS APFS/HFS+ enforce valid UTF-8 filenames and reject the
create with EILSEQ, so the hostile arm cannot be built on this machine. The
test runs its two positive controls here (exit 1 and `files[0].file ==
"ok.mds"` in JSON mode; the literal `clean,` summary needle in human mode)
and then returns at an explicitly macOS-gated branch; on any other unix
filesystem a rejected create panics rather than skipping silently. Expected
RED on Linux: a `files` key present in the envelope and a summary line on
stderr.
Interface scaffold so the RED tests compile and fail on BEHAVIOUR rather than
on a type error: `relative_display` already returns
`Result<String, MdsError>`, with the existing fail-open body wrapped in `Ok`
and the three production call sites carrying a temporary `.expect`. The GREEN
commit replaces the body, the call sites and that scaffold. The four existing
`relative_display` unit tests switch to `.expect("in-root path")` with their
assertions byte-identical.
Counts: mds-cli 878 tests (875 before), 876 passed, 2 failed (the two RED unit
tests above). fmt and clippy clean.
Refs #217
…fore the sort; run exits 2 with the analysis-failure envelope (#217) `relative_display` no longer manufactures a key it cannot justify. It returns `Err(MdsError::Io { .. })` when the path is not under `root`, when a component that survives the strip is not `Normal`, or when a component is not valid UTF-8 — instead of `strip_prefix(root).unwrap_or(path)` plus a `to_string_lossy` join. For every in-root UTF-8 path the `Ok` value is byte-identical to the previous output, so no existing assertion moved. `run_lint_directory` now computes every display key in a pre-pass BEFORE the sort and before the per-file loop, and `lint_one_file_accumulating` / `lint_one_file_human` take `display_path: &str` instead of recomputing it. Two orderings are load-bearing and are commented at the pre-pass: - before the sort — the sort key IS the display key, so a lossy key that only failed later would already have been built and compared; - before the per-file loop — nothing is linted, so no `files[]` entry can carry a key that names no file under the linted directory. The pre-pass does NOT use `?`. The caller returns `miette::Result`, so a bare `?` would render a human diagnostic and drop the JSON analysis-failure envelope that `--format json` consumers parse. It follows the existing symlinked-root shape instead: `emit_analysis_failure_json_or_stderr(&e, format, None)` then `std::process::exit(mds_error_exit_code(&e))`. The non-UTF-8 message text is the one `read_source_file` already produced for the same condition, so the user-visible wording is unchanged; the exit code is unchanged at 2. What changes is WHEN: the whole run now fails before any file is linted, instead of the tree being linted with one entry keyed by U+FFFD replacement characters. `LintDirCtx.lint_root` existed only to feed those two per-file calls and is removed; the `debug_assert_eq!` partition invariant now counts `keyed`. Verified locally by forcing `relative_display` to error and running the binary over a real directory: `--format json` emits `{"error":{"code":"mds::io","message":"path is not valid UTF-8: …",…},"version":1}` with no `files` key and exit 2; human mode prints the same message with no summary line and exit 2. That is the exact shape `lint_directory_non_utf8_entry_is_an_io_error_exit_2` asserts on Linux, whose hostile arm cannot be built on macOS — APFS/HFS+ reject a non-UTF-8 filename with EILSEQ at create. The test handles that with an explicitly macOS-gated early return placed AFTER its positive controls have run; on any other unix filesystem a rejected create panics rather than skipping silently. Mutation controls (applied, observed, reverted): - `strip_prefix(root).unwrap_or(path)` reintroduced → relative_display_rejects_path_outside_root fails on its relative arm ("other/x.mds"). Recording the first run of this control honestly: with only the absolute arm the mutant SURVIVED, because `/other/x.mds` is then rejected by the `Component::Normal` guard rather than by the strip. The relative arm and its control were added to the RED commit for exactly that reason, and the mutant now dies. - `to_string_lossy` reintroduced → relative_display_rejects_non_utf8_component fails, reporting a key of two U+FFFD replacement characters. - pre-pass replaced by a per-file lossy key: not observable on macOS, where the hostile arm cannot be created. The forced-error run above is the local stand-in — it exercises the envelope + exit code + absent summary that this mutation would break. Counts: mds-cli 878 tests, 878 passed, 0 failed. fmt, clippy and the source-hygiene gate clean. Refs #217
…acuous (#217) `relativize_source` converts its `root` and `base` anchors with `path_to_unified`, which is `p.to_str().unwrap_or("")`. An anchor that cannot be decoded therefore becomes the empty string, `normalize_abs("")` becomes the EMPTY component list, and `starts_with_comps(x, [])` is vacuously true — an empty component list is a prefix of every path. So an unusable root passes containment for every source, and `component_diff` then emits the full host path minus its leading `/` into `sources[]`. That is precisely the filesystem layout the guard exists to keep out. `NativeFs::source_root` has the mirror-image problem: it reports `p.display().to_string()`, so a root that is not valid UTF-8 is handed to the guard as a lossy string that names no directory and cannot be compared component-wise against a real source. Six tests, each pairing its degradation with a positive control. RED observations (this commit): - empty_root_is_not_a_vacuous_anchor — root `""`, source `/Users/alice/proj/src/a.mds`: expected `"a.mds"`, got `"Users/alice/proj/src/a.mds"` — the host path with the leading `/` removed. - non_utf8_root_degrades_to_basename — root `/Users/alice/proj` plus one invalid byte, source `/Users/alice/projX/src/a.mds`: expected `"a.mds"`, got `"Users/alice/projX/src/a.mds"`. Control root `/Users/alice/projX` already yields `"src/a.mds"`. - property_outputs_never_absolute_or_drive_qualified — the new `root: Some("")` case leaks `"Users/alice/proj/src/a.mds"`. The matrix's existing round-trip invariant cannot catch this on its own: an empty root is a prefix of every path, so the check is satisfied vacuously. The added assertion is explicit about the leak. - source_root_is_none_for_non_utf8_root — a root of `/tmp/` plus one invalid byte: expected `None`, got `Some` of a string ending in a U+FFFD replacement character. Two of the six are PINS that are green before and after, recorded as such rather than presented as RED: - non_utf8_base_falls_back_to_root_anchor — an unusable BASE already lands on the root anchor today, because step 9 asks whether the base is inside the root and an empty component list is not (`starts_with_comps([], ["proj"])` is false on the length check). The test pins that the behaviour survives the change, with a control proving a usable base still shifts emission to `"../src/a.mds"`. - root_slash_is_a_real_root_not_a_vacuous_one — `normalize_abs("/")` is also the empty component list, but `/` is a legitimate project root (a container with no WORKDIR resolves there) and must keep root-relative emission. This pins the deliberate non-change: the distinction to draw is "the unified string is empty or undecodable" versus "the unified string is `/`". The invalid bytes are built at runtime from numeric values; no escape sequence or raw byte appears in any test source. Counts: mds-core 1465 tests (1460 before), 1461 passed, 4 failed (the four RED observations above). fmt, clippy and the source-hygiene gate clean. Refs #217
…ble base → root anchor; source_root is None for a non-UTF-8 root (#217) `path_to_unified` now returns `Option<String>`, `None` for a path that is not valid UTF-8 or that unifies to the empty string. The whole hazard was that its old `to_str().unwrap_or("")` fed `normalize_abs("")`, which is the EMPTY component list — and `starts_with_comps(x, [])` is vacuously true. An anchor that could not be decoded therefore authorised containment for every source, and `component_diff` emitted the full host path minus its leading `/` into `sources[]`. `relativize_source` gains step 6b, immediately after the existing `root = None` branch: - root that is `None` from `path_to_unified` → `basename_fallback(&norm_comps)`; - base that is `None` from `path_to_unified` → the root anchor, at BOTH of its uses (the step-7 resolution anchor and the step-9 emission anchor `b`). Root `/` is deliberately NOT affected. It unifies to `"/"`, which is not empty, so it stays a real root and keeps root-relative emission even though `normalize_abs("/")` is also the empty component list — a container with no WORKDIR resolves there. The distinction the guard draws is "the unified string is empty or undecodable", not "the component list came out empty". `NativeFs::source_root` uses `to_str` instead of `display()`, so a root that is not valid UTF-8 is reported as `None` — the documented "no containment concept" value with a guarded branch at every consumer (never absolute, never drive-qualified, `..`-escape → basename) — rather than a lossy string naming a directory that does not exist. A sentinel string would be a fake path leaking through the public trait. Containment itself is untouched: `check_path_traversal` compares `Path`s from `root_dir` directly and never goes through this string. The trait rustdoc states the obligation for third-party implementations, and the `relativize_source` rustdoc and the module's security invariant both gain the anchor-validity rule. No new error variant and no sentinel: the change is entirely in the Option routing. Source-map goldens: NONE moved. The design guarantees zero change for any UTF-8 non-empty root and any base, and that is what the surfaces show — `packages/mds` 304/304 pass (including `source maps (U-SM)`, `source maps — WASM backend (W-SM)` and `source maps — compileFile differential (CF-SM)`) against a freshly rebuilt native addon, and the Python suite is 250 passed / 6 deselected against a freshly built extension. The CLI's own source-map assertions in output.rs and watch.rs are part of the green mds-cli run. The WASM path is structurally unaffected: `VirtualFs::source_root` is the trait default `None`, so it never reaches step 6b. An empty base is likewise unreachable from the CLI — `compute_source_map_base` absolutizes every branch. Mutation controls (applied, observed, reverted): - `path_to_unified(root).unwrap_or_default()` → empty_root_is_not_a_vacuous_anchor, non_utf8_root_degrades_to_basename and the property matrix all fail, each reporting the host path minus its leading `/`. - unusable base mapped to the empty component list instead of `None` → non_utf8_base_falls_back_to_root_anchor stays GREEN, and that is correct: step 9 already asks whether the base is inside the root, and an empty component list is not (the length check fails). The mutation is behaviour-preserving for the tested paths; it is recorded here rather than papered over with a test that would only pin an implementation detail. - `source_root` back to `display().to_string()` → source_root_is_none_for_non_utf8_root fails with `Some` of a string ending in a U+FFFD replacement character. Counts: workspace 2343 tests (mds-core 1465, mds-cli 878), all passing; doctests 53. fmt, clippy, the rustdoc gate (`RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core -p mds-cli --no-deps`) and the source-hygiene gate are clean. Refs #217
…absolute; watch ghost prune keeps in-root state (#217) Six tests over the directory-mode output oracles and the watch bookkeeping that probes them. `MirroredStem` + `mirror_stem` are added here as an INTERFACE SCAFFOLD only: the Err arm reproduces today's fail-open fallback verbatim (`d.join(source.file_stem().unwrap_or(source.as_os_str()))`), so the classification can be pinned before the behaviour changes. The follow-up commit replaces that body and wires the report. RED observations (`cargo nextest run -p mds-cli --bins --test dir_build --no-fail-fast`, 204 tests, 201 passed, 3 failed): - stemless_source_never_escapes_out_dir — FAILED. `output_base_no_ext(Path::new("/"), "/root", Dir("/out"))` returned `"/"`, not `"/out/output"`: `"/".file_stem()` is None, the fallback handed `source.as_os_str()` — `"/"` — to `d.join(...)`, and joining an absolute argument re-roots. Suppressing that first assertion locally to reach the second showed the write oracle's half: `output_path_for` built `"/.md"` and tripped the containment `debug_assert!` at output.rs:279 — `output_path_for: AC-M7 violated — output "/.md" escaped out-dir "/out"`. Neither value is inside the out-dir; neither is a path a source compiles to. - flatten_warning_lives_in_the_write_oracle_only — FAILED. The body of `fn output_path_for(` contains no `eprint_warning(` and no "is outside the build root": the flatten is currently silent. - ghost_external_dep_prune_keeps_in_root_last_written — FAILED. After one batch, `last_written` held only `[<root>/other.md]`; the in-root source's `out/x.md` entry was gone. Pruning a vanished OUT-OF-ROOT dep probes `output_base_no_ext`, takes the flatten arm, and forgets `<out-dir>/x.md` — an entry an in-root `x.mds` owns. Its control (`errored` no longer holds the ghost) passed, so the batch did reach the branch. Green in this commit (pins and controls, not regressions): - mirror_stem_classifies_out_of_root_as_flattened — passes on the scaffold; both arms asserted so a classifier that always says Flattened would fail. - build_non_utf8_path_exits_2_and_writes_no_map — control arm pins `--source-map` on a normal file: exit 0, sidecar present, `sources == ["ok.mds"]`. The hostile arm is a Linux-CI gate: macOS (APFS) rejects creating a non-UTF-8 filename with EILSEQ, so the create-failure path takes an explicit macOS-only `return` with a sibling `panic!` on every other platform — no silent skip. - dir_build_symlinked_ancestor_root_mirrors_without_warning and dir_build_dotdot_root_mirrors_without_warning — non-canonical roots (a symlinked ancestor; a `..` component) still mirror their subtree and must not emit the report. `run_build_directory` hands the same raw `dir` to the walker and to `output_path_for`, so `strip_prefix` succeeds; these fail if a future change canonicalizes one and not the other. Each carries the "2 built, 0 failed" needle so the negative assertion cannot pass on empty stderr. `cargo fmt --all` clean; `cargo clippy --workspace --all-targets -- -D warnings` and `cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings` both clean.
…s are relative, watch external-ghost prune skips the output probe (#217) Three changes plus four invariant comments. Nothing about the mirrored (normal) path moves; the flatten stays CONTAINED and stays a flatten. 1. `mirror_stem`'s Err arm no longer joins an absolute value. `file_stem()` is `None` only for `/`, `..` and a bare drive prefix — never a `.mds` file — and the old fallback, `source.as_os_str()`, was exactly the absolute value that escapes: `Path::new("/out").join("/")` re-roots to `"/"`. The literal `"output"` is the only value guaranteed relative for every input. The same substitution is applied to the two `unwrap_or(source.as_os_str())` fallbacks in `output_path_for`. The containment `debug_assert!` is KEPT and stays debug-only on purpose: its release fallback is contained, so there is nothing a release-time check would prevent. 2. `output_path_for` reports the flattened arm — once, inline, with `safe_path` at each interpolation site, not gated on `--quiet` (the depth-limit and stale-unlink warnings in the same file are not either; this is an invariant report, not a user preference). `output_base_no_ext` computes the same stem for bookkeeping probes and stays SILENT: watch calls it several times per batch and for sources that are never written. 3. `DirWatchState::forget` splits into `forget_graph` (the three graph removals) and `forget`. The ghost-prune at the `!src.exists()` branch of `process_dir_batch_incremental` now calls `forget_graph` for an out-of-root source. This was a REACHABLE bookkeeping bug: a vanished external dep has no output, so probing `output_base_no_ext` for one took the flatten arm and forgot `<out-dir>/<file name>.md` — an entry owned by the in-root source with that file name, whose next rebuild then rewrote identical bytes. Comment-only, no behaviour change: `canonicalize_out_dir`'s fail-open `current_dir()` fallback is documented as such with the reason it is a follow-up rather than part of this change; `is_within_default_excluded_dir`'s `Err => false` is documented as the CLOSED answer (both callers evaluate it only alongside their own `starts_with(root)` test); `is_partial`'s non-UTF-8 `false` is documented as fail-closed downstream (mds-core's `path_to_str` rejects the path, so the file becomes a per-file failure instead of a silent skip); and the three watch `output_path_for` call sites each state why the flatten arm cannot fire there. Exact warning text as emitted (one line, `{}` filled by `safe_path`): warning: <source> is outside the build root <root>; its output is written flat as <out> (another source outside the root with the same file name would overwrite it) GREEN — `cargo nextest run -p mds-cli --no-fail-fast`: 885 tests run, 885 passed, 0 skipped (was 878 before this phase; +7 new tests). All four RED tests now pass. `print_discipline` both tests green, registry untouched (`every_allowlist_entry_is_live` passes, so no entry went dead). `cargo nextest run -p mds-cli --test cli_watch --no-fail-fast` x3, sequential: 80/80 passed each run (4.54 s / 4.44 s / 5.07 s). No timeout, sleep, debounce or ordering change was made. Mutation controls, each observed then reverted: - report deleted from the write oracle and moved into the probe oracle: flatten_warning_lives_in_the_write_oracle_only fails on the positive half ("the write oracle must report the flattened arm"). - report present in BOTH oracles: the same test fails on the negative half ("the probe oracle must stay silent"), so neither half is vacuous. - `safe_path` dropped at the site and the value hoisted into a `let`: print_discipline reports `output.rs:334: eprint_warning(format!) interpolates unsanitized \`src_display\``. - Err-arm `unwrap_or(source.as_os_str())` reintroduced: stemless_source_never_escapes_out_dir fails, left `"/"` right `"/out/output"`. - watch gate forced to `if true`: ghost_external_dep_prune_keeps_in_root_last_written fails, `last_written` keys reduced to `[<root>/other.md]`. Named weakness: T-P16 pins the report's PRESENCE in `output_path_for` lexically, so it would still pass if the call were wrapped in a `!quiet` gate. `output_path_for` has no `quiet` in scope, so that mutation is not reachable without a signature change; recorded rather than performed. `cargo fmt --all` clean; `cargo clippy --workspace --all-targets -- -D warnings` and `cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings` both clean.
Two tests. `fmt_non_utf8_path_exits_two` (cli_fmt.rs) is the RED one;
`lint_single_file_non_utf8_path_exits_2` (cli_lint.rs) is the PIN it must match —
`lint`'s single-file arm has always raised `MdsError::Io` here and `exit_code` has
always mapped that to 2. Its directory sibling
(`lint_directory_non_utf8_entry_is_an_io_error_exit_2`) was added earlier in this
branch; this is the file arm.
`mds fmt`'s module doc has promised "2: file not found / not `.mds` / I/O / bad
UTF-8" since the subcommand shipped, and `check_symlink` — two statements earlier
in the same `read_source_file` — already exits 2. The undecodable-path arm raised
a bare `miette::miette!`, which does not downcast to `MdsError`, so `exit_code`
fell through to 1. One input class, two exit codes, decided by which of two
adjacent checks caught it first.
RED observation. macOS (APFS) rejects creating a non-UTF-8 filename with EILSEQ,
so the on-disk arm cannot run here — it is a Linux-CI gate, taken via an explicit
`#[cfg(target_os = "macos")] return` with a sibling `panic!` on every other
platform, so no other platform can skip silently. To observe the arm locally the
`to_str()` success path was temporarily forced to `None`
(`.filter(|_| false)`), the real binary rebuilt, and run over a normally named
file:
$ mds fmt <scratch>/ok.mds
× path is not valid UTF-8: <scratch>/ok.mds
EXIT=1 # must be 2
The same forced build in DIRECTORY mode:
$ mds fmt <scratch>
× path is not valid UTF-8: <scratch>/ok.mds
0 formatted, 0 unchanged, 1 failed
EXIT=1 # correct, and must STAY 1
Directory mode exits 1 through the `Failed` tally, not through `exit_code`, so it
is unaffected by the fix and is deliberately not asserted here. The forced branch
was reverted before committing.
Both tests pass on macOS today — the control arms run, the hostile arms return
early. The hostile assertions first execute on Linux CI.
`cargo nextest run -p mds-cli --test cli_fmt --test cli_lint --no-fail-fast`:
176 tests run, 176 passed, 0 skipped. `cargo fmt --all --check` clean;
`cargo clippy --workspace --all-targets -- -D warnings` and
`cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings`
both clean.
) `read_source_file` raised the undecodable-path failure as a bare `miette::miette!`. That report does not downcast to `MdsError`, so `build::exit_code` fell through to 1 — while `check_symlink`, called one statement earlier on the same argument, already returned an `MdsError` and exited 2. The module doc has documented 2 for this class since the subcommand shipped. The message text is byte-identical to `lint.rs`'s `read_source_file` (`path is not valid UTF-8: {}` over `path.display()`), so the two subcommands now report the same condition the same way. No new `MdsError` variant; no change to `exit_code`. GREEN observation. macOS (APFS) cannot create the on-disk fixture, so the arm was exercised by temporarily forcing the `to_str()` success path to `None` (`.filter(|_| false)`), rebuilding the real binary, and running it — the same procedure that produced the RED reading in the previous commit: $ mds fmt <scratch>/ok.mds mds::io × path is not valid UTF-8: <scratch>/ok.mds EXIT=2 # was 1 DIRECTORY mode is UNCHANGED, as intended: $ mds fmt <scratch> mds::io × path is not valid UTF-8: <scratch>/ok.mds 0 formatted, 0 unchanged, 1 failed EXIT=1 # was 1 Directory mode exits through the `Failed` tally, not through `exit_code`, so the error class it reports does not reach the exit status. Nothing about it moves. The forced branch was reverted before committing. Full gate battery on the final tree: - `cargo nextest run -p mds-core -p mds-cli --no-fail-fast`: 2352 tests run, 2352 passed, 0 skipped (mds-core 1465 unchanged, mds-cli 887 — was 878 at the start of this phase, +9: 7 in the output/watch RED commit, 2 here). - `cargo clippy --workspace --all-targets -- -D warnings` — clean. - `cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings` — clean. - `cargo fmt --all --check` — clean. - `RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core -p mds-cli --no-deps` — clean. - `node scripts/verify-no-control-bytes.mjs` — 563 files, 6811840 bytes, clean.
…GELOG, spec §7.2/§7.5/§7.9, SECURITY.md, rustdoc, KBs (#220, #217) Documents the behaviour landed by the ten preceding commits on this branch. No production code changes; the only .rs edit is a test-group comment. CHANGELOG [Unreleased]: - Changed: the pub-API range-check promotion (TextEdit::new, FixLineSpan::range_inclusive/range_exclusive now panic in release). - Fixed: the #220 release-enforcement entry (non-boundary import/extends spans degrade instead of panicking; skeleton-block, neutralize-width and source-map cursor invariants enforced in every profile), plus four #217 entries — lint directory-mode fails closed on an unnameable path, fmt non-UTF-8 exits 2, source-map anchors fail closed, and the build/watch flatten warning. spec.md: - §7.2 dir-mode --out-dir: the out-of-root flat write and its non-quiet warning. - §7.5 dir-mode lint: entries are named relative to the lint root before any file is linted; an unnameable path fails the whole run. - §7.9: both exit-2 rows name the non-UTF-8 path case. SECURITY.md: new filesystem-boundary bullet — source-map anchors are byte-faithful, and an empty or non-UTF-8 root can never make containment vacuous. rustdoc: the B.6 checklist was verified site by site; every item was already in place from the earlier commits except the line_len_at test-group header in resolver_tests.rs, which now records that the helper also backs attach_import_span and check_child_only_blocks. Feature KBs: mds-lint (neutralize assert_eq! promotion x2, relative_display now fallible and LintDirCtx.lint_root gone), source-map-security (evaluator cursor assert, the empty/non-UTF-8 anchor gotcha, source_root None for a non-UTF-8 root), mds-cli (AC-M7 paragraph rewritten for MirroredStem/mirror_stem, the write-oracle-only flatten warning, relative degenerate stems, the watch forget_graph prune), mds-fmt (single exit-code correction). Refs #220, #217
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent hardening passes at the same boundary — "what does this code do in a release build when an internal assumption is violated?" — plus one exit-code correction.
debug_assert!-only invariants silently degrade in release builds (3 sites) — PF-005 #220). Tendebug_assert!/debug_assert_eq!sites were audited. Two are now degradations (a non-boundary import/@extendsspan offset yields a zero-length span instead of slicingsourceraw), and eight are now unconditionalassert!/assert_eq!— the skeleton-block splice, theneutralize_source_for_renderbyte-length check, the three pub-API range checks, and the three evaluator source-map cursor checks. None of the ten can be tripped by template input; each is a compiler defect. The remaining 30 genuinedebug_assert!invocations were each examined and deliberately kept.output.rspath helpers fail-open onstrip_prefixedge cases and non-UTF-8 paths #217). Four host-path surfaces that failed open now fail closed:mds lint <dir>'s wire/sort key, the two source-mapsources[]anchors, and two degenerate output-path joins. Plus a new warning whenmds build/mds watchwrites a source flat instead of mirrored.mds fmt <file>on a non-UTF-8 path exits 2 (output.rspath helpers fail-open onstrip_prefixedge cases and non-UTF-8 paths #217), not 1 —mds::io, matchingmds lintandmds build. Directory mode is unchanged.security/snyk (dean0x)is SCA-only for this PR — the Snyk MCP server failed to connect locally (ENOENT on a stalesnyk-macos-arm64npx cache path), so nosnyk_code_scanran; Rust is not scanned by Snyk Code here in any case.What was wrong
Sites 1 and 2 did not "silently degrade" in release — they panicked. This is the measured refutation of the issue's premise.
attach_import_span(resolver.rs) andcheck_child_only_blocks(resolver/inheritance.rs) each had adebug_assert!that the offset was a char boundary, followed one line below by a raw slice ofsource. In a release build the assert compiles away and the slice runs. The RED release run produced, verbatim:So the pre-existing release behaviour was a panic with a message naming a byte of the user's source, not a degraded span.
Site 3 genuinely was a silent fallback, and the worst of the ten. The skeleton-splice walk had
debug_assert!(false, …)in the "no effective-blocks entry for this@block" arm and then spliced the base default. In release that silently drops a child template's@blockoverride and emits the parent's content — wrong output, no diagnostic, no exit code.Three more sites were promoted because their release fallback was also unsound rather than merely cosmetic: the
neutralize_source_for_renderbyte-length check (a width mismatch desynchronises every following span offset, so miette renders carets against the wrong columns), and the three pub-API range constructors (TextEdit::new,FixLineSpan::range_inclusive,range_exclusive), where a release caller passing a reversed range got a value the fix planner later skipped in silence.relative_displayfailed open on both the wire key and the sort key.mds lint <dir>built eachfiles[].filevalue with astrip_prefix(root).unwrap_or(path)plusto_string_lossy. An entry not under the lint root emitted an absolute key; an entry with a non-UTF-8 component emitted a lossy key (U+FFFD). The same string is the sort key, so the directory ordering was affected too.Empty-root containment was vacuous.
relativize_source's containment test isstarts_with_comps(source_comps, root_comps).path_to_unifiedreturnedString, so an empty or unusable root became an empty component list — andstarts_with_comps(x, [])is vacuously true for everyx. Result: every source-mapsources[]entry would be treated as "inside the project" and emitted as a host-absolute path minus its leading/.A non-UTF-8 root became a lossy anchor.
NativeFs::source_root()useddisplay().to_string(), so a root directory that is not valid UTF-8 produced a string ending in U+FFFD and was then compared, byte-wise, against real path components. It never matched, but it was still being used as an anchor.Two stem-less joins could escape the out-dir. Both
Dir(_)-mode output oracles fell back tosource.as_os_str()whenfile_stem()wasNone.d.join(<absolute>)re-roots:output_base_no_ext(Path::new("/"), "/root", Dir("/out"))returned"/", andoutput_path_forthen built"/.md"— outside the out-dir entirely.The watch ghost-prune collided on file name. Pruning a vanished out-of-root dependency called the full
DirWatchState::forget, which probes and removes the output path. For an out-of-root ghost the probe resolved through the flatten arm to<out>/<name>.md— the same path an in-root source with that file name owns — so the prune dropped the in-root source's write-dedup entry.mds fmtexited 1 on an undecodable path wheremds lintandmds buildboth exited 2, and thefmtmodule doc already claimed 2.Design
line_len_at, already had exactly the char-boundary-safe semantics (it backsbuild_type_mismatch), so both sites now route through it. One degradation rule, one helper, one test group — rather than a second hand-rolled boundary check at each site.assert!(false). Site 3 has no safe fallback value: splicing the base default is wrong output. The promotion is an assertion on the map lookup itself, so the check reads as the invariant it enforces rather than as an unreachable marker in an else-branch.safe_path,safe_inline) that every other CLI output path is forced through byprint_discipline. Interpolating source text or a path into an assert message would route unsanitized bytes around that guard. All eight messages therefore carry static text and integers only.Option, not a sentinel, forsource_root/path_to_unified. The bug being closed is a sentinel bug: the empty string is a valid prefix of every path, so""as "no anchor" makes containment vacuously true.Option<String>makes "not an anchor" unrepresentable as a prefix — the choke-point must decide whatNonemeans at step 6b, and the compiler forces both branches to be written. An unusable root means "not contained" (basename); an unusable base means "anchor on the root".output_base_no_extis a probe — watch calls it many times per batch to compute candidate paths for stale-output cleanup and dedup bookkeeping, for paths that may never be written; reporting there would emit the warning repeatedly for one logical event.output_path_foris called once per actual write. T-P16 pins both halves (present in the write oracle, absent in the probe oracle), so neither half is vacuous — and P-M7/P-M7b confirmed each half fails independently.?.run_lint_directorycomputes every display path into aVec<(PathBuf, String)>before sorting, and on the firstErrroutes it throughemit_analysis_failure_json_or_stderrand exits — rather than propagating. Two reasons: the emit-before-exit ordering rule means a bare?would let the caller's blanket catch exit 2 without writing the JSON envelope; and failing before any file is linted is what keeps a partialfiles[]array from being emitted alongside the error./is deliberately unchanged.path_to_unified("/")isSome("/"), which normalizes to an empty component list legitimately —/really is a root and every absolute path really is under it. The fail-closed rule therefore keys on "could not produce an anchor" (None), never on "the component list is empty". Collapsing those two would change behaviour for a real, reachable root.error.rs'sat()canary stays debug-only, and that is load-bearing. It is the mechanism that lets sites 1 and 2 degrade: the tests expect the debug run to panic withMdsError::at(): cross-source offset mismatchand the release run to returnspan: Some((1,0)), src: None. Promoting it would make the release branch panic — undoing this PR's degradation and re-creating thedebug_assert!-only invariants silently degrade in release builds (3 sites) — PF-005 #220 failure one layer up. Recorded as M11 (reasoned, deliberately not performed).Behaviour changes
TextEdit::new,FixLineSpan::range_inclusive,FixLineSpan::range_exclusivepanic on a reversed range in release as well as debug. Public API; rustdoc# Panicsadded. No in-tree caller constructs one.@blockplaceholder with no effective-blocks entry now panics in release instead of splicing the base default.neutralize_source_for_renderbyte-width mismatch now panics in release instead of desynchronising span offsets.mds::internal, unchanged.mds lint <dir>with an unnameable entry fails the whole run before any file is linted (was: per-file error with a lossy/absolute key, rest of the tree still linted). Exit code is unchanged at 2; the message text is unchanged.mds fmt <file>on a non-UTF-8 path exits 2 (mds::io), was 1. Directory mode unchanged (exit 1 via theN failedtally).mds build/mds watch --out-dirprints a warning when a source is written flat instead of mirrored. Not gated on--quiet. No walked source can reach it — it is a tripwire, not user-facing advice.<out>/output.<ext>instead of/and/.md. Unreachable from the CLI; a real change to the pure functions.mds watchno longer probes output paths when pruning a vanished out-of-root dependency, so it can no longer drop an in-root source's write-dedup entry.Nothing moved in any golden.
@mdscript/mds'ssource maps (U-SM),source maps — WASM backend (W-SM)andsource maps — compileFile differential (CF-SM)suites are green against a freshly rebuilt native addon and a freshly rebuiltcrates/mds-wasm/pkg— the addon must be rebuilt first or the harness loads a stalemds-napi.nodeand silently exercises the old binary. Python parity:pytest -m "not perf"→ 250 passed. WASM is structurally unaffected in any case:VirtualFs::source_rootis the trait defaultNone, so the WASM path never reaches step 6b, and an empty base is unreachable from the CLI becausecompute_source_map_baseabsolutizes on every branch.Evidence
Commits — RED → GREEN per pair
ad23019--list14, 6 passed / 8 failed9e2348812b3408Okof an out-of-root key; T-P2 returnedOkof a U+FFFD keydd54b82Err; controlsOk7e9a5fe/; T-P12 returnedSome(…U+FFFD)d73cf2fNone; controls unchangeda10a7bcoutput_base_no_ext("/", "/root", Dir("/out"))→"/"; T-P16 no report inoutput_path_for; T-P19last_writtenlost the in-root entry0be5e56/out/output, report present, in-root entry keptaae35a9EXIT=1f4707d7mds::io,EXIT=2; directory mode stillEXIT=1e1a69d0Note on 9/10: macOS APFS rejects
touch $'\xff\xfe.mds'withIllegal byte sequence(EILSEQ) — the kernel enforces valid UTF-8 in filenames; Linux ext4 does not. The on-disk hostile arms of T-P4, T-P5, T-P6 and T-P13 therefore return early here and first execute on Linux CI. Local evidence for those arms is the forced-branch technique: the success path of the decode was temporarily forced toNone, the real binary rebuilt, run end to end, and reverted. Each test's control arm runs unconditionally on every unix platform, and on any non-macOS unix a failed hostile createpanic!s rather than skipping, so a filesystem that should accept the name can never masquerade as a skip. No#[ignore], noA || Bassertions.G1 — release-profile verification (not run by CI)
ci.ymlrunscargo test --workspacein the dev profile only, so nothing in CI would have caught any of the eight promotions or the four degradations. G1 is a local gate:Plain form; no
CARGO_PROFILE_RELEASE_LTO=falseoverride and no[profile.release-test]were needed. At this commit: 14 listed, 14 passed, 1 154 skipped, 29.35 s wall (list + release compile + run).G2 — evaluator cost gate (the three cursor asserts fire per output node)
Fixture
big.mds: 180 000 ×Hello {{name}} \{{ tail text= 5 220 020 B → 4 500 020 B output, 3 600 083 B map./usr/bin/time -p <bin> build big.mds --source-map -o out.md, release binaries built atad23019(production code then byte-identical to main) vs9e23488.† first touch of a freshly copied binary; does not recur.
Round 2 (independent repeat), wall/user: before
0.21/0.16×5, after0.21/0.16×5 — medians identical. Noise control ×3 each without--source-map(the asserts are unreachable): median0.17both. Higher-resolution supplement (320 000 lines, 9 280 020 B, 960 000 segments — just underMAX_SOURCEMAP_SEGMENTS= 1 000 000): median wall0.29→0.30(ratio 1.034), median user0.22→0.22.Threshold
median wall(after) ≤ 1.05 × median wall(before)→0.21 ≤ 0.2205. PASS on two independent rounds (ratio 1.000). No revert; the three evaluatorassert_eq!s stay.G3 — WASM size
Measured with CI's own commands (
wasm-pack build crates/mds-wasm --target nodejs --out-dir pkg/--target web --out-dir pkg-web) — notnpm run build -w @mdscript/mds-wasm, which writespackages/mds-wasm/dist/{node,web}and leavescrates/mds-wasm/pkgstale.pkg/mds_wasm_bg.wasmpkg-web/mds_wasm_bg.wasm33380d7)e1a69d0)The #220 work accounts for +658 B and the #217
mds-corechanges (source_path.rs'sOptionreturn and step 6b,fs.rs'ssource_root) for the remaining +757 B. The #217 CLI work contributes nothing —mds-cliis not compiled into the WASM artifact.Local uses wasm-pack's cached wasm-opt v117, not CI's pinned Binaryen v129, so only the delta transfers; the CI byte count is the authoritative one. Local sits 6 865 B above the CI-measured 843 448 at
33380d7— inside the known 3–7 KB local/CI offset. Projected CI ≈ 844 863 B, ≈ 5 137 B under the 850 000 guard..github/workflows/ci.ymland the guard were not touched.Flake screen
cargo nextest run -p mds-cli --test cli_watch --no-fail-fast, three sequential runs on the green tree: 80/80 each time (4.536 s / 4.440 s / 5.069 s). Zero failures, zero skips, zero leaky. No timeout, sleep, debounce, serialisation or ordering change was made anywhere inwatch.rs.Mutation testing — #220 (M1–M11)
debug_assert!+ raw slice at site 1line_len_atat site 1debug_assert!(false)+ fallbackTextEdit::newmessage interpolates({start} > {end})neutralizeback todebug_assert_eq!TextEdit::newback todebug_assert!debug_assert_eq!#220justification comment above theneutralizeasserterror.rs:175Three mutations (M3, M6, M8) are caught ONLY by the lexical shape test
T-L1(crates/mds-core/tests/assert_promotions.rs). That test is load-bearing, not a style guard: with CI running dev-only, it is the sole detection for a silentassert!→debug_assert!demotion (M7).Mutation testing — #217 (P-M1 … P-M12)
strip_prefix(root).unwrap_or(path)/other/x.mdsis still rejected — by theComponent::Normalguard, not by the strip. A relative out-of-root arm was added to the RED commit; the mutant then diesto_string_lossyreintroducedpath_to_unified(root).unwrap_or_default()/; T-P9/T-P10 correctly stay greenNonesource_rootback todisplay().to_string()Some(…U+FFFD)output_path_forintooutput_base_no_extsafe_pathdropped, value hoisted into a localprint_disciplinefails:output.rs:334: eprint_warning(format!) interpolates unsanitizedunwrap_or(source.as_os_str())reintroduced"/"right"/out/output"if truelast_writtenreduced to the out-of-root keymiette::miette!EXIT=1(the pre-fix forced-branch reading)!quietfn output_path_for(, so anif !quiet { … }wrapper would still pass.output_path_forhas noquietin scope, so the mutation is unreachable without a signature change (out of scope) — but the test's weakness is real and recorded rather than papered overEvery mutation above except P-M12 and M11 was applied, observed and reverted. Post-revert greps for
if true {,src_display,filter(|_| false)andTEMP-OBSERVEreturned zero hits before each commit.The 30
debug_assert!sites that deliberately stayA naive
git grep -nE 'debug_assert(_eq|_ne)?!'overcrates/*/srcreturns 71 lines / 60 macro-looking hits, but only 40 are real invocations — the other 20 are prose in doc and inline comments that mention the macro (several of them explaining why adebug_assert!was deliberately not added there). The 40 real invocations are exactly 2 degraded + 8 promoted + 30 kept; 0 were unlisted. 39 are production, 1 (lint/fix.rs:1206) is inside a#[cfg(test)] mod testsand never compiles into a release build at all.mds-cli/lint.rs:1488files.len()mds-cli/output.rs:279base/<stem>.<ext>), so a release check would guard nothingmds-cli/watch.rs:1476,:2498last_mtimesequals the pre-compile snapshotmds-core/error.rs:175at()cross-source span in bounds / on a boundaryevaluator.rs:960,:977And/Oroperands are leavesevaluator.rs:1005,:1432elseif_branches.len() <= MAX_ELSEIF_BRANCHESformatter.rs:312,:359,:376lexer.rs:181scan_code_contentrequirescode_fence.is_some()lib.rs:720RESERVED_OUTPUT_KEYScontainstypeandimportslint/fix.rs:618,:653,:660,:664apply_plan_uncheckedis the explicit bypass; the production path isapply_fixes_incremental, which validates with real runtime checkslint/fix.rs:1206@import#[cfg(test)] mod tests— not in any release buildoptions.rs:144unknownsslice non-emptyparser.rs:76,:91depth > 0before decrement inDropparser_helpers.rs:641,:865op.len() == 2;names.len() == name_offsets.len()resolver.rs:1654prompt_bodyisNonesourcemap.rs:775,:786display_names.len() == sources.len()sourcemap.rs:827,:886segments.len() <= MAX_SOURCEMAP_SEGMENTSsegments_dropped); these fire per segment in the compiler's hottest loop — G2 measured the cost of promoting three siblings in that same loopmds-wasm/lib.rs:80Reflect::setsucceededOther counts
is_control_charclaims exactly 78 codepoints (30 C0 + DEL + 32 C1 + U+061C + 14 three-byte format hazards). The planned pin of 78 was correct. T-D1 also asserts width parity and actual substitution for each of the 78.line_len_atstays private and unchanged.parser.rs'sdirective_line_lenwas deliberately not unified with it (see limitations).Gates at
e1a69d0cargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningscargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warningscargo nextest run -p mds-core -p mds-cli1 leaky— a passing test that left a handle open, which nextest flags but does not fail; both single-crate runs report 0 leakycargo test --workspace(CI's command)test result: ok, 0 failedcargo test --doc -p mds-corecargo +1.88 check -p mds-core -p mds-cliRUSTDOCFLAGS="-D warnings" cargo doc -p mds-core -p mds-cli --no-depspkg/pkg-webmds_wasm_bg.wasmnpm test --workspaceson a rebuilt native addon@mdscript/mds304/304 (the source-map goldens), mds-napi 115, bundler-utils 91, rspack 26, vite 24, webpack 21, rollup 17node scripts/verify-no-control-bytes.mjsnode scripts/verify-versions.mjsnpm run test:gatesKnown limitations / out of scope
catch_unwind. A tripped invariant on the CLI is a raw Rust panic: exit 101, default panic message, and inmds watchit takes down the watcher. A hook that rendersmds::internaland acatch_unwindaround the watch rebuild loop are a follow-up; they were not added here because the panic message content is already constrained (no user data) and adding a hook changes the failure presentation of every existing panic site at once.mds::internal(exit 1) through napi/WASM/Python. Same defect, two presentations.SECURITY.md~:83 is inaccurate and was left alone. It saysmds-coreexposes thedebug-panicsfeature; in fact only the three binding crates do. Out of scope for adebug_assert!-only invariants silently degrade in release builds (3 sites) — PF-005 #220/output.rspath helpers fail-open onstrip_prefixedge cases and non-UTF-8 paths #217 docs commit — recorded here, not fixed.mds lint <dir>run instead of being one per-file error among many. This is the fail-closed behaviour the issue asked for, but it does mean one bad filename hides the findings of every other file in the tree.build.rs's SMv3filelabel (lossy),fmt.rs's rawdisplay()as a diagnostic file name,lib.rs's stem-less label,lint.rs's<file>sentinel, the twocurrent_dir()fallbacks (canonicalize_out_dirstill fails open — a relative--out-diranchors at"."), andwatch.rs'sgraph_keyfallback. Turning an infallible helper fallible changes every caller, which is whycanonicalize_out_dirwas scoped out.directive_line_len(parser.rs) was not unified withline_len_at. Two helpers with near-identical semantics remain.O_NOFOLLOWon the write fd is still deferred (carried over from fix(cli): atomic mds build/watch outputs and .map sidecars via atomic_write_file (removes the spurious metadata warning); empty directory exits non-zero; atomic_write_file contract documented (#227, #225, #204, #226) #385).P-M12and the T-L1 lexical checks are named weak spots, documented above rather than silently accepted.Review observations (self-review, non-blocking)
TextEdit::new/FixLineSpan::range_*checks guard the constructor path only; in-tree lint rules build these types with struct literals (e.g.lint/rules/legacy_interpolation.rs,rules/empty_block.rs,rules/unreachable_branch.rs,rules/unused_function.rs,lint/fix.rs) and bypass the checks — a pre-existing gap thedebug_assert!shared; the promotion is not broader than the constructors.flatten_warning_lives_in_the_write_oracle_only) and its absence is asserted indir_build.rswith a non-empty-stderr control.cfg!(debug_assertions), so CI executes the debug arm; the release arm rests on the local release-profile run (14/14) plus the lexical guard intests/assert_promotions.rs.source_path.rs's property table does not pin the relative-source × unusable-base combination (the case whose anchor changed from basename to root); behaviour is safe (apply_relativestill rejects an escape above the root anchor) and unreachable from the CLI and bindings..devflow/features/mds-lint/KNOWLEDGE.mdsays "the other 11 format hazards" whereis_three_byte_format_hazardcovers 14 — pre-existing wording next to an edited line; the 78-codepoint non-vacuity pin uses the real count.spliced_regionskeeps anif let Someafter theassert!; removing the assert later would silently reinstate a drop-the-region path — residual shape, deliberate under the pinned macro form.Iomessages interpolate the raw root and entry paths (path escapes lint root {root}: {path}), byte-identical in shape to the pre-existingread_source_filerejection; belongs with the path-sink follow-up inventory.canonicalize_out_dirstill falls back to.whencurrent_dir()fails — documented in place, deferred to the path-sink follow-up.Changes
crates/mds-core/srcresolver.rs— site 1 degrades vialine_len_at.resolver/inheritance.rs— site 2 degrades viasuper::line_len_at; site 3 unconditionalassert!+# Panicsrustdoc.lint/diagnostic.rs—neutralize_source_for_renderassert_eq!;TextEdit::new/FixLineSpan::range_inclusive/range_exclusiveassert!+# Panics.evaluator.rs— three source-map cursorassert_eq!.sourcemap.rs—MapBuildercursor-invariant rustdoc.source_path.rs—path_to_unified→Option<String>; step 6b "Anchor validity"; module# Security invariant.fs.rs—NativeFs::source_rootusesto_str;FileSystem::source_root# Contract.resolver_tests.rs—line_len_attest group (+ header note).tests/assert_promotions.rs— new, the T-L1 shape test.crates/mds-cli/srclint.rs—relative_display→Result<String, MdsError>; display-path pre-pass before the sort;LintDirCtx.lint_rootremoved; per-file helpers takedisplay_path: &str.output.rs— newMirroredStem+mirror_stem; the flatten report inoutput_path_foronly; twounwrap_or(source.as_os_str())fallbacks →OsStr::new("output").watch.rs—forgetsplit intoforget_graph+forget; ghost-external-dep prune gated; three invariant comments at theoutput_path_forcall sites.fmt.rs— undecodable-path arm isMdsError::Io.Tests —
crates/mds-cli/tests/{cli_lint.rs, dir_build.rs, cli_fmt.rs}.Docs —
CHANGELOG.md,spec.md(§7.2, §7.5, §7.9),SECURITY.md, and four feature KBs under.devflow/features/.Related Issues
Closes #220
Closes #217
Refs #69 #196