Skip to content

fix: v0.4.0 remediation — dogfooding blockers, bug batch, UX polish, docs sweep - #196

Merged
dean0x merged 58 commits into
mainfrom
fix/v0-4-0-remediation
Jul 19, 2026
Merged

dean0x merged 58 commits into
mainfrom
fix/v0-4-0-remediation

Conversation

@dean0x

@dean0x dean0x commented Jul 17, 2026

Copy link
Copy Markdown
Owner

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)

  1. Bare relative filename crash (e.g. mds build foo.mds from foo.mds's
    directory) — effective_parent in fs.rs mapped an empty parent path to .
    instead of producing a bad-path error; this caused panics on some platforms.

  2. sourceMap.sources[0] cross-surface divergence — string-source compiles
    produced "<source>" on napi/Python/WASM but "input.mds" on the CLI, making
    cross-surface source-map consumers unreliable. Unified to "input.mds" via a
    map_source_label choke-point in sourcemap.rs.

  3. mds fmt formatter-invariant false positive on trailing blank lines — templates
    ending with @if … @end\n\n were rejected by the safety gate with
    mds::formatter_invariant after a perfectly valid format run.

Key Changes

Phase A — Bare filename, walker exclusions, ElseifBranch AST

  • effective_parent helper in fs.rs; all subcommands now handle bare filenames.
  • Directory walker default-excludes hidden dirs (.git, .venv, …) and node_modules
    on all subcommands (build/check/watch/fmt/lint).
  • ElseifBranch { condition, body, offset } struct replaces tuple form; lint
    @elseif diagnostics now anchor at the branch span.

Phase B — Lint core: partial apply, preview gate, display-path, overlap surfacing

  • apply_fixes_incremental in lint/fix.rs: batch-first, right-to-left per-edit
    fallback; reports "N of M fixes applied" for partial results.
  • lint --fix --check/--diff are now honest gated previews (reverify before showing).
  • Directory-mode JSON file keys are full relative paths, not basenames.
  • Overlapping fix plans are surfaced rather than silently dropped.
  • Stdin lint diagnostics include a code frame with "input.mds" source label.

Phase C — Formatter gate + format_str_named

  • Trailing-insignificant-text stripping in structural_equivalent eliminates the
    formatter-invariant false positive on trailing blank lines.
  • format_str_named(source, base_dir, file_name): new public API that threads a
    caller file name through the formatter; mds fmt errors now name the file.

