fix: v0.4.0 remediation — dogfooding blockers, bug batch, UX polish, docs sweep - #196
Merged
Merged
Conversation
…, edge-cases 27-29) Co-Authored-By: Claude <noreply@anthropic.com>
…ive_parent in check_symlink)
Path::parent() returns Some("") — not None — for a bare filename like
"hello.mds", so the previous unwrap_or(".") fallback was dead code and
"".canonicalize() failed with file_not_found on every subcommand
(build/check/lint/fmt/watch) when the user passed a bare relative filename.
Extract effective_parent() which maps both Some("") and None to
Path::new("."), use it at the one check_symlink call site (all 11 call
sites heal for free), add unit tests for effective_parent, and add
CLI-level regression tests (build/check/fmt/lint bare-filename from cwd).
Co-Authored-By: Claude <noreply@anthropic.com>
…directory walker (all subcommands) + watch guards
Add is_default_excluded_dir (name starts with "." or "node_modules") and
skip matching directory entries in the RECURSION step of
collect_mds_files_inner. The explicitly-passed root is always processed;
hidden files at the traversed level are still collected. All six walker
callers (build/check/lint/fmt/watch/dir-mode) inherit the exclusion with
zero per-caller changes (PF-004: one code path, not six).
Add is_within_default_excluded_dir(root, path) for the two watch guards:
- handle_fs_event_dir: retain excludes events from excluded subdirs so
npm install writing to node_modules/ never triggers a spurious rebuild.
- process_dir_batch_incremental step 4: paths inside excluded subdirs
that appear in the dep graph get the DD3 treatment (quiet recompile to
refresh dep graph, never emit output for the excluded file itself).
Co-Authored-By: Claude <noreply@anthropic.com>
…n IfBlock, opener-anchored unclosed-block errors (closes #181) Replace Vec<(Condition, Vec<Node>)> elseif_branches with Vec<ElseifBranch> carrying an offset field, add else_offset: Option<usize> to IfBlock, and thread opener_offset into consume_end so unclosed-block syntax errors are anchored at the opening directive rather than EOF. Consumer sites updated (13 files): evaluator, validator, resolver, lint/facts, empty_block, redundant_else, unreachable_branch, structural_eq, parser, parser_tests. Span improvements: - @elseif lint diagnostics (empty-block, unreachable-branch) now anchor at the exact @elseif line rather than the enclosing @if offset - @else empty-body diagnostic uses else_offset when present - Unclosed @if/@for/@define/@message/@block errors point at the opener structural_eq excludes ElseifBranch.offset from comparison — offset is a span annotation, not part of the template's logical identity. Tests added: 13 new tests covering offset capture, else_offset, structural equality invariant, unclosed-block spans, and per-branch diagnostic anchors.
…PartiallyFixed outcome; lint message copy consistency
Adds apply_fixes_incremental (ADR-004 three-tier fix model): batch-first attempt with a single reverify call, falling back to right-to-left per-edit retry that keeps accumulated_source consistent. FixOutcome::PartiallyFixed{source,residual,rejected} surfaces skipped edits. RejectedEdit struct exposes the failing edit + reason. Module-level helpers (count_untargeted_per_rule, regressed_rules) isolate the reverify logic. 7 INC tests cover: nothing-to-fix, overlap-immediate-reject, batch-success-single-call, partial-batch-fail-per-edit-fallback, all-rejected, bounded-call-count, right-to-left-accumulation. api_surface.rs: pin test confirms apply_fixes_incremental signature and PartiallyFixed exhaustive match. unreachable_branch.rs + empty_block.rs: trailing periods on all diagnostic messages (message copy consistency, G3).
…eporting, stdin code frames, subcommand-aware hints
Bug 5 (PF-004): preview_fixes routes through same gated pipeline as apply (reverify gate), eliminating the preview≠apply asymmetry. Bug 6 (ADR-004): plan_and_apply_fixes adopts apply_fixes_incremental — per-file fix batching now falls back per-edit instead of all-or-nothing rejection; FixFileOutcome::PartiallyFixed{new_source,residual} exposes partial results. Bug 4: set_diag_display_path helper rewrites diag.file to the relative display path (not basename) in both stdin and directory modes. Bug 12: overlap surfacing — early return only when plan.edits.is_empty() && !plan.overlap_rejected so overlap_rejected is forwarded to output. Bug 19: stdin code frame — named_source=Some(("input.mds", ...)) populated in report-only and fix paths so human formatter shows annotated context. Bugs 22/23: auto_detect_mds_file and resolve_input take a subcommand: &str parameter; hint text becomes "mds {subcommand} <file>" for build, fmt, check, watch, lint subcommands.
Add 9 integration tests pinning Phase B behavior:
(a) dir JSON distinct paths for same-basename files
(b) dir --fix JSON residuals keyed by relative path
(c) --fix --check on refused fix prints "fix rejected" not "Would fix"
(d) --fix --check on fixable file prints "Would fix" and exits 1, file unchanged
(e) dir --fix --check exits 1 when any file fixable, 0 when none
(f) overlap fixture surfaces rejection (not silent)
(g) PartiallyFixed end-to-end: "1 of 2 fixes applied" in summary
(h) stdin lint diagnostics include code frame with "input.mds" label
(i) --fix/lint hint names correct subcommand (lint vs fmt)
Also fix two implementation bugs exposed by the tests:
- preview_fixes: return PreviewOutcome enum (WouldFix/Rejected/NothingToFix)
instead of Option<String> so --fix --check can surface rejection reason
- FixFileOutcome::PartiallyFixed: add applied_count/total_count fields for
"{N} of {M} fixes applied" summary in all three output paths
And fix per-edit regression check asymmetry in apply_fixes_incremental
(fix.rs): use full targeted_rules set (not single_targeted) for residual
count comparison so baseline and residual exclusion sets are symmetric.
…nificant text in non-compiling sources
RELEASE BLOCKER 3 (ADR-001): mds fmt raised MdsError::FormatterInvariant on sources like
`@if undefined_var:\nx\n@end\n\n` because the R2 rule trims trailing blank lines in the
formatted output while the original token stream retains a trailing Text("\n") token.
`structural_equivalent` saw a count mismatch and returned false, making the gate reject a
perfectly valid formatting operation.
Fix: add `strip_trailing_insignificant_text` that pops tail Text tokens from BOTH token
streams where (a) the offset is outside any raw-content span and (b) `clean_output(text)`
is empty. This is provably zero-change for correct inputs: whitespace-only trailing Text
outside raw-content spans contributes nothing to compiled output and cannot distinguish
correct from incorrect formatting. Interior blank lines (meaningful for R2 invariant
verification) are unaffected because they are not tail tokens.
Recomputes raw-content span offsets for the formatted stream separately (`raw_content`
parameter uses SOURCE offsets which are not reusable against the formatted-stream layout).
Adds 8 unit tests in formatter.rs and 3 integration tests in mds-core/tests/fmt.rs covering
exact bug repro, CRLF variant, multiple trailing blanks, and rejection of real formatting
regressions. CLI gate verified via cli_fmt.rs test (exit 0, no formatter_invariant in stderr).
…matter errors
Add pub fn format_str_named(source, base_dir, file_name) to mds-core.
format_str_with becomes a thin wrapper calling format_str_named("<source>").
File name threading:
- lexer::tokenize(source, file_name) — lex-level Syntax errors (unclosed
interpolation, code fence, frontmatter) now carry the caller-supplied name
in their NamedSource, making miette diagnostics show the actual file path.
- assert_equivalent Syntax propagation arm — parser-level Syntax errors
(unclosed @if/@for/@message/@define/@block blocks, which tokenize cleanly
but fail at compile time) had their src field set by compile_str_collecting_
warnings which uses an empty label. Now rebuild MdsError::Syntax {src} with
NamedSource::new(file_name, source) so the miette render names the file.
CLI (crates/mds-cli/src/fmt.rs):
- Replace format_source with format_source_named(source, base_dir, file_name).
- run_fmt_stdin passes "<stdin>"; run_fmt_file passes path.display().to_string().
- format_one_file (dir mode) computes file_name once; all three eprintln! error
sites are prefixed with "{file_name}: " so directory-mode runs identify which
file triggered each failure.
Tests: 3 integration tests in mds-core/tests/fmt.rs (happy-path parity with
format_str_with; lex-level Syntax file-name; parser-level Syntax file-name) +
2 CLI tests in cli_fmt.rs (single-file path in stderr; dir-mode file prefix) +
api_surface.rs pin for the new function signature.
… all surfaces
Add STRING_SOURCE_MAP_LABEL const and map_source_label() choke-point in
sourcemap.rs; apply in MapBuilder::new and source_index so both the seed
sources[0] and any additional source_index("<source>", ...) calls (S8
function-body attribution, @extends region origins) are rewritten before
they enter the sources[] array.
- lib.rs: use STRING_SOURCE_MAP_LABEL for lint_source so the file-key matches
- mds-wasm/lib.rs: add SYNC comment anchoring DEFAULT_FILENAME to the const
- Rule 1 in relativize_source_path: match "input.mds" || "<source>" (defense-in-depth)
- Tests: source_map_vfs (3 new D1 tests), napi (F-SM1), python (SM-PY-1),
universal JS (U-SM1 updated, W-SM3 cross-backend differential, W-SM3b sourcesContent)
Fixes PF-007 cross-surface divergence (native vs WASM sources[0] label).
… stdin --inline -o - relativize_source_map_fields early-returned when output_path=None, bypassing all relativization and leaking absolute source paths into inline data-URI maps for -o - (stdout) output. Fix: use PathBuf::new() as map_dir when output is stdout so relativize_source_path computes a CWD-relative path (PF-005 / ADR-005). Also remove the false --inline + -o - rejection for stdin builds: there is no "no file to embed the carrier into" problem — the carrier rides in the output stream identically to file-based inline output. Rule 1 update for relativize_source_path (already landed in prior commit) is visible here since build.rs carries both hunks; the unit tests added in this commit exercise the fixed relativize_source_map_fields logic directly. Tests added: - SM-14b: stdin --source-map sidecar uses "<stdin>", not "input.mds"/"<source>" - SM-16a: file --inline -o - produces carrier with no absolute paths - SM-16b: stdin --inline -o - is now allowed (no rejection) - Unit tests: empty map_dir relativizes absolute vs CWD, stdin label, legacy sentinel
…at via EvalContext threading) - Add file/source fields to EvalContext<'a> so all evaluator paths carry attribution metadata; update evaluate(), evaluate_with_map(), evaluate_with_map_seeded(), and evaluate_messages_intrinsic() to populate them (all ~8 resolver.rs call sites updated). - Add MdsError::type_mismatch_at() constructor (mirrors type_error_at pattern). - Add build_type_mismatch() helper that degrades gracefully to spanless when the anchor offset is cross-source or out-of-bounds (guard: !source.is_empty() && off <= source.len() && is_char_boundary(off)). - Add anchor: Option<usize> param to evaluate_condition(); @if and @elseif branches pass Some(block.offset)/Some(branch.offset) so the span points to the directive line that triggered the mismatch. - Syntax label deduped to "syntax error occurred here" (was "{message}"). - ArityMismatch gains #[help] text for discoverability. - name_collision upgraded to name_collision_at in ExportDirective::Wildcard, resolve_alias_import, and resolve_merge_import where offset is in scope. - MSG_MODE_SOURCE_MAP_WARNING const deduplicates two identical resolver warning strings; wording reworded to be surface-neutral. - Tests: 3 new virtual_fs tests (D2 if/elseif span, cross-source degrade), 1 CLI test (miette code frame), Python test, napi test.
Unclosed `{` in an interpolation (`{expr`) now surfaces the hint:
"to include a literal `{`, escape it as `\{`"
aligned with the existing hint in parser_helpers.rs (invalid interpolation
expression error). Helps users who typed a literal `{` rather than
opening a `{var}` interpolation.
…er compile The two emission sites for the messages-mode source-map degradation warning are on mutually exclusive code paths (string-source vs. file-source), so the warning is naturally emitted at most once per compilation. This test makes that invariant explicit: source_map_messages_mode_degrades_to_none now asserts matching_warnings.len() == 1 in addition to the presence check.
Replace the messages-mode degradation warning with the locked design copy: "source maps are not supported for messages-mode templates (@message blocks); no source map will be generated" The old wording used Rust-isms ("source_map will be None for this compilation") that leak implementation details to CLI/JS/Python users. The segment-cap format strings in resolver.rs are also updated to the same surface-neutral phrasing for consistency. Update all test assertions that matched the old substrings: - source_map_vfs.rs line 891: filter updated to new locked wording - source_map_vfs.rs line 968: drop stale OR fallback (segment-cap check already anchors on "segment cap") - test_source_map.py: replace "source_map" (underscore) with "source map".lower() + "not supported" — both present in new message napi F-SM4 (/source.?map/i) and JS U-SM4 (/source.?map/i) already matched surface-neutral wording and need no change. Co-Authored-By: Claude <noreply@anthropic.com>
…Options split
Closes PF-004: the universal @mdscript/mds wrapper previously silently
dropped unrecognised option keys while the napi/WASM backends would have
rejected them, leaving a silent divergence across execution paths.
- Add METHOD_KEYS table mapping each public method to its allowed key set
- assertKnownKeys() validates options before forwarding, throws
Error { code: 'mds::invalid_options' } with phrasing matching
format_unknown_keys_error exactly (single/plural forms)
- Split CheckOptions { vars? } from CompileOptions { vars?, sourceMap?,
sourcesContent? } — check/checkFile do not accept source-map options
- Apply assertKnownKeys in node.ts, browser.ts at all 7 compiler-facing
methods; update backend/native.ts and backend/wasm.ts accordingly
- Add 14 unit tests (options-validation.spec.mjs) covering every method,
including message-parity assertion (U-OV-14)
…nt, --vars errors name file Three UX polish items: - `mds check` summary: "N passed, M failed" instead of "N checked, M failed" so success reads as an assertion, not just a count - `mds fmt --check` summary: adds "N unchanged" alongside "N would reformat, M failed" so the operator knows total files examined - `--vars <file>` JSON parse and type errors now include the file path in the message (e.g. "vars.json: invalid type: sequence") — previously the error gave no context about which file was at fault Tests: update dir_build.rs assertions for new wording; add dir_check_summary_includes_unchanged_count (cli_fmt.rs) and two vars-file error-naming tests (cli_build.rs). All 1757 nextest tests green.
…i build script Two independent test-infrastructure fixes: - conftest.py: prefer the freshest of target/release/mds and target/debug/mds by mtime, so a fresh debug build is not shadowed by a stale release artifact. Previously the code iterated ["release", "debug"] and returned the first existing one, always preferring release regardless of age. - crates/mds-napi/package.json: add "build": "napi build --release --no-js" so `npm run build -w @mdscript/mds-napi` works for local development. Omitting --platform produces mds-napi.node (the filename that index.js loader and index.spec.mjs both expect). --no-js preserves the hand-maintained loader. CI release.yml is unaffected (has its own cross-compile commands).
…ourceMap+lint API docs on all surfaces - spec.md: str( → string( cast function (str() does not exist); remove nonexistent @# comment syntax from example and add "no comment syntax" note; unclosed code fence is a hard error (mds::syntax); §4.9 module-system output now includes emitted frontmatter (standalone templates preserve frontmatter byte-for-byte in output) - README.md: --source-map sidecar is <output>.map not <output>.md.map; fmt stdin demo replaced with an input that actually changes (printf @if trailing spaces trimmed by R4) - main.rs: align fmt after_help demo with README; update -q/--quiet help text to mention lint diagnostic suppression behavior - crates/mds-napi/src/lib.rs: doc comments on compile/compileFile add sourceMap/ sourcesContent options; check/checkFile docs clarify source-map options not accepted - crates/mds-napi/README.md: expand API section with sourceMap, lint, lintFile, lintVirtual - crates/mds-python/README.md: API table adds source_map/sources_content params and lint/lint_file/lint_virtual rows with LintResult notes - packages/mds/README.md: add lint APIs, CompileOptions/CheckOptions split, strict unknown-option rejection (mds::invalid_options), sourceMap label "input.mds" note - packages/mds-wasm/README.md: document sourceMap/sourcesContent and lint options Co-Authored-By: Claude <noreply@anthropic.com>
Add comprehensive entries for all behavior changes from the six-agent dogfooding campaign (Phases A–F, PR #196): Breaking: unknown-option rejection in @mdscript/mds wrapper; CheckOptions/CompileOptions split; sourceMap sources[0] label "<source>" → "input.mds"; directory walker now excludes hidden dirs + node_modules; mds check summary "passed/failed" (was "checked"). Added: partial fix "N of M applied"; type_mismatch spans; name-collision + unclosed- block spans; \{ hint on unclosed brace; ArityMismatch help; per-branch elseif offsets; format_str_named; fmt --check unchanged count; napi build script. Fixed/Changed: inline stdout source-map absolute-path leak; stdin --inline -o - allowed; lint JSON dir-mode full-path keys; lint --fix --check honest gated preview; overlap surfaced; fmt errors name file; --vars errors name file; stdin lint code frames; bare filename builds; formatter_invariant false positive on trailing blanks; lint message copy periods; messages-mode source-map warning deduped+reworded; syntax label deduped. Also: fix str( → string( in v0.4.0 migration bullet; fix sidecar name in Source Map v3 entry (<output>.md.map → <output>.map). Closes #181 Co-Authored-By: Claude <noreply@anthropic.com>
Multi-line wrap of eprintln! at lines 334–336 in run_fmt_directory function. No logic change. Co-Authored-By: Claude <noreply@anthropic.com>
fix.rs: replace batch_ok/let-_ dead-variable hack with idiomatic if-let-Ok — the match always evaluated false when not returning early; the let-_ suppressed a lint instead of expressing intent. lint.rs: strip "Bug N:" / "bug N fix:" history-encoding prefixes from twelve inline comments. The bug-tracking IDs are meaningless to future readers (they reference the handoff document's internal numbering); the explanations that follow them are preserved intact.
…ed to child source The @extends flat-evaluate paths (process_module_intrinsic_opts non-source-map branch and process_module_extends) evaluate a final_body spliced from base-skeleton nodes (base-relative offsets) and child block overrides (child-relative offsets) against a single ctx.source (the child). A base-relative @if/@elseif offset that lands within the child source at a char boundary anchored the type_mismatch span onto the child's @extends line — a foreign-source mis-attribution that violates build_type_mismatch's own contract (ADR-005: degrade rather than mis-attribute). The validator already validates these regions per-origin; only the evaluator still flattened. ctx.source/ctx.file in EvalContext are consumed solely by build_type_mismatch, so passing empty file/source on the two markdown @extends flat-evaluate sites degrades an inherited-condition type_mismatch to spanless instead of mis-attributing. The source-map @extends branch already attributes correctly via evaluate_regions_with_map (per-region origin). Non-@extends paths (process_module) are untouched — their offsets and ctx.source share one origin. Failing-first test: extends_base_skeleton_type_mismatch_span_not_misattributed_to_child.
… in fix pipeline Delete the placeholder test `d2_type_mismatch_cross_source_extends_degrades_spanless`: its own comment admitted it did not exercise the real @extends cross-source path (no VFS, no @extends, no span assertion). The actual contract — base-relative offsets degrade to spanless rather than mis-attributing to the child source — is fully covered by `extends_base_skeleton_type_mismatch_span_not_misattributed_to_child`. Replace the `.expect()` in `apply_fixes_incremental` (~L599) with a `match` that returns `FixOutcome::Rejected` when `last_residual` is `None` despite `accepted_count > 0`. The invariant holds in practice (reject_reason is None only when reverify_result is Ok), but business logic must not panic — fail-closed semantics are preserved and all outcomes for valid inputs are byte-identical. Co-Authored-By: Claude <noreply@anthropic.com>
The fix_json_stdin_is_usage_error_exit_2 test spawns a child process with --fix --format json on stdin, which is a usage error the process detects before reading any input. On Linux the child exits fast enough that write_all() on stdin returns Err(BrokenPipe) before completing, causing the unwrap() to panic. Match the established pattern from cli_fmt.rs (line 264): use `let _ = ` to discard BrokenPipe, then call wait_with_output() to get the exit code.
This was referenced Jul 18, 2026
Open
This was referenced Jul 18, 2026
…r helper (PF-004) Add `output::eprint_error(report: miette::Report)` as the single structural enforcement point for sanitized stderr rendering in directory-mode loops. All per-file error handlers MUST use this helper — the sanitizer cannot be forgotten on any future parallel path (avoids PF-004 recurring). Sites fixed: - lint.rs:1225 lint_one_file_human read_source_file error (UNGUARDED) - lint.rs:1238 lint_one_file_human mds::lint error (UNGUARDED - reported failure) - fmt.rs:242 format_one_file read_source_file error (UNGUARDED) - fmt.rs:251 format_one_file format_source_named error (UNGUARDED, user content risk) - fmt.rs:259 format_one_file print_diff write error (UNGUARDED) Already-guarded paths unchanged: - main.rs:232 last-resort boundary (single-file build/check/fmt/lint propagation) - main.rs:348 run_check_directory per-file handler - build.rs:1506 run_build_directory per-file handler - lint.rs:1389 emit_analysis_failure_json_or_stderr (lint single-file path) Test coverage extended to all 8 cells of subcommand x mode matrix: - build x single-file, build x directory (existing) - lint x single-file (existing), lint x directory (NEW) - check x single-file (NEW), check x directory (NEW) - fmt x single-file (NEW), fmt x directory (NEW) All tests byte-verified: 0 raw 0x1B bytes in stderr or stdout for every cell. Miette box-drawing, carets, and \\n/\\t preserved intact. ESC bytes in source content rendered as \\uXXXX literals in the code frame. 606/606 tests pass; cargo clippy -D warnings clean; snyk_code_scan 0 issues. Co-Authored-By: Claude <noreply@anthropic.com>
…basename A `..`-chain relative path produced by `relative_path()` for a source file outside the map directory reconstructed the full absolute path, leaking USERNAME and directory layout into inline/sidecar source maps shipped as published artifacts. The never-absolute guard only rejected leading `/` and Windows drive forms; `../../../Users/alice/...` sailed through. Fix: treat any result starting with `../` (or exactly `..`) the same as an absolute path — degrade to the bare filename. The check is applied AFTER both Rule 3 (absolute → relativized) and Rule 4 (already-relative pass-through) outputs, making enforcement uniform across both code paths (avoids PF-004). Regression test `sm_sec3_dotdot_escape_falls_back_to_basename` covers: - sidecar map: deep output dir in a separate tempdir from the source - inline stdout: process CWD set to the deep output dir Both modes assert no `../` components and no src-tempdir name in sources[]. applies ADR-005, avoids PF-004, avoids PF-005 Co-Authored-By: Claude <noreply@anthropic.com>
…ncate-then-write data loss mds fmt used bare std::fs::write (truncate-then-write), leaving the user's only copy of their source file truncated on crash or full-disk mid-write. The sibling mds lint --fix path already uses atomic_write_file (temp file + rename) and commit c5aa086 hardened it to ALSO preserve Unix file permissions (tempfile::Builder defaults 0600; a rename would silently turn 0644 → owner-only). Fix: make atomic_write_file pub(crate) in lint.rs, then use it for BOTH write sites in fmt.rs (single-file run_fmt_file and directory-mode format_one_file). A copy would recreate the same permission drift — one shared helper prevents it. Regression tests (unix-only): fmt_single_file_preserves_mode_0644 and fmt_directory_mode_preserves_mode_0644 create a 0644 file, format it, and assert the mode is still 0644 — locking in the guarantee c5aa086 delivered for lint and ensuring fmt can never regress on this the same way. Co-Authored-By: Claude <noreply@anthropic.com>
…DR-005 / PF-004 / PF-005) Step 0: remove wrong `escapes_base` disjunct from `relativize_source_path` (build.rs) that blocked valid map-relative `../src/a.mds` results. The guard was correct in intent but the implementation was wrong; the real fix is in source_path.rs. Step 1: new module `crates/mds-core/src/source_path.rs` — single public function `relativize_source(source, base, root)` implementing a 10-step guard algorithm. Closes three bypass classes missed by prior guards: - backslash-on-Unix: `..\..\` separator not unified before escape check - leading dot-slash: `./../../etc/passwd` resolves through leading `.` - interior dot-dot: `/proj/a/../../etc/passwd` normalizes outside root Step 2: 19-test matrix, RED-confirmed before implementation (3 bypass rows and 7 others failed with stub; all 19 GREEN after full algorithm). Step 3: `source_root() -> Option<String>` defaulted method on `FileSystem` trait (returns `None`); NativeFs override returns `root_dir.get()`. 4 unit tests added to `fs.rs`. Step 4: `source_map_base: Option<PathBuf>` field on `CompileOptions` (default `None` = root-relative). 31 struct literal sites updated with `..Default::default()` across all binding crates and tests. Step 5: apply `relativize_source` at BOTH `b.finalize(...)` sites in `resolver.rs` (extends + standalone), unconditionally (PF-005 — no debug_assert!, no opt-in flag). Single choke-point per PF-004. Steps 6–11 (CLI migration, golden updates, CHANGELOG, ADR-005 amendment) deferred to Coder B. Snyk: SNYK-CODE-0006 (Rust not supported by Snyk Code). Tests: 1824/1824 nextest, 36/36 doc tests, fmt clean, clippy clean.
…ps 6–8 (ADR-005 / PF-004 / PF-005)
Step 6 — CLI migration:
- Delete `relative_path` and `relativize_source_path` from build.rs; their
behaviour is now subsumed by `mds_core::source_path::relativize_source`
at the single choke-point wired in Phase A (PF-004).
- Add `compute_source_map_base` to compute the map-file directory BEFORE
constructing `CompileOptions`, absolutizing relative `-o` paths against
CWD (mirrors the old Rule-3 absolutization from `relativize_source_path`).
- Set `source_map_base` in `CompileOptions` at all three call sites: stdin
path, file path, and directory mode.
- Shrink `relativize_source_map_fields` → `apply_source_map_file_label`,
keeping only its two genuinely CLI-only jobs: setting `sm.file` to the
output basename and relabeling `STRING_SOURCE_MAP_LABEL` → `"<stdin>"`.
- Restructure directory loop from loop-invariant `opts` to per-file
construction (per-file `source_map_base` from `output_base_no_ext`).
Step 7 — prove Decision 1 landed:
- Move 5 unit tests from build.rs to source_path.rs with adapted
`relativize_source(source, base, root)` signatures; verify the
discriminating pair (`core_rule_map_relative` / `core_rule_source_outside_root`)
already exists and is green.
Step 8 — strengthen integration assertions:
- Add shared `assert_source_is_contained(s, root, base, forbidden)` helper
to cli_source_map.rs.
- Replace 4 weak `!starts_with('/')` assertions (SM-3, SM-DET, SM-16a,
SM-16b) with the new helper, which enforces: no verbatim prefix, no
drive-qualified form, not absolute, lexically contained in root, no
forbidden component.
- `sm_sec3_dotdot_escape_falls_back_to_basename` is NOT touched — passes
unmodified (ADR-005).
All 1822 tests pass; clippy -D warnings clean; fmt clean; 36 doc tests pass.
…full path in errors (steps 9-11) Step 9 — correct atomic write helper (issue #25, ADR-005, PF-004): - Move atomic_write_file from lint.rs to output.rs so both fmt and lint --fix share the same write path (closes PF-004 parallel-path gap) - Rename temp prefix from .mds-lint-fix- to neutral .mds-tmp- - Add path.display() to all 5 non-persist error paths (were generic) - Replace tmp.flush() (no-op on unbuffered File) with tmp.as_file().sync_all() for crash durability - Mask st_mode with & 0o7777 before Permissions::from_mode to strip file-type bits — prevents EINVAL on some kernels - Update callers: lint.rs and fmt.rs now import from output.rs - Update output.rs module header to list eprint_error and atomic_write_file Tests added (cli_lint.rs): - lint_fix_preserves_mode_0644: --fix preserves Unix mode 0644 via atomic write - lint_write_failure_includes_filename_in_stderr: read-only parent triggers temp-file error that now includes the target filename Step 10 — cross-surface differential parity tests (PF-007): - source-map.spec.mjs: add imports for fs/promises, path, os, buildModulesMap - V-SM1: WASM compile() with explicit filename + empty modules produces same sources[] as native — gates modules-map code path parity - CF-SM1: native compileFile vs WASM-via-buildModulesMap produce identical sources[] using a temp fixture with .mdsroot — catches the live PF-007 instance where universal @mdscript/mds returned different sources[] per backend - SM-PY-10 comment: update "absolute path" -> "root-relative"; add startswith('/') assertion to gate the choke-point fix - F-SM7 (napi): update comment; add sources[0] not-absolute assertion Step 11 — docs: - CHANGELOG: Security (sources[] no longer leak absolute paths), BREAKING (CompileOptions.source_map_base), Changed (napi/Python root-relative sources[]), Fixed (map-relative -o build/ path) - ADR-005 amendment: refine "never absolute" to two-level rule (map-relative when source_map_base set; root-relative otherwise); cite PF-004, PF-005, PF-007
This was referenced Jul 19, 2026
Closed
…ink + PF-004 guard
Three coordinated fixes to pass the CF-SM1 differential parity test
(PF-007: native compileFile and WASM-via-buildModulesMap must produce
identical sources[]):
1. packages/mds/src/backend/wasm.ts — compileOpts() now forwards
`filename` and `modules` from the caller when present via the new
internal _WasmCompileInput interface. The public CompileOptions type
is unchanged; the WASM compile() path previously always used
DEFAULT_COMPILE_OPTS.filename ('input.mds'), causing WASM to emit
sources[]=['input.mds'] while native emitted ['hello.mds'].
2. packages/mds/src/util/module-scanner.ts — buildModulesMap()
canonicalizes only the PARENT directory via realpath(), not the full
entry path. Eliminates false-positive "possible symlink" errors on
macOS where /var is a system symlink to /private/var (realpath of
/var/folders/…/hello.mds differs from resolve(hello.mds) even for a
regular file). File-level symlink detection is preserved: the final
path component is NOT canonicalized, so O_NOFOLLOW / post-open
realpath checks still catch file-level symlinks (U-SM7 continues to
pass). Mirrors NativeFs::check_symlink (Rust): canonicalize parent,
join filename, then inspect the file.
3. crates/mds-core/src/resolver.rs — defense-in-depth guard at both
finalize sites in process_module_intrinsic_opts: if source_root() is
None and base_dir is known, call set_root(base_dir). Prevents a
future alternate code path (PF-004 shape) from bypassing root
establishment and leaking absolute paths. No-op for VirtualFs (its
source_root() always returns None).
All validation gates: cargo nextest (590+ tests), cargo fmt --check,
cargo clippy -D warnings, cargo test --doc, npm test --workspaces
(CF-SM1 now passes), pytest 199/199, verify-versions — all EXIT=0.
ADR-005 (amended 2026-07-19): PF-004 parallel-path guard, PF-005
runtime choke-point, PF-007 differential CF-SM1 test now green.
…absolute in every branch Self-review findings on the four source-map choke-point commits (f00b1f6, e661002, 168a58a, 4d3ff6b). 1. source_path.rs — the `root = None` branch (VirtualFs / WASM) ran only a leading-`..` escape check and returned the unified path verbatim, so an absolute or drive-qualified key was echoed back: `/Users/a/p/x.mds` became `Users/a/p/x.mds` (leading slash silently stripped) and `C:\secret\foo.mds` passed through unchanged. That contradicted the two invariants the module documents ("never absolute", "never drive-qualified") on the one branch that is reachable by default: `FileSystem::source_root` is a DEFAULTED method returning None, so any impl that does not override it lands here (PF-005 — an invariant that holds only where a root happens to be established is not an invariant). Not reachable as a host-path disclosure on any shipped surface today: the CLI, napi and Python all run NativeFs with a root established at the entry point, and WASM keys come from buildModulesMap, which emits project-root- relative slash paths. Fixed as defence in depth, not as a live leak. The ADR-005 byte-parity clause is preserved: virtual keys are relative by construction, so neither guard can fire on the shipped WASM path. Pinned by root_none_relative_keys_are_unchanged_by_the_absolute_guard. 2. build.rs — compute_source_map_base documents "the result is always absolutized", but the mds.json output_dir branch returned config_dir.join(output_dir) unwrapped. config_dir is canonical in practice so this was incidentally correct; `abs()` makes it structural. A relative base fails core's root-containment check and silently demotes map-relative emission to root-relative — sources[] that no longer resolve from the map file's directory, with no error. 3. build.rs — compute_source_map_base had no direct unit tests. Added three covering the always-absolute invariant across all seven output modes, the PF-006 bare-filename case, and output-directory tracking. Verified unchanged CLI output for dir-mode, -o subdir, default-beside-source and output-outside-root. Gates: nextest 1830/1830 (1824 + 6 new), cargo test --doc 36, npm 518/518, pytest 199 passed / 6 deselected (-m, per PF-008), fmt clean, clippy -D warnings clean, verify-versions clean, cargo publish -p mds-core --dry-run clean.
1. fs.rs — strengthen native_source_root_no_marker_falls_back_to_entry_dir from ancestor check to exact equality (assert_eq! vs starts_with). A regression that walked the root up to /tmp or / would have passed the old assertion; the new one rejects any root wider than the entry dir. macOS /var→/private/var canonicalization applied before comparison so the test is not flaky across platforms. 2. source-map.spec.mjs — add CF-SM2: all four surfaces (napi, WASM, CLI, Python) produce identical sources[] for a nested @import fixture with .mdsroot marker, src/ entry, and partials/ sibling. CLI is invoked with -o <dir>/out.md so its map anchors at the project root, matching binding output byte-for-byte (avoids PF-007 per-surface-golden anti-pattern). 3. source_path.rs — add round-trip assertion to property_outputs_never_absolute_or_drive_qualified: for every non-sentinel output with root=Some, normalize(effective_b.join(out)) must be inside root. This catches a regression in the production guard at lines 191-197 that the existing never-absolute/never-drive-qualified/non-empty checks could not express. Helper lexical_join added in test module. 4. source_path.rs — delete orphaned section header "// ── Property test over the full matrix ──" (transition residue). 5. lint.rs — fix print-before-write: emit "Fixed: <file>" AFTER atomic_write_file succeeds, not before, at both the single-file path (lint.rs:762) and the directory-mode path (lint.rs:1227). Mirrors the fmt.rs:284 pattern. Two regression tests added: lint_fix_write_failure_does_not_print_fixed_label_{single_file,directory} — on write failure stderr must NOT contain "Fixed:". Closes PF-007 (per-surface-golden anti-pattern) for the four-surface compileFile differential.
CF-SM2 shells out to the mds CLI and the Python binding to compare cross-surface sources[] parity. The JS CI job was running npm test without ever building the CLI binary, causing CF-SM2 to fail on all three OS runners with 'mds CLI binary not found'. The Python leg was separately soft-skipping (console.warn + pass) in CI whenever the binding was absent, silently breaking the four-way differential gate. Fix: - Add 'cargo build -p mds-cli' step before 'npm test' in the js job so findMdsCli() auto-discovers target/debug/mds on all three runners. Rust toolchain + cache are already present for the native addon build. - Add actions/setup-python@v5 + 'pip install ./crates/mds-python' so the Python binding is available as the fourth parity surface. - Export MDS_PYTHON_BIN to the pip-managed interpreter so findPythonForMdscript() resolves it cross-platform (bin/ vs Scripts/). - Add MDS_PYTHON_BIN env-var support to findPythonForMdscript() so CI can explicitly point at the installed interpreter (analogous to MDS_CLI_BIN for the CLI surface). - Hard-fail CF-SM2 when process.env.CI is set and the Python interpreter is not found, instead of warning and passing. A parity gate that silently does not run is worse than no gate (avoids PF-007). Co-Authored-By: Claude <noreply@anthropic.com>
The Python subprocess previously did sys.path.insert(0, pyModulePath) to prepend the source crates/mds-python/python/ directory. With maturin develop the compiled extension (_mdscript.so) is in the source tree so this worked, but with pip install ./crates/mds-python it lands in site-packages: the source __init__.py was found first in sys.path while _mdscript was not there, producing ModuleNotFoundError on all three CI runners. Drop the sys.path manipulation entirely and import mdscript from the standard path. This works for both install paths: - pip install ./crates/mds-python → module in site-packages, importable - maturin develop → editable install registers source tree in site-packages Also remove the now-unused pyModulePath variable and simplify the spawnSync call (entryPath is now argv[1], not argv[2]). Co-Authored-By: Claude <noreply@anthropic.com>
…ied (Windows CF-SM2) On Windows, `std::fs::canonicalize()` returns verbatim UNC paths of the form `\\?\C:\...`. `NativeFs::source_root()` forwards these via `display()`, so the root string arriving at `relativize_source` is `\\?\C:\<tmpdir>`. `path_to_unified` only applied backslash→slash, yielding `//?/C:/<tmpdir>`. `normalize_abs` then produced `["?", "C:", "<tmpdir>", ...]`, while the source string (after step 3's verbatim-prefix strip) normalized to `["C:", "<tmpdir>", "src", "entry.mds"]`. The containment check (`"?" != "C:"`) always failed, causing every source-map entry to degrade to its bare basename — `"entry.mds"` instead of `"src/entry.mds"` — breaking CF-SM2 napi vs WASM parity on windows-latest. Fix: apply the same verbatim-prefix strip in `path_to_unified` that `relativize_source` applies to source strings (step 3), so root and source component lists are always comparable. Adds two regression tests: `verbatim_root_relativizes_source_correctly` and `verbatim_root_nested_import_relativizes` — both cover the CF-SM2 scenario (source + nested import under a verbatim UNC root).
dean0x
added a commit
that referenced
this pull request
Aug 20, 2026
regression-01 (F-10): Correct stale #196 entry — check() accepts { vars?, basePath? } while checkFile() accepts { vars? } only via CheckFileOptions (basePath?: never). TS implementers must narrow check to CheckOptions, checkFile to CheckFileOptions. Fold in testing-04 residual: warn that diag.span !== undefined is no longer a sufficient guard now that help/span are | null. documentation-01 (F-12): Add missing legacy-interpolation bullet to the 10-rule catalogue — the rule was enumerated in code but absent from the CHANGELOG list, leaving a count of 9 that contradicted the "10-rule" headline. documentation-03: Add ### Removed section for packages/mds/src/index.ts deletion. The file was not in the package exports map so no supported import path is affected; notes the seven internal backend types that were only reachable via that path. regression-07: Replace vacuous "TypeScript interface implementers" warning with a factual note that MdsBaseBackend/MdsNodeBackend are internal types defined only in the deleted index.ts and never reachable via @mdscript/mds published imports. documentation-08: Move MdsError::source_name() and MdsError::is_string_source() from the ### BREAKING interpolation section to ### Added — both are purely additive public methods. Co-Authored-By: Claude <noreply@anthropic.com>
dean0x
added a commit
that referenced
this pull request
Aug 20, 2026
… consistency issues Consistency-07/21: move the six BREAKING sections to lead the [Unreleased] section (matching the [0.3.0] precedent), ordered by blast radius: 1. lint JSON wire contract (#202, #203, #211) 2. Error/lint messages \uXXXX literals (#176) 3. Options validation, directory walker, source-map labels, check API (#196) 4. File-method basePath rejection, TS option types, WASM basePath (#180, #213) 5. Interpolation syntax {x} -> {{x}} (#236) 6. Strict cross-type comparisons, @extends FM, interior-verbatim, filesystem (#146, #150, #151, #152, #154) Consistency-07 secondary: add inline issue refs to the three BREAKING headings that previously pushed refs only into #### sub-headings. Consistency-08: fix "see the BREAKING subsection below" in ### Fixed — the referent is now above; replace with anchor link. Consistency-15: replace the sole ' -- ' (line 334 before reorder) with an em dash, matching the 130 em dashes used elsewhere in the file. Documentation-13: soften "The stdin source identity is **always** <stdin>" to "(CLI only — see below)" to prevent skimmers from missing the binding- surface qualification 14 lines later. Regression-02: append "Fixed: <path> and Would fix: <path> now emitted in JSON directory mode" to the lint JSON wire-change ledger — these eprintln! calls in lint.rs:1555/1632 break zero-stderr CI assertions silently. Regression-05: add a ### Fixed bullet noting that LintDiagnostic.to_dict() now conditionally includes "line"/"column" in the span object, and LintResult.files[] now parses these fields; no built-in rule sets them so live-lint output is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Aug 26, 2026
dean0x
added a commit
that referenced
this pull request
Aug 27, 2026
Closes sub-item #34 of issue #219 (Rust minor cleanups from PR #196 review). The owner confirmed this is the intended site; the original issue text named a symbol `finalize_sub` that has never existed in repo history — the correct site is `finalize` in MapBuilder. The underflow (fm_prefix_len > final_body.len()) is unreachable in practice: fm_prefix_len is the byte length of a prepended frontmatter block, which cannot exceed the total final body length under normal compiler flow. The saturating_sub is therefore a zero-cost latent-defect guard — if the impossible case were ever hit, it saturates to 0, which causes clamp_trailing_trim to drop all segments and the existing body_clean_len == 0 early-return to produce an empty SourceMap, a correct and benign result rather than a panic. Behaviour for all currently reachable inputs is unchanged. Note: #219 remains open (sub-item #28 is still outstanding).
This was referenced Sep 14, 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.
Context
Six-agent dogfooding campaign (2026-07-17) found 3 release blockers plus a bug/UX/docs
batch while using MDS v0.4.0 in anger. This single consolidated PR fixes all of it
pre-release. Related issues: #172 (lint block-span --fix, deferred), #180 (follow-up
design questions).
Closes #181
Release Blockers (3)
Bare relative filename crash (e.g.
mds build foo.mdsfromfoo.mds'sdirectory) —
effective_parentinfs.rsmapped an empty parent path to.instead of producing a bad-path error; this caused panics on some platforms.
sourceMap.sources[0]cross-surface divergence — string-source compilesproduced
"<source>"on napi/Python/WASM but"input.mds"on the CLI, makingcross-surface source-map consumers unreliable. Unified to
"input.mds"via amap_source_labelchoke-point insourcemap.rs.mds fmtformatter-invariant false positive on trailing blank lines — templatesending with
@if … @end\n\nwere rejected by the safety gate withmds::formatter_invariantafter a perfectly valid format run.Key Changes
Phase A — Bare filename, walker exclusions, ElseifBranch AST
effective_parenthelper infs.rs; all subcommands now handle bare filenames..git,.venv, …) andnode_moduleson all subcommands (build/check/watch/fmt/lint).
ElseifBranch { condition, body, offset }struct replaces tuple form; lint@elseifdiagnostics now anchor at the branch span.Phase B — Lint core: partial apply, preview gate, display-path, overlap surfacing
apply_fixes_incrementalinlint/fix.rs: batch-first, right-to-left per-editfallback; reports
"N of M fixes applied"for partial results.lint --fix --check/--diffare now honest gated previews (reverify before showing)."input.mds"source label.Phase C — Formatter gate +
format_str_namedstructural_equivalenteliminates theformatter-invariant false positive on trailing blank lines.
format_str_named(source, base_dir, file_name): new public API that threads acaller file name through the formatter;
mds fmterrors now name the file.Phase D — Source-map accuracy + error quality
--source-map --inline -o -(stdout) relativizessources[]entries against CWD, eliminating literal absolute-path output.../-chain reconstruction (which could still reconstruct the absolute path from a deep CWD) was found during review to remain live — HIGH severity — and was closed in the post-review remediation described below. Stdin--inline -o -is now allowed (was incorrectly rejected).type_mismatcherrors carry spans viaEvalContextthreading.name_collision_atupgrades inExportDirective::Wildcard, alias, and merge paths.mds::syntaxlabel deduped ("syntax error occurred here").ArityMismatchhelp text added.\{escape hint on unclosed brace.Phase E — Wrapper strict options, check summary,
--varsUX@mdscript/mdswrapper rejects unknown option keys (mds::invalid_options).CheckOptionssplit fromCompileOptions; check/checkFile no longer acceptsource-map options.
mds checksummary:"N passed, M failed"(was"N checked").mds fmt --checksummary adds"N unchanged"count.--varsJSON errors include the file path.conftest.pypicks the freshest CLI binary by mtime.Phase F — Docs sweep + CHANGELOG
spec.md:str(→string();@#nonexistent comment syntax removed; unclosedcode fence documented; §4.9 output example now includes emitted frontmatter.
README.md: sidecar name corrected (<output>.map); fmt stdin demo updated.main.rsafter_help: same fmt demo fix;--quiethelp text clarified for lint.sourceMap/sourcesContentand lint API documented.str(→string()in migration bullet;sidecar name fixed in Source Map v3 entry.
Verification
All gates green at tip
f55f4d0:cargo nextest run --workspacecargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningsnode --test crates/mds-napi/__test__/index.spec.mjspytest crates/mds-python/tests -q -m "not perf"npm test -w packages/mdsvite/rollup/webpack/rspack/bundler-utils)crates/,packages/mds/src)Test Plan
mds build foo.mds(bare filename from file's directory) — should succeedcompile(src, {sourceMap:true}).sourceMap.sources[0]— should equal"input.mds"on napi, WASM, Pythonmds fmton file with trailing blank line after@end— should exit 0mds checkon directory — summary should say "N passed, M failed"mds lint --fix --check— should print "fix rejected:" for overlapping fixes@mdscript/mdscompile(src, {unknownKey: true})— should throwmds::invalid_optionsmds build template.mds --source-map --inline -o -— no absolute paths in stdoutPost-Review Remediation (2026-07-18, ~20 additional commits)
A full code review of this PR (13 reviewer agents, 9 focus areas) found 91 issues.
57 were fixed in a resolve pass across approximately 20 additional commits. 29 were
deliberately deferred as tracked follow-ups (FIX_SEPARATE / TECH_DEBT). 1 HIGH security finding —
sources[]..-chain path reconstruction — was escalated and fixed by explicit maintainer decision: arelativize_sourcechoke-point incrates/mds-core/src/source_path.rsnow applies project-root containment at bothresolver.rsfinalize sites; the napi and Python surfaces additionally moved from absolute paths to root-relative paths; Windows verbatim UNC prefix handling was added. 3 were confirmedBY_DESIGN.
User-visible behavior changes introduced by the resolve pass
Silent gate bypass fixed (
mds fmt/mds lint --fixon relative paths)mds fmt <bare-filename>was swallowing genuinemds::syntaxerrors and printing"Unchanged" (exit 0), because a relative
base_dircausedcheck_symlinkto failsilently, falling through to a weaker structural fallback instead of running the
compile-equivalence gate.
mds lint --fix <relative-path>was likewise rejectingevery fix with no diagnostic and exiting 2. The original "bare filename" blocker fix
did not cover the paths that compute a
base_dir;resolve_base_dirnow canonicalizesto an absolute path before those checks run.
Directory walker all-excluded now exits non-zero
A directory whose
.mdsfiles all lived under hidden directories (.git,.venv,.claude) ornode_modulespreviously produced "No .mds files found" and exit 0 — aCI gate passing green having validated nothing. The walker now exits non-zero and emits
a distinct skip-count message that is not suppressed by
--quiet.checkFile/lintFilereverted to synchronous throwsBoth had been made
asyncduring this PR's development, silently changing the publicerror contract (sync
throwbecame an unhandled rejection). The pre-existing contractis restored;
asynckeywords removed.@mdscript/mdswrapper now acceptsbasePathoncompile/checkThe option validator's
METHOD_KEYStable was not derived from the TypeScriptinterfaces, so
basePath— which the underlying napi binding accepts — was rejected bythe wrapper with a generic "unknown key" error. The table is now generated from the
interface types at compile time, and napi's purpose-built error message is reachable
through the universal package.
lint --fix --check --format json <dir>now emits a parseable JSON envelopePreviously this combination produced exit 1 with empty stdout, causing
JSON.parse("")to throw in any downstream consumer. The JSON envelope is now emitted before exit.
lint - --fix --checkno longer silently applies fixes and writes to stdoutThe stdin lint path was not reading the
--check/--diffflags, so this combinationapplied all fixes and wrote the result to stdout rather than exiting 1 unmodified. The
flag combination is now rejected at the usage guard for stdin input.
Terminal-escape sanitization extended to the CLI error-rendering boundary
sanitize_control_charscoveredLintDiagnostic.message/.helpbut notMdsError::Syntaxmessages, which can embed user-controlled source fragments. Untrusted.mdssource could emit raw ESC bytes viamds lintormds build. The sanitizationboundary is now the CLI error-rendering layer for both subcommands.
FixOutcomeis#[non_exhaustive]; fix-plan sortedness guard is unconditionalmds::fixhas never shipped on crates.io, so adding#[non_exhaustive]is free todayand a permanent breaking change after publish. The ascending-sortedness precondition for
the right-to-left accumulation in
apply_fixes_incrementalwas enforced only bydebug_assert!, which is compiled out in release; an external caller passing anunsorted
FixPlanwould get silent source corruption written to disk. Both are promotedto unconditional fail-closed guards before the crates.io publish window closes.
crates/mds-napiscripts.buildrenamed tobuild:nativeAs
build, the script joined thenpm run build --workspacesfan-out thatrelease.ymlruns after publishing
@mdscript/mds-napiand before publishing the universal@mdscript/mdspackage. A failure there would strand the release with platformpackages published but the universal package absent — npm will not accept a republish
of the same version.
CHANGELOG
[Unreleased]restructured to Keep a Changelog conformanceThe section had accumulated two
Added, twoChanged, and two breaking groups atdifferent heading depths and in divergent styles, split by a horizontal rule ~200 lines
apart. Merged and reordered before
bump-version.mjsstamps the version.Deferred items
29 issues were designated FIX_SEPARATE and will be filed as tracked GitHub issues.
These include: complexity refactors in the fix pipeline (
apply_fixesdead code,reverifygate duplication, oversized functions), pre-existing structural debt(oversized
resolver.rs/build.rs), the stdin label convention decision (#37,recommended to resolve before v0.5.0 to avoid a second wire-format churn), and
TypeScript surface improvements that constitute deliberate behavior changes deserving
their own PRs.
Second Code-Review Pass (2026-07-18/19, 9 commits)
Two issues were missed by the first resolve run — the triage ledger's per-file group
tables enumerated 55 while its bucket count said 57. The two were implicitly FIX_NOW
by subtraction but were never dispatched to the resolver. Both had defective earlier
commits; one of those commits (
a7ef84f) left the tree red.Source-map path disclosure (HIGH / security)
sources[]entries in inline source maps could reconstruct an absolute filesystempath, leaking the username and directory layout into compiled output that ships to end
users.
mds_core::source_path::relativize_source, applied unconditionally at bothresolver.rsfinalize sites. A release-critical security invariant must not sitbehind a flag (PF-005).
../".../src/a.mdsis correctSourceMap v3 output when the source lives above the output file and must survive;
only paths that escape above the project root collapse to a basename. The reverted
commit
a7ef84fhad this logic backwards.CompileOptionsgainssource_map_base: Option<PathBuf>— BREAKING; callersthat use struct-literal construction must add
..Default::default().#[non_exhaustive]was deliberately rejected: it forbids struct-literal and functional-update construction
from other crates, which would break the existing bindings.
FileSystem::source_root() -> Option<String>added with a provided default body —non-breaking for external implementors.
sources[]go from absolute paths toproject-root-relative paths. The root is detected via
.git/.mdsroot, fallingback to the entry-file directory.
\\?\C:\…) were notstripped from the detected root, so containment failed on the first path component
and every Windows source map silently collapsed every entry to a basename.
mds fmtnon-atomic write (MEDIUM)std::fs::writetruncates then writes; a crash or power loss mid-write left theuser's only copy of their source file truncated with no recovery path.
fmtwrites now route through a sharedatomic_write_filehelper (moved fromlint.rs→output.rs, making it available to both subcommands).sync_all()is called beforeNamedTempFile::persist(). The previousflush()wasa no-op:
NamedTempFilederefs to an unbufferedFile, soflush()on it doesnothing.
& 0o7777to strip theS_IFMTtype bits before passingto
set_permissions.path.display()is used in all six error messages (waspathraw, which on Windowsemits
\\?\prefixes unreadably into diagnostics)..mds-lint-fix-to something that doesn't embed thesibling subcommand's name.
Cross-surface parity (PF-007)
Two differential tests added to catch future surface divergence early:
sources[]values match across the two JS surfaces.CF-SM2hard-fails when theCIenvironment variable is set if any surface isunavailable, so it cannot silently degrade to a green partial run. To support this,
the JS CI job now builds
mds-cliand installs the Python binding before running theJS test suite.
Follow-ups filed (real GitHub issues — not review-ledger IDs)
Three limitations of the atomic write approach are tracked but deliberately not fixed
in this pass:
metadata()failure silently yields mode 0600 via.ok()build::write_outputstill uses truncate-then-write for compiled output artifactsVerification
CI 18/18 green at HEAD
c034fd1. Locally: 1,832 Rust tests (nextest), 36 doc tests,519 JS, 199 pytest,
cargo fmt --all --checkandcargo clippy -D warningsclean,verify-versions,cargo publish -p mds-core --dry-run. QA ran 31 acceptancescenarios — all pass. Atomicity confirmed by inode identity check and a 688,650-sample
concurrent poller that observed zero truncation windows.
Reviewer note
CHANGELOG carries four new entries under
[Unreleased](Security / BREAKING / Changed/ Fixed) covering the source-map and atomic-write changes. ADR-005 was amended (not
replaced) to document the containment rule and the
source_map_basefield..devflow/is gitignored, so the ADR amendment is local-only and will not appear in the diff.
Footnotes
Corrected — the original entry claimed 198 passed / 6 deselected, which
came from a local run using
-k "not perf"instead of-m "not perf". Because-kmatches substrings of the full test node id, it silently deselected the 15 parametrized
test_perf4_malformed_input_never_yields_internalcases alongside the 6 genuinelyperf-marked ones, producing a false 198/6 split. The correct split is 183 passed /21 deselected. CI was never affected:
.github/workflows/ci.ymllines 175, 180, 207,212 and
crates/mds-python/pyproject.toml:45–47all use the correct-m "not perf"form. The 15 adversarial panic-path guards do run in CI. This
-k-vs--mtrap isdocumented as repo pitfall PF-008. ↩