Phase D — Source-map accuracy + error quality

  • --source-map --inline -o - (stdout) relativizes sources[] 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_mismatch errors carry spans via EvalContext threading.
  • name_collision_at upgrades in ExportDirective::Wildcard, alias, and merge paths.
  • mds::syntax label deduped ("syntax error occurred here").
  • ArityMismatch help text added. \{ escape hint on unclosed brace.
  • Messages-mode source-map warning reworded surface-neutral, deduplicated.

Phase E — Wrapper strict options, check summary, --vars UX

  • @mdscript/mds wrapper rejects unknown option keys (mds::invalid_options).
  • CheckOptions split from CompileOptions; check/checkFile no longer accept
    source-map options.
  • mds check summary: "N passed, M failed" (was "N checked").
  • mds fmt --check summary adds "N unchanged" count.
  • --vars JSON errors include the file path.
  • conftest.py picks the freshest CLI binary by mtime.

Phase F — Docs sweep + CHANGELOG

  • spec.md: str(string(); @# nonexistent comment syntax removed; unclosed
    code fence documented; §4.9 output example now includes emitted frontmatter.
  • README.md: sidecar name corrected (<output>.map); fmt stdin demo updated.
  • main.rs after_help: same fmt demo fix; --quiet help text clarified for lint.
  • napi/Python/JS/WASM READMEs: sourceMap/sourcesContent and lint API documented.
  • CHANGELOG: all behavior changes entered; str(string() in migration bullet;
    sidecar name fixed in Source Map v3 entry.

Verification

All gates green at tip f55f4d0:

Gate Result
cargo nextest run --workspace 1757/1757 passed
cargo fmt --all --check clean
cargo clippy --workspace --all-targets -- -D warnings 0 warnings
node --test crates/mds-napi/__test__/index.spec.mjs 93/93 passed
pytest crates/mds-python/tests -q -m "not perf" 183 passed, 21 deselected 1
npm test -w packages/mds 248/248 passed
bundler workspaces (vite/rollup/webpack/rspack/bundler-utils) all pass
WASM binary size 764.8 KB / 800 KB budget
Snyk code scan (crates/, packages/mds/src) 0 issues

Test Plan

  • mds build foo.mds (bare filename from file's directory) — should succeed
  • compile(src, {sourceMap:true}).sourceMap.sources[0] — should equal "input.mds" on napi, WASM, Python
  • mds fmt on file with trailing blank line after @end — should exit 0
  • mds check on directory — summary should say "N passed, M failed"
  • mds lint --fix --check — should print "fix rejected:" for overlapping fixes
  • @mdscript/mds compile(src, {unknownKey: true}) — should throw mds::invalid_options
  • mds build template.mds --source-map --inline -o - — no absolute paths in stdout

Post-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: a relativize_source choke-point in crates/mds-core/src/source_path.rs now applies project-root containment at both resolver.rs finalize sites; the napi and Python surfaces additionally moved from absolute paths to root-relative paths; Windows verbatim UNC prefix handling was added. 3 were confirmed
BY_DESIGN.

User-visible behavior changes introduced by the resolve pass

Silent gate bypass fixed (mds fmt / mds lint --fix on relative paths)
mds fmt <bare-filename> was swallowing genuine mds::syntax errors and printing
"Unchanged" (exit 0), because a relative base_dir caused check_symlink to fail
silently, falling through to a weaker structural fallback instead of running the
compile-equivalence gate. mds lint --fix <relative-path> was likewise rejecting
every 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_dir now canonicalizes
to an absolute path before those checks run.

Directory walker all-excluded now exits non-zero
A directory whose .mds files all lived under hidden directories (.git, .venv,
.claude) or node_modules previously produced "No .mds files found" and exit 0 — a
CI 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/lintFile reverted to synchronous throws
Both had been made async during this PR's development, silently changing the public
error contract (sync throw became an unhandled rejection). The pre-existing contract
is restored; async keywords removed.

@mdscript/mds wrapper now accepts basePath on compile/check
The option validator's METHOD_KEYS table was not derived from the TypeScript
interfaces, so basePath — which the underlying napi binding accepts — was rejected by
the 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 envelope
Previously 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 --check no longer silently applies fixes and writes to stdout
The stdin lint path was not reading the --check/--diff flags, so this combination
applied 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_chars covered LintDiagnostic.message/.help but not
MdsError::Syntax messages, which can embed user-controlled source fragments. Untrusted
.mds source could emit raw ESC bytes via mds lint or mds build. The sanitization
boundary is now the CLI error-rendering layer for both subcommands.

FixOutcome is #[non_exhaustive]; fix-plan sortedness guard is unconditional
mds::fix has never shipped on crates.io, so adding #[non_exhaustive] is free today
and a permanent breaking change after publish. The ascending-sortedness precondition for
the right-to-left accumulation in apply_fixes_incremental was enforced only by
debug_assert!, which is compiled out in release; an external caller passing an
unsorted FixPlan would get silent source corruption written to disk. Both are promoted
to unconditional fail-closed guards before the crates.io publish window closes.

crates/mds-napi scripts.build renamed to build:native
As build, the script joined the npm run build --workspaces fan-out that release.yml
runs after publishing @mdscript/mds-napi and before publishing the universal
@mdscript/mds package. A failure there would strand the release with platform
packages published but the universal package absent — npm will not accept a republish
of the same version.

CHANGELOG [Unreleased] restructured to Keep a Changelog conformance
The section had accumulated two Added, two Changed, and two breaking groups at
different heading depths and in divergent styles, split by a horizontal rule ~200 lines
apart. Merged and reordered before bump-version.mjs stamps 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_fixes dead code,
reverify gate 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 filesystem
path, leaking the username and directory layout into compiled output that ships to end
users.

  • All relativization now funnels through a single choke point,
    mds_core::source_path::relativize_source, applied unconditionally at both
    resolver.rs finalize sites. A release-critical security invariant must not sit
    behind a flag (PF-005).
  • The rule is project-root containment, not "no ../". ../src/a.mds is correct
    SourceMap 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 a7ef84f had this logic backwards.
  • CompileOptions gains source_map_base: Option<PathBuf>BREAKING; callers
    that 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.
  • Observable change: napi and Python sources[] go from absolute paths to
    project-root-relative paths. The root is detected via .git / .mdsroot, falling
    back to the entry-file directory.
  • Windows fix (discovered by CI): verbatim UNC prefixes (\\?\C:\…) were not
    stripped 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 fmt non-atomic write (MEDIUM)

std::fs::write truncates then writes; a crash or power loss mid-write left the
user's only copy of their source file truncated with no recovery path.

  • All fmt writes now route through a shared atomic_write_file helper (moved from
    lint.rsoutput.rs, making it available to both subcommands).
  • sync_all() is called before NamedTempFile::persist(). The previous flush() was
    a no-op: NamedTempFile derefs to an unbuffered File, so flush() on it does
    nothing.
  • File mode is masked with & 0o7777 to strip the S_IFMT type bits before passing
    to set_permissions.
  • path.display() is used in all six error messages (was path raw, which on Windows
    emits \\?\ prefixes unreadably into diagnostics).
  • Temp file prefix renamed from .mds-lint-fix- to something that doesn't embed the
    sibling subcommand's name.

Cross-surface parity (PF-007)

Two differential tests added to catch future surface divergence early:

  • V-SM1: napi ↔ WASM — verifies sources[] values match across the two JS surfaces.
  • CF-SM2: napi ↔ WASM ↔ CLI ↔ Python — four-way cross-surface comparison.

CF-SM2 hard-fails when the CI environment variable is set if any surface is
unavailable, so it cannot silently degrade to a green partial run. To support this,
the JS CI job now builds mds-cli and installs the Python binding before running the
JS 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:

Verification

CI 18/18 green at HEAD c034fd1. Locally: 1,832 Rust tests (nextest), 36 doc tests,
519 JS, 199 pytest, cargo fmt --all --check and cargo clippy -D warnings clean,
verify-versions, cargo publish -p mds-core --dry-run. QA ran 31 acceptance
scenarios — 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_base field. .devflow/
is gitignored, so the ADR amendment is local-only and will not appear in the diff.

Footnotes

  1. 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 -k
    matches substrings of the full test node id, it silently deselected the 15 parametrized
    test_perf4_malformed_input_never_yields_internal cases alongside the 6 genuinely
    perf-marked ones, producing a false 198/6 split. The correct split is 183 passed /
    21 deselected. CI was never affected: .github/workflows/ci.yml lines 175, 180, 207,
    212 and crates/mds-python/pyproject.toml:45–47 all use the correct -m "not perf"
    form. The 15 adversarial panic-path guards do run in CI. This -k-vs--m trap is
    documented as repo pitfall PF-008.

dean0x and others added 20 commits July 18, 2026 01:32
…, 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>
@dean0x dean0x changed the title fix: v0.4.0 remediation Phase A — bare filename, walker exclusions, ElseifBranch AST span accuracy fix: v0.4.0 remediation — dogfooding blockers, bug batch, UX polish, docs sweep Jul 18, 2026
dean0x and others added 5 commits July 18, 2026 06:00
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.
dean0x and others added 6 commits July 18, 2026 19:46
…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
dean0x and others added 6 commits July 19, 2026 10:40
…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
dean0x merged commit 3aef465 into main Jul 19, 2026
18 checks passed
@dean0x
dean0x deleted the fix/v0-4-0-remediation branch July 19, 2026 12:52
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>
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add per-branch offset to IfBlock.elseif_branches for precise @elseif diagnostics

1 participant