diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2960b520..cbb94950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,12 +134,40 @@ jobs: - name: Build native addon (host) working-directory: crates/mds-napi run: npx napi build --platform --release --no-js + # Build the unsuffixed mds-napi.node that __test__/index.spec.mjs loads + # directly via require('../mds-napi.node'). cargo is a cache hit here; + # napi just renames the already-built artifact without recompiling. + - name: Build native addon (unsuffixed, napi spec test) + run: npm run build:native -w @mdscript/mds-napi # Build the WASM pkg so the WASM fallback (and wasm-backend tests) work. - uses: ./.github/actions/setup-wasm - name: Build WASM (nodejs) run: wasm-pack build crates/mds-wasm --target nodejs --out-dir pkg - name: Build TS packages run: npm run build --workspaces --if-present + # Build the mds CLI binary so CF-SM2 can invoke it as the third parity + # surface. target/debug/mds is auto-discovered by findMdsCli(); no env + # var needed. The Rust toolchain + cache are already set up above, so + # this is an incremental build sharing the dep graph with the napi addon + # (avoids PF-007: CLI surface must actually run in CI, not skip silently). + - name: Build mds CLI (CF-SM2 parity producer) + run: cargo build -p mds-cli + # Install Python + the mdscript binding so CF-SM2 can compare the Python + # output as the fourth parity surface (avoids PF-007). pip uses the + # maturin PEP 517 build backend declared in crates/mds-python/pyproject.toml; + # no pre-installed maturin needed. MDS_PYTHON_BIN is set to the exact + # executable that owns the installed module so findPythonForMdscript() + # picks it up cross-platform (bin/ on Unix, Scripts/ on Windows). + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python binding (CF-SM2 parity surface) + run: python -m pip install ./crates/mds-python + - name: Export MDS_PYTHON_BIN + shell: bash + run: | + PY=$(python -c "import sys; print(sys.executable)") + echo "MDS_PYTHON_BIN=$PY" >> "$GITHUB_ENV" - name: Test run: npm test --workspaces --if-present diff --git a/CHANGELOG.md b/CHANGELOG.md index ab56f790..d0a5a839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### **BREAKING** — Strict cross-type comparisons, merged `@extends` frontmatter, interior-verbatim whitespace +### Security + +- **Source Map v3 `sources[]` no longer leaks absolute filesystem paths** across + all surfaces. Previously, `compileFile` on napi and Python emitted the absolute + filesystem path (e.g. `/home/user/project/src/foo.mds`) as `sources[0]` in the + generated Source Map v3. Shipped source maps and inline maps embedded with + `--inline` could expose the full path of the machine that compiled the template, + a privacy-significant information disclosure. Fixed by the `relativize_source` + choke-point in `crates/mds-core/src/source_path.rs` (ADR-005 Phase A): all + surfaces now emit root-relative paths (e.g. `src/foo.mds`) relative to the + project root (located via `.mdsroot` / `.git` walk-up), and `..`-escaping + references outside the project root fall back to the basename. (#3) + +### **BREAKING** — Strict cross-type comparisons, merged `@extends` frontmatter, interior-verbatim whitespace, filesystem API These changes alter observable runtime behavior and compiled output. Templates relying on the previous (buggy) behavior must be updated. @@ -19,7 +32,7 @@ vs. a string, or a boolean vs. null) now raises `mds::type_mismatch` at runtime instead of silently returning `false` (for `==`) or `true` (for `!=`). **Migration:** add an explicit conversion before comparing: -- `@if str(count) == "3":` — convert number to string +- `@if string(count) == "3":` — convert number to string - `@if count == 3:` — compare number to number literal #### Cross-flag duplicate keys in `--set` / `--set-string` are now a hard error (#152) @@ -39,6 +52,67 @@ now appear in the compiled output. **Migration:** if your pipeline depends on base frontmatter keys being absent from the compiled output, strip them downstream or move them to a non-frontmatter location. +#### Interior-verbatim whitespace contract for block bodies and `mds fmt` (#150, #151) + +Leading blank lines and interior blank runs inside `@block` / `@define` bodies and +`mds fmt` output are now preserved verbatim; previously they were collapsed or stripped. +The `mds fmt` blank-line collapsing rule (R3) has been removed to maintain compile +equivalence with the updated evaluator behavior. Only the trailing edge normalizes (to +exactly one final newline). + +**Migration:** compiled outputs may gain blank lines that were previously collapsed or +stripped; templates relying on this collapse must remove the extra blank lines at the +source level. + +#### `FileSystem` trait now requires `normalize_in_dir` and `parent_dir` (#146) + +`FileSystem` now requires two new methods — `normalize_in_dir` and `parent_dir` — that +replace the internal `` path-sentinel pattern. String-source `@import`/`@extends` +resolution is now directly directory-anchored: `ctx.base_dir` carries the importing +directory explicitly, with no synthetic filename appended. No behavior change for +`compile`/`check` users; only affects code that implements the `FileSystem` trait +directly via `ModuleCache::with_fs`. + +### **BREAKING** — Options validation, directory walker, source-map labels, check API (#196) + +- **`@mdscript/mds` now rejects unknown option keys** with + `Error { code: 'mds::invalid_options' }` before forwarding to the backend. Previously + unrecognized keys were silently passed through (napi and WASM backends would reject + them, but the universal JS wrapper did not validate). Callers with typos in option + objects will now get immediate, accurate error messages. (#196) + +- **`CheckOptions` is now split from `CompileOptions`** in `@mdscript/mds`. + `check()` and `checkFile()` accept only `{ vars? }` — source-map options + (`sourceMap`, `sourcesContent`) are not valid for check calls and are rejected with + `mds::invalid_options`. `CompileOptions` retains `sourceMap`/`sourcesContent`. + TS interface implementers: `check`/`checkFile` signatures narrow to `CheckOptions`. (#196) + +- **String-source `sourceMap` label changed from `""` to `"input.mds"`** + across all surfaces (CLI, napi, WASM, Python). The `sources[0]` entry in Source Map v3 + output for `compile(src, {sourceMap:true})` / `compile_str*` / WASM `compile` now reads + `"input.mds"` instead of `""`. CLI stdin builds use `""` (unchanged). + Code inspecting `sources[0]` for the string `""` must be updated. (#196) + +- **Directory walker now excludes hidden directories and `node_modules` by default** + across all subcommands (`mds build`, `mds check`, `mds watch`, `mds fmt`, + `mds lint`). Directories whose name starts with `.` (e.g. `.git`, `.venv`) and + `node_modules` are silently skipped during recursive traversal. Templates inside these + directories are no longer compiled, formatted, or linted in directory mode. (#196) + +- **`mds check` summary wording changed** from `N checked` to `N passed, M + failed`. Scripts parsing CLI output must be updated. (#196) + +- **lint `--format json` `"file"` keys are now full relative paths** in directory mode. + When running `mds lint --format json .`, the `"file"` key in each JSON result is now + the path relative to the lint root (e.g. `"src/template.mds"`) rather than just the + basename (e.g. `"template.mds"`). This prevents key collisions when two different + files have the same filename. (#196) + +- **`mds-core::CompileOptions` gained `source_map_base: Option`**. Rust code + that initializes `CompileOptions` with a struct literal must either add + `source_map_base: None` or use the `..Default::default()` tail. Binding surfaces + (napi, Python, WASM) are not affected. (#3) + ### Added - **`--set-string KEY=VALUE`** CLI flag for `mds build`, `mds check`, and `mds watch`. @@ -127,7 +201,7 @@ compiled output, strip them downstream or move them to a non-frontmatter locatio - **Source Map v3** (#62). Compile calls can now produce a [Source Map v3](https://sourcemaps.info/spec.html) document alongside the rendered output. - **CLI** (`mds build`): `--source-map` writes a `.md.map` sidecar and leaves + **CLI** (`mds build`): `--source-map` writes a `.map` sidecar and leaves the compiled output byte-identical to a no-flag build (ADR-002). `--inline` embeds the map as a `` HTML comment at the end of the output; no sidecar is written (requires `--source-map`). `--no-source-map` suppresses @@ -159,27 +233,130 @@ compiled output, strip them downstream or move them to a non-frontmatter locatio includes the full original template source in the map file — including any hardcoded secrets or PII. Only use in trusted build environments. +- **Partial fix application** for `mds lint --fix`: when a batch of fixes partially + applies (some edits are accepted, some are rejected due to post-fix regression), + the CLI now reports `"N of M fixes applied"` and writes the best accumulated state + to the file. Previously, a partial batch was all-or-nothing (either all or nothing + applied). (#196) + +- **`type_mismatch` errors now carry a source span** (file + line + column) pointing + to the `@if` or `@elseif` directive that triggered the comparison. The span is + propagated through all surfaces (CLI miette code frame, napi `.span`, Python + `.span`, WASM error object). (#196) + +- **Spans on `mds::name_collision` errors** in `@export *` (wildcard), alias-import, + and merge-import paths. The error now points to the collision site instead of the + file root. (#196) + +- **Spans on unclosed-block errors**: `@if`/`@for`/`@define`/`@message` blocks that + are never closed now produce `mds::syntax` errors anchored at the opening directive. + (#196) + +- **`\{` escape hint on unclosed interpolation brace**: when the compiler encounters + an unclosed `{` (brace without a matching `}`), the error now includes the hint + "to include a literal `{`, escape it as `\{`". (#196) + +- **`ArityMismatch` help text**: function-call arity errors now include a help string + pointing users to check the call site and the `@define` signature. (#196) + +- **Per-branch `@elseif` offset** in the AST (`ElseifBranch.offset`): lint diagnostics + for `empty-block`, `unreachable-branch`, and `duplicate-@elseif` now anchor at the + `@elseif` directive span rather than the parent `@if` opener. (#196) + +- **`format_str_named(source, base_dir, file_name)`** — new public `mds-core` API that + threads a caller-supplied file name through the formatter so that any `mds::syntax` + errors emitted during formatting name the file rather than using a generic sentinel. + `mds-cli`'s `mds fmt` uses this to show the actual file path in error output. (#196) + +- **`mds fmt --check` summary now includes unchanged count**: the directory-mode summary + under `--check` is now `"N would reformat, M unchanged, K failed"` (previously `"N + would reformat, K failed"`). (#196) + +- **napi workspace `build` script**: `crates/mds-napi/package.json` gains a `build` + script (`napi build --release --no-js`) for local development. (#196) + ### Changed -- **BREAKING:** Interior-verbatim whitespace contract for block bodies and `mds fmt`. - Leading blank lines and interior blank runs inside `@block` / `@define` bodies and - `mds fmt` output are now preserved verbatim; previously they were collapsed or stripped. - The `mds fmt` blank-line collapsing rule (R3) has been removed to maintain compile - equivalence with the updated evaluator behavior. Only the trailing edge normalizes (to - exactly one final newline). **Migration:** compiled outputs may gain blank lines that - were previously collapsed or stripped; templates relying on this collapse must remove - the extra blank lines at the source level. (#150, #151) - -- **BREAKING:** `FileSystem` trait now requires two new methods — `normalize_in_dir` - and `parent_dir` — that replace the internal `` path-sentinel pattern. - String-source `@import`/`@extends` resolution is now directly directory-anchored: - `ctx.base_dir` carries the importing directory explicitly, with no synthetic - filename appended. No behavior change for `compile`/`check` users; only affects - code that implements the `FileSystem` trait directly via `ModuleCache::with_fs`. - (#146) +- **napi and Python `compileFile` / `compile_file` now emit root-relative + `sources[]`** in Source Map v3 output. Previously these surfaces emitted the + absolute filesystem path as `sources[0]` (e.g. `/home/user/project/src/foo.mds`); + now they emit a slash-separated path relative to the project root found via + `.mdsroot` / `.git` walk-up (e.g. `src/foo.mds`). The `@mdscript/mds` + universal package's `compileFile` previously returned different `sources[]` + depending on which backend `init()` loaded (absolute on native, root-relative via + `buildModulesMap` on WASM); both backends now produce identical root-relative paths. + Code that compares `sources[0]` to an absolute path must be updated. (#3) + +- **Inline stdout source-map absolute-path leak fixed**: `mds build --source-map + --inline -o -` and `mds build --source-map -o -` no longer leak absolute filesystem + paths in the embedded `sourceMappingURL` data-URI; sources are relativized against + the current working directory. Previously the output path was `None` for stdout + builds, causing the relativization step to short-circuit and leave absolute paths. + (#196) + +- **`mds build --inline -o -` for stdin input is now allowed**: previously rejected + with an error. Inline and sidecar source maps now work identically for stdin and + file inputs. The `sources[0]` label is `""` for stdin builds. (#196) + +- **lint `--fix --check` and `--fix --diff` are now honest gated previews**: the + preview pass runs through the same reverify gate as apply. Fixes that would be + rejected (overlap, post-fix regression) are reported as `"fix rejected: "` + rather than silently shown as `"would fix"`. Directory mode `--fix --check` exits 1 + when any file has fixable issues. (#196) + +- **Overlap-rejected fix plans are now surfaced**: when `lint --fix` finds overlapping + byte ranges (two rules targeting the same span), the plan is no longer silently + abandoned. The overlap is reported so users know a fix exists but could not be auto- + applied. (#196) + +- **`mds fmt` errors name the file**: formatting errors emitted to stderr now include + the file path as a prefix (e.g. `"src/foo.mds: formatter_invariant: …"`). Previously + file context was absent, making batch `mds fmt .` errors hard to trace. (#196) + +- **`--vars` JSON errors name the file**: when a `--vars` JSON file is malformed or + does not contain a top-level object, the error message now includes the file path. + (#196) + +- **stdin `mds lint` code frames**: lint diagnostics for stdin input now include a + miette code frame with `"input.mds"` as the source label. Previously stdin lint + diagnostics lacked source context. (#196) + +- **Bare relative filenames now work** for all subcommands and the `compile_str` + binding family. Running `mds build foo.mds` (without a `./` prefix) from the file's + directory previously failed on some platforms because the parent-path resolution + produced an empty path instead of `.`. Fixed by `effective_parent` in `fs.rs`. (#196) + +- **`mds fmt` formatter-invariant gate false positive on trailing blank lines is + fixed**: templates containing trailing blank lines (e.g. `@if … @end\n\n`) were + incorrectly rejected by the safety gate with `mds::formatter_invariant` after being + formatted. The gate now correctly ignores insignificant trailing whitespace. (#196) + +- **Lint diagnostic messages now consistently end with a period** (G3 message-copy + consistency): all `empty-block` and `unreachable-branch` rule messages are + punctuated uniformly. (#196) + +- **Messages-mode source-map warning reworded and deduplicated**: the warning emitted + when `sourceMap: true` is requested on a messages-mode template now reads "source + maps are not supported for messages-mode templates (@message blocks); no source map + will be generated" across all surfaces. The warning is emitted exactly once per + compilation (previously it could appear twice for some template shapes). (#196) + +- **`mds::syntax` error label no longer duplicates the message**: the miette diagnostic + label was previously set to `{message}` (same as the headline), producing redundant + output in code-frame renderings. It now reads `"syntax error occurred here"`. (#196) ### Fixed +- **`mds build -o build/out.md` with sources in `src/` again emits map-relative + paths** (e.g. `../src/foo.mds`) in the sidecar `.map` file and inline source map. + The `source_map_base` field added to `CompileOptions` tells `relativize_source` + to emit paths relative to the map file's parent directory (as the Source Map v3 + spec requires) rather than root-relative. Without this, a source `src/foo.mds` + compiled to `build/out.md` with `--source-map` would emit `src/foo.mds` in the + map instead of the spec-correct `../src/foo.mds`. Root-relative emission + (`source_map_base: None`) is now the default for all binding surfaces (napi, + Python, WASM), which never write map files to disk. (#3) + - **Code fences: tilde (`~~~`), indented, and blockquoted variants are now recognized** as passthrough regions. Previously only `` ``` ``-fences that started at column 1 were treated as code — a `~~~` fence, a `` > ``` `` blockquote fence, or a fence @@ -443,7 +620,8 @@ First public release of the MDS (Markdown Script) compiler. - 590 Rust tests (integration, unit, and doc-tests across the workspace) plus the JavaScript package suites -[Unreleased]: https://github.com/dean0x/mdscript/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/dean0x/mdscript/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/dean0x/mdscript/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/dean0x/mdscript/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/dean0x/mdscript/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/dean0x/mdscript/releases/tag/v0.1.0 -[0.3.0]: https://github.com/dean0x/mdscript/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index db79c0db..d1d1b802 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mds lint [FILE|DIR] [OPTIONS] Static-analysis lint (9 rules; --fix, --format j mds init [FILENAME] Create a starter MDS file Global options: - -q, --quiet Suppress status messages (applies to all commands) + -q, --quiet Suppress status and diagnostic output; errors always print; exit codes unaffected Build/Watch options: -o, --output Output file, or "-" for stdout (build and single-file watch only; @@ -90,7 +90,7 @@ Build/Watch options: --vars JSON file with variable overrides (reloaded each rebuild) --set KEY=VALUE Set a single variable (repeatable); value coerced to number/bool/null/array when possible --set-string KEY=VALUE Set a single variable as a string, bypassing type coercion (repeatable) - --source-map Write a Source Map v3 sidecar (.md.map); output is + --source-map Write a Source Map v3 sidecar (.map, e.g. -o out.md → out.md.map); output is byte-identical to a no-flag build. Ignored for messages-mode templates (no renderable output). See ⚠ privacy note below. --inline Embed the source map as a sourceMappingURL data-URI comment @@ -124,7 +124,7 @@ Exit codes: 3 Resource limit exceeded ``` -**Directory mode** (`mds build ` / `mds check `): every non-partial `.mds` file under the directory is compiled. `_`-prefixed files are partials — tracked as dependencies but never emitted to their own output. Output mirrors the source subtree (e.g. `src/a/b/foo.mds` → `dist/a/b/foo.md`). Symlinks are rejected. Errors are per-file and do not abort the run; a summary is printed and the exit code is non-zero if any file fails. Stale output files (compiled outputs with no corresponding source) are cleaned up automatically. The output extension is intrinsic: `.md` for Markdown templates, `.json` for templates with `@message` blocks. +**Directory mode** (`mds build ` / `mds check `): every non-partial `.mds` file under the directory is compiled, with two automatic exclusions: directories whose name starts with `.` (e.g. `.git`, `.github`, `.claude`, `.cursor`) and `node_modules` are skipped during traversal. `_`-prefixed files are partials — tracked as dependencies but never emitted to their own output. Output mirrors the source subtree (e.g. `src/a/b/foo.mds` → `dist/a/b/foo.md`). Symlinks are rejected. Errors are per-file and do not abort the run; a summary is printed and the exit code is non-zero if any file fails. If **every** `.mds` file is under a default-excluded directory, the command exits non-zero and prints a diagnostic carrying the skip count — even under `--quiet` — because this is the silent CI green-pass failure mode for prompt-template libraries stored under `.github/prompts/`, `.claude/`, or `.cursor/rules/`. A genuinely empty directory (no `.mds` files anywhere) still exits 0 with a "No .mds files found" message. Stale output files (compiled outputs with no corresponding source) are cleaned up automatically. The output extension is intrinsic: `.md` for Markdown templates, `.json` for templates with `@message` blocks. `mds fmt ` follows the same directory-mode conventions (recursive, symlinks rejected, continue-on-error, non-zero exit summary) with one deliberate difference: it formats `_`-prefixed **partials too** — formatting rewrites source, not compiled output, and a partial's source is just as much a candidate for reformatting as any other file. @@ -179,7 +179,7 @@ mds fmt . # format every .mds file recursively (partials i mds fmt --check template.mds # exit 1 if the file would change; never writes — for CI mds fmt --diff template.mds # print a unified diff of pending changes; never writes mds fmt --check --diff . # show diffs for every file that would change; exit 1 if any would -echo 'Hello {name}!' | mds fmt - # format from stdin, write to stdout; creates no file +printf '@if ready: \nGo\n@end\n' | mds fmt - # format from stdin, write to stdout; creates no file ``` What it normalizes: @@ -199,7 +199,7 @@ What it deliberately leaves untouched: Directory mode formats every `.mds` file recursively, **including `_`-prefixed partials**, continuing past per-file errors and printing a summary -(`N formatted, M unchanged, K failed`, or `N would reformat, K failed` under `--check`). A file +(`N formatted, M unchanged, K failed`, or `N would reformat, M unchanged, K failed` under `--check`). A file is only written (and its mtime touched) when its content actually changes. Status lines and summaries go to stderr; `--diff` output and stdin filter-mode content go to stdout; `--quiet` suppresses status but never errors. Reads a `fmt` section from `mds.json` @@ -215,7 +215,7 @@ mds lint template.mds # lint a single file mds lint . # lint all .mds files recursively (partials included) mds lint --fix template.mds # auto-fix fixable issues in place mds lint --format json . # machine-readable JSON output (stdout) -mds lint --quiet template.mds # suppress warnings; exit 2 on errors only +mds lint --quiet template.mds # suppress output; exits 1 on warnings, 2 on errors ``` Rules (configure via `mds.json` `lint.rules`; severities differ per rule): @@ -232,7 +232,7 @@ Rules (configure via `mds.json` `lint.rules`; severities differ per rule): | `duplicate-import` | **error** | Same file imported more than once (auto-fixable) | | `duplicate-export` | **error** | Same export name defined more than once (auto-fixable) | -Exit codes: `0` = clean, `1` = warnings only, `2` = errors or analysis failure, `3` = resource limit. +Exit codes: `0` = clean, `1` = warnings only, `2` = errors or analysis failure, `3` = resource limit. With `--quiet`, output is suppressed but exit codes are unaffected. JSON output shape: `{"files":[{"file":"…","diagnostics":[…]}],"truncated":false,"version":1}`. ## Bundler Integration diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 3a7b792f..223b3cf7 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -7,7 +7,10 @@ use std::ffi::OsString; use std::io::Read; use std::path::{Path, PathBuf}; -use mds::{CompiledOutput, MdsError, MAX_FILE_SIZE, MAX_TRAVERSAL_DEPTH}; +use mds::{ + effective_parent, sanitize_control_chars, CompiledOutput, MdsError, MAX_FILE_SIZE, + MAX_TRAVERSAL_DEPTH, STRING_SOURCE_MAP_LABEL, +}; use miette::Result; use serde::Deserialize; @@ -110,16 +113,20 @@ const MAX_CONFIG_SIZE: u64 = 1024 * 1024; /// resolve relative `output_dir` values. pub(crate) fn load_config(start: &Path) -> Result> { // Walk upward from `start` (which may be a file; begin at its parent). - let start_dir = if start.is_dir() { + // avoids PF-006: a relative start_dir (e.g. "" or ".") causes current.parent() + // to return None after just 1–2 iterations, making grandparent mds.json + // unreachable even when MAX_TRAVERSAL_DEPTH would allow it. Canonicalize + // to an absolute path first so every parent() step advances one real directory. + let raw_start_dir = if start.is_dir() { start.to_path_buf() } else { - start - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")) + effective_parent(start).to_path_buf() }; - let mut current = start_dir; + let mut current = match raw_start_dir.canonicalize() { + Ok(p) => p, + Err(_) => raw_start_dir, + }; // Cap prevents unbounded traversal on unusual filesystems. for _ in 0..MAX_TRAVERSAL_DEPTH { let candidate = current.join("mds.json"); @@ -331,7 +338,8 @@ pub(crate) fn resolve_output_path_for_kind( match input_path { Some(p) => { let filename = derive_output_filename_for_kind(p, kind); - let dir = p.parent().unwrap_or(Path::new(".")); + // effective_parent maps "" (bare filename) to "." — avoids PF-006. + let dir = effective_parent(p); Ok(Some(dir.join(filename))) } // Should not reach here (auto-detect always sets Some), but stdout as safe fallback. @@ -546,7 +554,7 @@ pub(crate) fn write_output( /// /// Returns `Ok(path)` if exactly one `.mds` file is found, or an `Err` describing /// why auto-detection failed (zero files, multiple files, or I/O error). -pub(crate) fn auto_detect_mds_file() -> Result { +pub(crate) fn auto_detect_mds_file(subcommand: &str) -> Result { let cwd = std::env::current_dir() .map_err(|e| miette::miette!("cannot determine current directory: {e}"))?; @@ -573,7 +581,7 @@ pub(crate) fn auto_detect_mds_file() -> Result { names.sort(); Err(miette::miette!( "multiple .mds files found: {}\n \ - hint: specify which file to compile, e.g. 'mds build {}'", + hint: specify which file, e.g. 'mds {subcommand} {}'", names.join(", "), names.first().map(|s| s.as_str()).unwrap_or(".mds"), )) @@ -740,11 +748,14 @@ pub(crate) struct BuildArgs { /// Resolve the input path: use the explicit value, or auto-detect from cwd. /// +/// `subcommand` is the CLI verb (e.g. `"build"`, `"lint"`, `"fmt"`, `"check"`, `"watch"`) +/// used in the auto-detect error hint so the user sees a correct example command. +/// /// Returns `(path, auto_detected)`. -pub(crate) fn resolve_input(input: Option) -> Result<(PathBuf, bool)> { +pub(crate) fn resolve_input(input: Option, subcommand: &str) -> Result<(PathBuf, bool)> { match input { Some(p) => Ok((p, false)), - None => auto_detect_mds_file().map(|p| (p, true)), + None => auto_detect_mds_file(subcommand).map(|p| (p, true)), } } @@ -834,154 +845,108 @@ pub(crate) fn embed_carrier(content: String, map_json: &str) -> String { format!("{base}{sep}{}\n", carrier_line(map_json)) } -/// Compute a relative path from `base_dir` to `target` using forward slashes. +/// Compute the directory to use as `source_map_base` in [`mds::CompileOptions`]. /// -/// Used to relativize `sources[]` entries in the source map so they are -/// map-relative rather than absolute (AC-FUNC-05 / AC-SEC-01). +/// This is the map-file directory: the anchor against which core's +/// `relativize_source` relativizes every `sources[]` entry (ADR-005 / PF-004 — +/// single choke-point in core). Must be called BEFORE constructing +/// `CompileOptions` so `source_map_base` can be set on the options struct. /// -/// Falls back to the absolute path (forward-slash converted) when a relative -/// path cannot be computed (e.g. different drive roots on Windows). -pub(crate) fn relative_path(base_dir: &Path, target: &Path) -> String { - // Use `pathdiff` logic inline so we don't add a dependency. - // Build the relative path by walking up from base_dir to the common ancestor, - // then down to target. - let base = base_dir; - let mut base_comps: Vec<_> = base.components().collect(); - let mut target_comps: Vec<_> = target.components().collect(); - - // Strip common prefix. - let common = base_comps - .iter() - .zip(target_comps.iter()) - .take_while(|(b, t)| b == t) - .count(); - base_comps.drain(..common); - target_comps.drain(..common); - - if base_comps.is_empty() && target_comps.is_empty() { - return ".".to_string(); - } - - let mut parts: Vec = Vec::new(); - for _ in &base_comps { - parts.push("..".to_string()); - } - for c in &target_comps { - parts.push(c.as_os_str().to_string_lossy().into_owned()); - } - - let rel = parts.join("/"); - // Guard: if rel somehow became empty use ".". - if rel.is_empty() { - ".".to_string() - } else { - rel - } -} - -/// Relativize and sanitize a single source path for use in `sources[]`. +/// The result is always absolutized against the current working directory so +/// that core's root-containment check works correctly against the absolute +/// project root. /// -/// Rules (AC-FUNC-05 / AC-SEC-01 / PF-003): -/// 1. `` sentinel → relabeled to `` (AC-FUNC-12) when -/// the caller explicitly passes `stdin_label = true`. -/// 2. Windows `\\?\` verbatim-prefix paths → stripped before further processing. -/// 3. Absolute paths → relativized against `map_dir`. -/// 4. Relative paths → left as-is (already relative). -/// 5. All backslashes → forward slashes (AC-FUNC-05). -/// 6. Result MUST NOT be an absolute path (enforced by assertion). -pub(crate) fn relativize_source_path(source: &str, map_dir: &Path, stdin_label: bool) -> String { - // Rule 1: stdin sentinel relabeling. - if source == "" && stdin_label { - return "".to_string(); - } - // Pass through non-path sentinels unchanged (e.g. "" in non-stdin builds). - if source.starts_with('<') && source.ends_with('>') { - return source.to_string(); - } - - // Rule 2: strip Windows \\?\ verbatim prefix (PF-003 / AC-SEC-01). - let stripped = source.strip_prefix(r"\\?\").unwrap_or(source); - - // Normalize to a Path. - let p = Path::new(stripped); - - let result = if p.is_absolute() { - // Rule 3: relativize the absolute source against the map directory. - // `map_dir` derives from the caller's `-o` value and may itself be - // relative (or empty when `-o` is a bare filename). Absolutize it - // against the CWD first so both paths share one coordinate space — - // otherwise the component diff embeds the source's absolute path - // verbatim (`..//abs/...`) or produces a leading-`/` result, leaking - // the filesystem path into sources[] (AC-SEC-01). - let abs_map_dir = if map_dir.is_absolute() { - map_dir.to_path_buf() +/// Mirrors the output-directory rules of [`resolve_output_path_for_kind`]: +/// - `-o -` or stdin-with-no-output → current working directory. +/// - `-o ` → directory of that file (absolutized if relative). +/// - `--out-dir ` → that directory (absolutized if relative). +/// - mds.json `output_dir` → config-directory-relative. +/// - Default → beside the source file. +fn compute_source_map_base( + input: &Path, + output: &Option, + out_dir: &Option, + config: &Option<(MdsConfig, PathBuf)>, +) -> Option { + let cwd = || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let abs = |p: PathBuf| -> PathBuf { + if p.is_absolute() { + p } else { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(map_dir) - }; - relative_path(&abs_map_dir, p) - } else { - // Rule 4: already relative — convert separators only. - stripped.replace('\\', "/") + cwd().join(p) + } }; - // AC-SEC-01: never leak an absolute path into sources[]. The relativization - // above yields a relative path for same-root inputs; as defense-in-depth for - // exotic cross-root cases (e.g. different Windows drives), degrade a - // still-absolute result to the bare file name rather than panicking or - // leaking a filesystem path. Runtime guard (not debug_assert) so the - // guarantee holds in release builds too. - // - // SEC-2 (guards PF-005 theme / AC-SEC-01): broaden the Windows drive-path - // guard beyond the backslash-only check. `relative_path` and Rule 4 both - // normalise separators to `/`, so a forward-slashed drive path such as - // `C:/secret/foo.mds` or a cross-drive relative result like `../../D:/x.mds` - // contains `:/` rather than `:\\` and slipped through the old guard. The - // three conditions below together catch all three forms: - // • `:\` — classic backslash-qualified Windows drive path - // • `:/` — forward-slash-normalised Windows drive path (SEC-2 gap) - // • leading `:` — bare drive designator without a separator - let is_drive_qualified = |s: &str| -> bool { - s.contains(":\\") - || s.contains(":/") - || (s.len() >= 2 - && (s.as_bytes()[0] as char).is_ascii_alphabetic() - && s.as_bytes()[1] == b':') - }; - if result.starts_with('/') || is_drive_qualified(&result) { - return Path::new(stripped) - .file_name() - .map(|n| n.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|| "source".to_string()); + match output.as_deref() { + Some("-") => { + // -o - : stdout; relativize against CWD (PF-005: unconditional). + Some(cwd()) + } + Some(o) => { + // -o : map lives beside the output file. + // effective_parent maps "" (bare filename) to "." — PF-006. + Some(abs(effective_parent(Path::new(o)).to_path_buf())) + } + None => { + if let Some(dir) = out_dir { + // --out-dir : absolutize if relative. + Some(abs(dir.clone())) + } else if input == Path::new("-") { + // Stdin with no -o or --out-dir → stdout → relativize against CWD. + Some(cwd()) + } else if let Some((cfg, config_dir)) = config { + if let Some(ref output_dir) = cfg.build.output_dir { + // mds.json output_dir: config-directory-relative. `config_dir` + // is canonical (load_config canonicalizes before walking up) so + // the join is already absolute in practice; `abs` makes the + // "result is always absolutized" contract above structural + // rather than incidental — a relative base would silently + // demote core's map-relative emission to root-relative. + Some(abs(config_dir.join(output_dir))) + } else { + // Default: beside the source file. + Some(abs(effective_parent(input).to_path_buf())) + } + } else { + // No config, no -o, no --out-dir: beside the source file. + Some(abs(effective_parent(input).to_path_buf())) + } + } } - - result } -/// Relativize `sources[]` and set `file` in a [`mds::SourceMap`] in place. +/// Set the SMv3 `file` field and relabel the stdin source in a [`mds::SourceMap`]. +/// +/// These are the CLI's two genuinely CLI-only post-processing jobs after core +/// has already applied `relativize_source` at both finalize sites (ADR-005 / +/// PF-004 — single choke-point in core): /// -/// Must be called before writing the map to disk or embedding it inline. -/// - `output_path`: the path where the compiled output is written (used to -/// compute the map directory and the `file` basename). -/// - When `output_path` is `None` (stdout), sources are left as-is and -/// `file` remains as set by the core (no map-relative anchor exists). -pub(crate) fn relativize_source_map_fields( +/// 1. `sm.file = output_basename` — the SMv3 `file` field names the generated +/// artifact; core always emits `file: None` because it has no notion of the +/// output path. +/// 2. The `` relabel: maps `STRING_SOURCE_MAP_LABEL` (`"input.mds"`) → +/// `""` for stdin builds. Core canonicalizes the source entry at +/// `MapBuilder::new`/`source_index`, so the exact-string check here always +/// matches the canonicalized value. This is a pure label swap — no path +/// logic. +pub(crate) fn apply_source_map_file_label( sm: &mut mds::SourceMap, output_path: Option<&Path>, stdin_label: bool, ) { - let Some(out) = output_path else { - return; - }; - let map_dir = out.parent().unwrap_or(Path::new(".")); - - // Set `file` to the output basename. - sm.file = out.file_name().map(|n| n.to_string_lossy().into_owned()); + // Job 1: set the `file` field for file output. + if let Some(out) = output_path { + sm.file = out.file_name().map(|n| n.to_string_lossy().into_owned()); + } + // `sm.file` stays None for stdout output (no output filename to anchor). - // Relativize each source. - for src in &mut sm.sources { - *src = relativize_source_path(src, map_dir, stdin_label); + // Job 2: relabel the stdin source entry. + if stdin_label { + for src in &mut sm.sources { + if src == STRING_SOURCE_MAP_LABEL { + *src = "".to_string(); + } + } } } @@ -1058,7 +1023,7 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { // Resolve the input: explicit path, or auto-detect from cwd. // When auto-detected, print a "Building {path}" banner so users know which file was selected. - let (input, auto_detected) = resolve_input(input)?; + let (input, auto_detected) = resolve_input(input, "build")?; if auto_detected && !quiet { eprintln!("Building {}", input.display()); } @@ -1123,16 +1088,11 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { ); } - // Inline + stdout is not supported (there is no file to embed the carrier into). - if use_source_map && inline && output.as_deref() == Some("-") { - return Err(miette::miette!( - "--inline cannot be used with -o - (stdout has no file to embed the carrier into)" - )); - } - + let source_map_base = compute_source_map_base(Path::new("-"), &output, &out_dir, &None); let opts = mds::CompileOptions { source_map: use_source_map, include_sources_content: use_embed_sources, + source_map_base, }; let (source, cwd) = read_stdin()?; @@ -1153,8 +1113,8 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { resolve_output_path_for_kind(&Some(input), &output, &out_dir, &None, kind, quiet)?; if let Some(ref mut sm) = source_map { - // Relabel for stdin builds (AC-FUNC-12). - relativize_source_map_fields(sm, output_path.as_deref(), true); + // Set `file` field and relabel source entry for stdin builds (AC-FUNC-12). + apply_source_map_file_label(sm, output_path.as_deref(), true); } if use_source_map { @@ -1222,9 +1182,11 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { ); } + let source_map_base = compute_source_map_base(&input, &output, &out_dir, &config); let opts = mds::CompileOptions { source_map: use_source_map, include_sources_content: use_embed_sources, + source_map_base, }; let compiled = compile_to_content(&input, runtime_vars, quiet, opts)?; @@ -1239,7 +1201,7 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { let mut source_map = compiled.source_map; if let Some(ref mut sm) = source_map { - relativize_source_map_fields(sm, output_path.as_deref(), false); + apply_source_map_file_label(sm, output_path.as_deref(), false); } if use_source_map { @@ -1332,8 +1294,8 @@ fn run_build_directory( inline: bool, ) -> Result<()> { use crate::output::{ - canonicalize_out_dir, collect_mds_files, is_partial, output_base_no_ext, output_path_for, - probe_and_remove_stale, resolve_output_base, OutputBase, + canonicalize_out_dir, collect_mds_files_detailed, is_partial, output_base_no_ext, + output_path_for, probe_and_remove_stale, resolve_output_base, OutputBase, }; const MAX_DEPTH: usize = 64; @@ -1358,20 +1320,28 @@ fn run_build_directory( _ => None, }; - let files = collect_mds_files(dir, MAX_DEPTH, exclude_prefix.as_deref()); + let walk = collect_mds_files_detailed(dir, MAX_DEPTH, exclude_prefix.as_deref()); + let files = walk.files; if files.is_empty() { + if walk.excluded_by_default > 0 { + // All candidates were inside default-excluded directories. Emit the + // diagnostic even under --quiet (avoids a silent CI green pass — avoids + // PF-004 enforcement gap where the limit is real on one path and absent + // on another). + eprintln!( + "{} .mds file(s) found but all are under default-excluded directories \ + (hidden dirs, node_modules); nothing was built", + walk.excluded_by_default + ); + std::process::exit(1); + } if !quiet { eprintln!("No .mds files found in {}", dir.display()); } return Ok(()); } - let opts = mds::CompileOptions { - source_map, - include_sources_content: embed_sources, - }; - let mut ok_count: usize = 0; let mut fail_count: usize = 0; // Track paths successfully written in this build run so the stale-cleanup @@ -1389,8 +1359,23 @@ fn run_build_directory( continue; } + // Per-file source_map_base: the output directory for this file, computed + // from the kind-independent directory oracle (avoids calling + // prepare_output_dir_for_kind here — an early create_dir_all would leave + // an empty directory on compile failure; Step 6 Caveat 1 / PF-004). + let base_no_ext = output_base_no_ext(file, dir, &output_base); + let source_map_base = base_no_ext + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + let opts = mds::CompileOptions { + source_map, + include_sources_content: embed_sources, + source_map_base: Some(source_map_base), + }; + // Compile (all reads go through mds-core which enforces MAX_FILE_SIZE — PF-004). - match compile_to_content(file, runtime_vars.clone(), quiet, opts.clone()) { + match compile_to_content(file, runtime_vars.clone(), quiet, opts) { Ok(mut compiled) => { let ext = compiled.kind.extension(); let out_path = output_path_for(file, dir, &output_base, ext); @@ -1409,9 +1394,9 @@ fn run_build_directory( } } - // Relativize source map fields for this output path. + // Set `file` field for this output path (sources already relativized by core). if let Some(ref mut sm) = compiled.source_map { - relativize_source_map_fields(sm, Some(&out_path), false); + apply_source_map_file_label(sm, Some(&out_path), false); } // Determine final content (inline embeds the carrier). @@ -1477,7 +1462,9 @@ fn run_build_directory( } } Err(e) => { - eprintln!("{e:?}"); + // Sanitize at the render boundary: MdsError::Syntax embeds user-controlled + // source fragments that may contain raw ESC bytes (avoids terminal escape injection). + eprintln!("{}", sanitize_control_chars(&format!("{e:?}"))); fail_count += 1; } } @@ -1497,6 +1484,75 @@ fn run_build_directory( mod tests { use super::*; + // ── compute_source_map_base ─────────────────────────────────────────────── + // + // `source_map_base` is the anchor core's `relativize_source` uses to emit + // map-relative `sources[]` (ADR-005). Core resolves it against an ABSOLUTE + // project root, so a relative base fails the containment check and silently + // demotes the result to root-relative — a `sources[]` entry that no longer + // resolves from the map file's directory. The invariant is therefore + // "every branch returns Some(absolute)", asserted here per branch because + // the failure mode is silent (wrong paths, not an error). + + #[test] + fn source_map_base_is_absolute_for_every_output_mode() { + let input = PathBuf::from("src/a.mds"); + let cases: Vec<(&str, Option, Option)> = vec![ + ("-o - (stdout)", Some("-".to_string()), None), + ( + "-o relative/out.md", + Some("relative/out.md".to_string()), + None, + ), + ("-o bare.md", Some("bare.md".to_string()), None), + ("-o /abs/out.md", Some("/abs/out.md".to_string()), None), + ("--out-dir relative", None, Some(PathBuf::from("dist"))), + ("--out-dir /abs", None, Some(PathBuf::from("/abs/dist"))), + ("default (beside source)", None, None), + ]; + for (label, output, out_dir) in cases { + let got = compute_source_map_base(&input, &output, &out_dir, &None) + .unwrap_or_else(|| panic!("{label}: source_map_base must never be None")); + assert!( + got.is_absolute(), + "{label}: source_map_base must be absolute so core's root-containment \ + check succeeds; got {got:?}" + ); + } + } + + #[test] + fn source_map_base_for_bare_filename_output_is_cwd() { + // PF-006: Path::parent() of a bare filename is Some("") not None, so a + // naive parent() would yield an empty (relative) base here. + let got = compute_source_map_base( + Path::new("src/a.mds"), + &Some("out.md".to_string()), + &None, + &None, + ) + .expect("bare -o must still produce a base"); + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + got, cwd, + "`-o out.md` writes into the CWD, so the map base is the CWD" + ); + } + + #[test] + fn source_map_base_tracks_the_output_file_directory() { + // The map is written beside the output file, so the base must be the + // output file's directory — this is what makes sources[] map-relative. + let got = compute_source_map_base( + Path::new("src/a.mds"), + &Some("dist/nested/a.md".to_string()), + &None, + &None, + ) + .expect("base must be Some"); + assert_eq!(got, std::env::current_dir().unwrap().join("dist/nested")); + } + #[test] fn parse_cli_value_nan_is_string() { // "NaN".parse::() succeeds but is not finite — must fall through to string. @@ -1788,84 +1844,6 @@ mod tests { ); } - // ── AC-SEC-01: source-path relativization must never leak an absolute path ── - - #[test] - fn relativize_absolute_source_with_absolute_mapdir_is_clean_relative() { - // Both absolute, sharing a common ancestor → clean map-relative path. - let got = relativize_source_path("/proj/src/a.mds", Path::new("/proj/build"), false); - assert_eq!( - got, "../src/a.mds", - "same-root absolute paths relativize cleanly" - ); - } - - #[test] - fn relativize_absolute_source_with_relative_mapdir_never_leaks_absolute() { - // Regression: a relative `-o` (relative map_dir) diffed against an - // absolute source previously produced `..//abs/...` (or a leading-'/' - // result that panicked the debug_assert), leaking the absolute path. - // After absolutizing map_dir against the CWD the result must be a clean - // relative path — never absolute, never an embedded absolute prefix. - let got = - relativize_source_path("/tmp/deep/nested/abs_input.mds", Path::new("build"), false); - assert!(!got.starts_with('/'), "must not be absolute: {got}"); - assert!( - !got.contains("//"), - "must not embed a raw absolute path: {got}" - ); - assert!( - !got.contains(":\\"), - "must not embed a Windows drive path: {got}" - ); - assert!( - got.ends_with("abs_input.mds"), - "must still resolve to the source file: {got}" - ); - } - - #[test] - fn relativize_absolute_source_with_empty_mapdir_never_leaks_absolute() { - // `-o out.md` (bare filename) yields an EMPTY map_dir. Previously this - // produced a leading-'/' result (`//abs/...`) that panicked in debug and - // leaked an absolute path in release. Must now be a clean relative path. - let got = relativize_source_path("/tmp/deep/abs_input.mds", Path::new(""), false); - assert!(!got.starts_with('/'), "must not be absolute: {got}"); - assert!( - !got.contains("//"), - "must not embed a raw absolute path: {got}" - ); - assert!( - got.ends_with("abs_input.mds"), - "must still resolve to the source file: {got}" - ); - } - - // SEC-2: forward-slashed Windows drive paths must also degrade to filename. - // (The old guard only checked ":\\" — "C:/" slipped through.) - - #[test] - fn relativize_forward_slashed_drive_path_degrades_to_filename() { - // `C:/secret/foo.mds` on Unix is not seen as absolute by Path, so it - // went through Rule 4 unchanged and slipped past the old `":\\"` guard. - let got = relativize_source_path("C:/secret/foo.mds", Path::new("build"), false); - assert_eq!( - got, "foo.mds", - "forward-slashed drive path must degrade to filename: {got}" - ); - } - - #[test] - fn relativize_cross_drive_relative_path_degrades_to_filename() { - // A cross-drive path suffix (`../../D:/x.mds`) that passes through - // Rule 4 contains `:/ ` not `:\\` and previously slipped through. - let got = relativize_source_path("../../D:/x.mds", Path::new("build"), false); - assert_eq!( - got, "x.mds", - "cross-drive :/ path must degrade to filename: {got}" - ); - } - // ── AC-SEC-03: inline carrier is a self-contained, idempotent HTML comment ── #[test] diff --git a/crates/mds-cli/src/fmt.rs b/crates/mds-cli/src/fmt.rs index 19c9932e..17dde11d 100644 --- a/crates/mds-cli/src/fmt.rs +++ b/crates/mds-cli/src/fmt.rs @@ -25,11 +25,12 @@ use std::io::{IsTerminal, Write as _}; use std::path::{Path, PathBuf}; -use mds::{FileSystem, MdsError}; +use mds::{effective_parent, FileSystem, MdsError}; use miette::Result; use crate::build::{load_config, read_stdin, resolve_input}; -use crate::output::collect_mds_files; +use crate::output::atomic_write_file; +use crate::output::collect_mds_files_detailed; pub(crate) struct FmtArgs { pub(crate) input: Option, @@ -55,7 +56,7 @@ pub(crate) fn run_fmt(args: FmtArgs) -> Result<()> { quiet, } = args; - let (input, auto_detected) = resolve_input(input)?; + let (input, auto_detected) = resolve_input(input, "fmt")?; if auto_detected && !quiet { eprintln!("Formatting {}", input.display()); } @@ -125,8 +126,17 @@ struct FmtResult { changed: bool, } -fn format_source(source: &str, base_dir: Option<&Path>) -> Result { - let formatted = mds::format_str_with(source, base_dir).map_err(miette::Error::from)?; +/// Format `source` and return a [`FmtResult`] with change detection. +/// +/// `file_name` is threaded into lexer and safety-gate errors so diagnostics +/// name the file rather than showing a blank path. +fn format_source_named( + source: &str, + base_dir: Option<&Path>, + file_name: &str, +) -> Result { + let formatted = + mds::format_str_named(source, base_dir, file_name).map_err(miette::Error::from)?; let changed = formatted != source; Ok(FmtResult { formatted, changed }) } @@ -136,7 +146,7 @@ fn format_source(source: &str, base_dir: Option<&Path>) -> Result { fn run_fmt_stdin(flags: FmtFlags) -> Result<()> { let FmtFlags { check, diff, quiet } = flags; let (source, cwd) = read_stdin()?; - let result = format_source(&source, Some(&cwd))?; + let result = format_source_named(&source, Some(&cwd), "")?; if diff { print_diff(&render_diff(&source, &result.formatted, ""))?; @@ -159,8 +169,13 @@ fn run_fmt_stdin(flags: FmtFlags) -> Result<()> { fn run_fmt_file(path: &Path, flags: FmtFlags) -> Result<()> { let FmtFlags { check, diff, quiet } = flags; let source = read_source_file(path)?; - let base_dir = path.parent(); - let result = format_source(&source, base_dir)?; + // effective_parent maps "" (bare filename) to "." so that resolve_base_dir + // (called by format_str_named → assert_equivalent) receives a canonicalisable + // path and does not silently fall through to the structural_equivalent fallback + // that would swallow a genuine mds::syntax error. avoids PF-006, applies ADR-001. + let base_dir = Some(effective_parent(path)); + let file_name = path.display().to_string(); + let result = format_source_named(&source, base_dir, &file_name)?; if diff && result.changed { let label = path.display().to_string(); @@ -170,8 +185,10 @@ fn run_fmt_file(path: &Path, flags: FmtFlags) -> Result<()> { let read_only = check || diff; if !read_only { if result.changed { - std::fs::write(path, &result.formatted) - .map_err(|e| miette::miette!("cannot write {}: {e}", path.display()))?; + // Atomic write preserves file permissions and avoids truncate-then-write + // data loss on crash or full disk (avoids the issue fixed for lint by + // commit c5aa086 — both write paths now share the same helper). + atomic_write_file(path, &result.formatted)?; if !quiet { eprintln!("Formatted: {}", path.display()); } @@ -221,26 +238,32 @@ enum FileOutcome { /// read and format errors are treated in the surrounding loop. fn format_one_file(file: &Path, flags: FmtFlags) -> FileOutcome { let FmtFlags { check, diff, quiet } = flags; + let file_name = file.display().to_string(); let source = match read_source_file(file) { Ok(s) => s, Err(e) => { - eprintln!("{e:?}"); + // File path is embedded in the miette report; sanitize for ESC injection safety + // (avoids PF-004 parallel-path gap — uses the shared render helper). + crate::output::eprint_error(e); return FileOutcome::Failed; } }; - let base_dir = file.parent(); - let result = match format_source(&source, base_dir) { + // effective_parent maps "" (bare filename) to "." — avoids PF-006, applies ADR-001. + let base_dir = Some(effective_parent(file)); + let result = match format_source_named(&source, base_dir, &file_name) { Ok(r) => r, Err(e) => { - eprintln!("{e:?}"); + // MdsError::Syntax embeds user-controlled source fragments that may contain + // raw ESC bytes; file_name is threaded into the report by format_source_named. + crate::output::eprint_error(e); return FileOutcome::Failed; } }; if diff && result.changed { - let label = file.display().to_string(); + let label = file_name.clone(); if let Err(e) = print_diff(&render_diff(&source, &result.formatted, &label)) { - eprintln!("{e:?}"); + crate::output::eprint_error(e); return FileOutcome::Failed; } } @@ -255,7 +278,10 @@ fn format_one_file(file: &Path, flags: FmtFlags) -> FileOutcome { } else if !result.changed { FileOutcome::Unchanged } else { - match std::fs::write(file, &result.formatted) { + // Atomic write preserves file permissions and avoids truncate-then-write + // data loss on crash or full disk — same guarantee as lint --fix (avoids + // the divergence introduced after commit c5aa086 hardened the lint path). + match atomic_write_file(file, &result.formatted) { Ok(()) => { if !quiet { eprintln!("Formatted: {}", file.display()); @@ -263,7 +289,7 @@ fn format_one_file(file: &Path, flags: FmtFlags) -> FileOutcome { FileOutcome::Formatted } Err(e) => { - eprintln!("error: cannot write {}: {e}", file.display()); + crate::output::eprint_error(e); FileOutcome::Failed } } @@ -290,9 +316,19 @@ fn run_fmt_directory(dir: &Path, flags: FmtFlags) -> Result<()> { // malformed config rather than silently ignoring it. let _ = load_config(dir)?; - let files = collect_mds_files(dir, MAX_DEPTH, None); + let walk = collect_mds_files_detailed(dir, MAX_DEPTH, None); + let files = walk.files; if files.is_empty() { + if walk.excluded_by_default > 0 { + // Always emit — not suppressed by --quiet (avoids silent CI green pass). + eprintln!( + "{} .mds file(s) found but all are under default-excluded directories \ + (hidden dirs, node_modules); nothing was formatted", + walk.excluded_by_default + ); + std::process::exit(1); + } if !flags.quiet { eprintln!("No .mds files found in {}", dir.display()); } @@ -320,7 +356,9 @@ fn run_fmt_directory(dir: &Path, flags: FmtFlags) -> Result<()> { // the single-file --check path (which is fully silent under --quiet, // exiting 1 with no message when a file would change). if !flags.quiet || fail_count > 0 { - eprintln!("{changed_count} would reformat, {fail_count} failed"); + eprintln!( + "{changed_count} would reformat, {unchanged_count} unchanged, {fail_count} failed" + ); } } else if !flags.quiet || fail_count > 0 { eprintln!("{changed_count} formatted, {unchanged_count} unchanged, {fail_count} failed"); @@ -447,14 +485,15 @@ mod tests { #[test] fn format_source_detects_no_change() { - let result = format_source("Hello!\n", None).unwrap(); + let result = format_source_named("Hello!\n", None, "").unwrap(); assert!(!result.changed); assert_eq!(result.formatted, "Hello!\n"); } #[test] fn format_source_detects_change() { - let result = format_source("Hello!\r\n\r\n\r\n\r\nBye.\r\n", None).unwrap(); + let result = + format_source_named("Hello!\r\n\r\n\r\n\r\nBye.\r\n", None, "").unwrap(); assert!(result.changed); assert!(!result.formatted.contains('\r')); } diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index d3575606..c4b4104c 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -27,11 +27,11 @@ use std::io::{IsTerminal, Write as _}; use std::path::{Path, PathBuf}; -use mds::{FileSystem, MdsError, NativeFs, Severity}; +use mds::{effective_parent, FileSystem, MdsError, NativeFs, Severity}; use miette::Result; use crate::build::{build_runtime_vars, load_config, read_stdin, resolve_input, RuntimeVarArgs}; -use crate::output::collect_mds_files; +use crate::output::{atomic_write_file, collect_mds_files_detailed}; /// Known lint rule names — used to warn about unknown names in mds.json config. const KNOWN_RULES: &[&str] = &[ @@ -85,7 +85,7 @@ pub(crate) fn run_lint(args: LintArgs) -> Result<()> { match do_lint(args) { Ok(()) => Ok(()), Err(e) => { - eprintln!("{e:?}"); + eprintln!("{}", mds::sanitize_control_chars(&format!("{e:?}"))); std::process::exit(2); } } @@ -119,7 +119,7 @@ fn do_lint(args: LintArgs) -> Result<()> { set_string_vars, })?; - let (input, _auto_detected) = resolve_input(input)?; + let (input, _auto_detected) = resolve_input(input, "lint")?; // USAGE ERROR: --fix + --format json + stdin (AC-F-22b). // DELIBERATE EXCEPTION (AC-F-14): the JSON envelope is deferred for this 3-way combo; @@ -194,6 +194,23 @@ fn load_lint_config(dir: &Path) -> Result { } } +// ── Display-path remap ──────────────────────────────────────────────────────── + +/// Remap the `file` field in every diagnostic in `result` to `display`. +/// +/// `mds::lint(path, …)` sets each diagnostic's `file` to the file's basename +/// (via `path.file_name()`). In directory mode the same basename appears for +/// every file, so the JSON output groups all findings under the same key. +/// This function replaces the field with the caller-supplied relative path so +/// the JSON output uses distinct, navigable paths. +/// +/// Call this immediately after every `mds::lint` that runs in directory mode. +fn set_diag_display_path(result: &mut mds::LintResult, display: &str) { + for diag in &mut result.diagnostics { + diag.file = Some(display.to_string()); + } +} + // ── Read source file ────────────────────────────────────────────────────────── /// Read raw source of `path`: symlink-checked and size-capped (mirrors fmt.rs). @@ -321,34 +338,6 @@ fn exit_by_severity(result: &mds::LintResult) { } } -// ── Atomic write ────────────────────────────────────────────────────────────── - -/// Write `content` to `path` atomically via a temp file in the same directory. -/// -/// Re-checks for symlink immediately before the write cycle (TOCTOU protection, -/// AC-F-21). Uses `tempfile::Builder` for the temp file so cleanup is automatic -/// on drop if the rename fails. -fn atomic_write_file(path: &Path, content: &str) -> Result<()> { - // Re-check for symlink right before writing (TOCTOU guard). - NativeFs::check_symlink(path).map_err(miette::Error::from)?; - - let parent = path.parent().unwrap_or(Path::new(".")); - // Temp file in same directory so rename is always intra-filesystem. - let mut tmp = tempfile::Builder::new() - .prefix(".mds-lint-fix-") - .suffix(".tmp") - .tempfile_in(parent) - .map_err(|e| miette::miette!("cannot create temp file in {}: {e}", parent.display()))?; - tmp.write_all(content.as_bytes()) - .map_err(|e| miette::miette!("cannot write temp file: {e}"))?; - tmp.flush() - .map_err(|e| miette::miette!("cannot flush temp file: {e}"))?; - // persist() atomically renames the temp file to the target path. - tmp.persist(path) - .map_err(|e| miette::miette!("cannot rename temp file to {}: {e}", path.display()))?; - Ok(()) -} - // ── Fix pipeline helpers ────────────────────────────────────────────────────── /// Outcome of the `--fix` pipeline for one file. @@ -357,6 +346,18 @@ enum FixFileOutcome { new_source: String, residual: mds::LintResult, }, + /// Some edits applied, some individually rejected by the per-edit reverify gate. + /// + /// Produced when `apply_fixes_incremental` falls back to the per-edit path and not + /// all edits pass. The `new_source` is the partially-fixed text; `residual` carries + /// remaining diagnostics (from the last successful per-edit reverify). + /// `applied_count` / `total_count` are used for the summary line. + PartiallyFixed { + new_source: String, + residual: mds::LintResult, + applied_count: usize, + total_count: usize, + }, Rejected { reason: String, original: mds::LintResult, @@ -391,10 +392,17 @@ fn plan_and_apply_fixes( let is_standalone = result.is_standalone; let plan = mds::fix::plan_fixes_with_options(&result, source, is_standalone); - if plan.edits.is_empty() { + // Treat overlap_rejected as "something to do" — don't short-circuit as NothingToFix + // when edits were rejected due to overlap. The incremental fallback (per-edit retry) + // handles individual edits that survive the overlap check. + if plan.edits.is_empty() && !plan.overlap_rejected { return FixFileOutcome::NothingToFix { original: result }; } + // Capture total edit count before moving plan into apply_fixes_incremental. + // Used to compute the "{applied} of {total}" summary for PartiallyFixed output. + let total_edits = plan.edits.len(); + // AC-F-20 output-delta baseline: compile the original source once. // If it fails (e.g. missing runtime vars at eval time), skip the output-diff — // existing gates (recompile-success, no-new-diagnostics) still apply. @@ -405,7 +413,11 @@ fn plan_and_apply_fixes( let base_dir_owned = base_dir.to_path_buf(); let config_clone = config.clone(); - let outcome = mds::fix::apply_fixes(source, plan, &result, move |fixed| { + // apply_fixes_incremental requires F: Fn (not FnOnce) — the reverify closure + // may be called up to plan.edits.len()+1 times (batch attempt + per-edit fallback). + // All captured variables are either borrowed (&) or cloned inside, so the closure + // satisfies Fn without any extra effort. + let outcome = mds::fix::apply_fixes_incremental(source, plan, &result, |fixed| { let residual = mds::lint_str_with( fixed, Some(&base_dir_owned), @@ -425,7 +437,7 @@ fn plan_and_apply_fixes( Ok(fixed_compile) if fixed_compile.output != *orig_out => { return Err(MdsError::Io { message: "lint --fix would change compiled output; \ - batch refused to preserve template semantics" + edit reverted to preserve template semantics" .to_string(), }); } @@ -444,11 +456,116 @@ fn plan_and_apply_fixes( new_source, residual, }, + mds::fix::FixOutcome::PartiallyFixed { + source: new_source, + residual, + rejected, + } => FixFileOutcome::PartiallyFixed { + new_source, + residual, + applied_count: total_edits - rejected.len(), + total_count: total_edits, + }, mds::fix::FixOutcome::Rejected { source: _, reason } => FixFileOutcome::Rejected { reason, original: result, }, mds::fix::FixOutcome::NothingToFix => FixFileOutcome::NothingToFix { original: result }, + // FixOutcome is #[non_exhaustive]: a wildcard arm is required when + // matching from outside mds-core. New variants added in future + // releases should be plumbed here; until then, treat them as no-ops. + _ => FixFileOutcome::NothingToFix { original: result }, + } +} + +/// Outcome of the preview fix pipeline (`--fix --check` / `--fix --diff`). +/// +/// Distinguishes "would fix", "rejected by reverify gate", and "nothing to fix" +/// so that callers can surface rejection reasons in `--fix --check` output +/// (PF-004: preview must use the same gated pipeline as apply and be equally honest +/// about outcomes). +enum PreviewOutcome { + /// At least one edit would be applied; contains the would-be fixed source. + WouldFix(String), + /// Every edit was refused by the reverify gate (overlap or recompile failure); + /// contains the human-readable rejection reason. + Rejected(String), + /// No fixable edits exist or the plan is empty with no overlap. + NothingToFix, +} + +/// Run the fix pipeline in preview mode (for `--fix --check` / `--fix --diff`). +/// +/// Routes through the same gated pipeline as the write path — plan, reverify, and +/// apply — but does NOT write to disk. Returns [`PreviewOutcome::WouldFix`] with +/// the would-be fixed source when at least one edit would be applied; +/// [`PreviewOutcome::Rejected`] with the rejection reason when the reverify gate +/// refused every edit; [`PreviewOutcome::NothingToFix`] when the plan is empty. +/// +/// ADR-004 / PF-004: preview must use the same gated pipeline as apply so that +/// `--diff` shows exactly what `--fix` would write, and `--check` exits 1 iff +/// `--fix` would change the file. The `Rejected` variant gives `--fix --check` +/// the same honesty as `--fix` (which prints "fix rejected: …" on refusal). +fn preview_fixes( + result: &mds::LintResult, + source: &str, + base_dir: &Path, + runtime_vars: Option>, + config: &mds::LintConfig, +) -> PreviewOutcome { + let is_standalone = result.is_standalone; + let plan = mds::fix::plan_fixes_with_options(result, source, is_standalone); + + if plan.edits.is_empty() && !plan.overlap_rejected { + return PreviewOutcome::NothingToFix; + } + + let original_output = + mds::compile_str_collecting_warnings(source, Some(base_dir), runtime_vars.clone()) + .ok() + .map(|r| r.output); + + let base_dir_owned = base_dir.to_path_buf(); + let config_clone = config.clone(); + let outcome = mds::fix::apply_fixes_incremental(source, plan, result, |fixed| { + let residual = mds::lint_str_with( + fixed, + Some(&base_dir_owned), + runtime_vars.clone(), + &config_clone, + )?; + + if let Some(ref orig_out) = original_output { + match mds::compile_str_collecting_warnings( + fixed, + Some(&base_dir_owned), + runtime_vars.clone(), + ) { + Ok(fixed_compile) if fixed_compile.output != *orig_out => { + return Err(MdsError::Io { + message: "lint --fix would change compiled output; \ + edit reverted to preserve template semantics" + .to_string(), + }); + } + _ => {} + } + } + + Ok(residual) + }); + + match outcome { + mds::fix::FixOutcome::Fixed { + source: new_source, .. + } + | mds::fix::FixOutcome::PartiallyFixed { + source: new_source, .. + } => PreviewOutcome::WouldFix(new_source), + mds::fix::FixOutcome::Rejected { reason, .. } => PreviewOutcome::Rejected(reason), + mds::fix::FixOutcome::NothingToFix => PreviewOutcome::NothingToFix, + // FixOutcome is #[non_exhaustive]: wildcard required from outside mds-core. + _ => PreviewOutcome::NothingToFix, } } @@ -458,8 +575,14 @@ fn run_lint_stdin( flags: LintFlags, runtime_vars: Option>, ) -> Result<()> { + // Bind all five flags explicitly — `..` would silently drop unbound flags to + // their defaults, which breaks `--fix --check` semantics (avoids PF-004). let LintFlags { - fix, quiet, format, .. + fix, + check, + diff, + quiet, + format, } = flags; let (source, cwd) = read_stdin()?; @@ -484,28 +607,84 @@ fn run_lint_stdin( }; if fix { - // --fix stdin: apply fixes, emit fixed source to stdout, diagnostics to stderr. + // ── Preview path: --fix --check and/or --fix --diff (never writes source) ─── + // Mirrors run_lint_file's preview path so stdin honours --check / --diff the + // same way file targets do (PF-004: same gated pipeline as the write path). + if check || diff { + let preview = preview_fixes(&result, &source, &cwd, runtime_vars.clone(), &config); + match preview { + PreviewOutcome::WouldFix(ref fixed) => { + if diff { + let diff_str = render_diff_lint(&source, fixed, "stdin"); + let _ = write_stdout(&diff_str); + } + if check { + if !quiet { + eprintln!("Would fix: stdin"); + } + std::process::exit(1); + } + } + PreviewOutcome::Rejected(ref reason) => { + if !quiet { + eprintln!("fix rejected: {reason}"); + } + } + PreviewOutcome::NothingToFix => {} + } + // After diff-only preview, or when nothing would change / fix rejected: + // render diagnostics of the original result and exit by severity. + let named_source = if format == LintFormat::Human { + Some((mds::STRING_SOURCE_MAP_LABEL, source.as_str())) + } else { + None + }; + emit_result(format, &result, quiet, named_source); + exit_by_severity(&result); + return Ok(()); + } + + // ── Write path: apply fixes, emit fixed source to stdout ───────────────── let fix_outcome = plan_and_apply_fixes(result, &source, &cwd, runtime_vars, &config); let (output_src, diag_result) = match fix_outcome { FixFileOutcome::Fixed { new_source, residual, } => (new_source, residual), + FixFileOutcome::PartiallyFixed { + new_source, + residual, + applied_count, + total_count, + } => { + if !quiet { + eprintln!( + "Partially fixed: stdin ({applied_count} of {total_count} fixes applied)" + ); + } + (new_source, residual) + } FixFileOutcome::Rejected { reason, original } => { eprintln!("fix rejected: {reason}"); (source, original) } FixFileOutcome::NothingToFix { original } => (source, original), }; - // Stdin diagnostics: no named source for span rendering (no stable filename). - render_result_human(&diag_result, quiet, None); + // Stdin diagnostics: pass source text for span context rendering. + let named_source = Some((mds::STRING_SOURCE_MAP_LABEL, output_src.as_str())); + render_result_human(&diag_result, quiet, named_source); let _ = write_stdout(&output_src); exit_by_severity(&diag_result); return Ok(()); } - // Report-only mode. - emit_result(format, &result, quiet, None); + // Report-only mode: pass stdin source for span context rendering. + let named_source = if format == LintFormat::Human { + Some((mds::STRING_SOURCE_MAP_LABEL, source.as_str())) + } else { + None + }; + emit_result(format, &result, quiet, named_source); exit_by_severity(&result); Ok(()) } @@ -525,7 +704,8 @@ fn run_lint_file( format, } = flags; - let base_dir = path.parent().unwrap_or(Path::new(".")); + // effective_parent maps "" (bare filename) to "." — avoids PF-006. + let base_dir = effective_parent(path); // mds.json load/parse failure → JSON envelope in --format json mode (AC-F-14). let config = match load_lint_config(base_dir) { Ok(c) => c, @@ -578,9 +758,25 @@ fn run_lint_file( residual, } => { emit_result(format, &residual, quiet, named_source); + atomic_write_file(path, &new_source)?; if !quiet { eprintln!("Fixed: {}", path.display()); } + exit_by_severity(&residual); + } + FixFileOutcome::PartiallyFixed { + new_source, + residual, + applied_count, + total_count, + } => { + if !quiet { + eprintln!( + "Partially fixed: {} ({applied_count} of {total_count} fixes applied)", + path.display() + ); + } + emit_result(format, &residual, quiet, named_source); atomic_write_file(path, &new_source)?; exit_by_severity(&residual); } @@ -601,24 +797,35 @@ fn run_lint_file( } // ── Preview path: --fix --check and/or --fix --diff ─────────────────────── + // Route preview through the same gated pipeline as the write path. + // Previously called apply_plan_unchecked directly, bypassing the reverify gate — + // a diff or check result could misrepresent what --fix would actually do. if fix && (check || diff) { - let is_standalone = result.is_standalone; - let plan = mds::fix::plan_fixes_with_options(&result, &source, is_standalone); - if !plan.edits.is_empty() { - if diff { - let label = path.display().to_string(); - let fixed = mds::fix::apply_plan_unchecked(&source, &plan); - let diff_str = render_diff_lint(&source, &fixed, &label); - let _ = write_stdout(&diff_str); + let preview = preview_fixes(&result, &source, base_dir, runtime_vars, &config); + match preview { + PreviewOutcome::WouldFix(ref fixed) => { + if diff { + let label = path.display().to_string(); + let diff_str = render_diff_lint(&source, fixed, &label); + let _ = write_stdout(&diff_str); + } + if check { + if !quiet { + eprintln!("Would fix: {}", path.display()); + } + std::process::exit(1); + } } - if check { + PreviewOutcome::Rejected(ref reason) => { + // Surface the rejection reason so --fix --check is as honest as --fix. if !quiet { - eprintln!("Would fix: {}", path.display()); + eprintln!("fix rejected: {reason}"); } - std::process::exit(1); } + PreviewOutcome::NothingToFix => {} } - // After diff-only preview, render diagnostics and exit by severity. + // After diff-only preview (or when nothing would change / fix rejected), + // render diagnostics and exit by severity. emit_result(format, &result, quiet, named_source); exit_by_severity(&result); return Ok(()); @@ -635,6 +842,20 @@ fn run_lint_file( // ── Directory mode ──────────────────────────────────────────────────────────── +/// Compile-time context for directory-mode lint. +/// +/// Groups the parameters resolved once at the start of a directory lint run and +/// threaded into every per-file call — removes the `#[allow(clippy::too_many_arguments)]` +/// suppressions on `lint_one_file_accumulating` and `lint_one_file_human` +/// (issue #6 / zero-warnings policy). Pattern mirrors `FileCompileCtx` / `DirWatchCtx` +/// in watch.rs. +struct LintDirCtx<'a> { + lint_root: &'a Path, + flags: LintFlags, + runtime_vars: &'a Option>, + config: &'a mds::LintConfig, +} + /// Aggregate exit-code category for directory mode. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum FileTally { @@ -682,9 +903,20 @@ fn run_lint_directory( } }; - let mut files = collect_mds_files(dir, MAX_DEPTH, None); + let walk = collect_mds_files_detailed(dir, MAX_DEPTH, None); + let mut files = walk.files; if files.is_empty() { + if walk.excluded_by_default > 0 { + // Always emit — not suppressed by --quiet (avoids silent CI green pass). + // Exit 2: usage error consistent with lint's exit-code table. + eprintln!( + "{} .mds file(s) found but all are under default-excluded directories \ + (hidden dirs, node_modules); nothing was linted", + walk.excluded_by_default + ); + std::process::exit(2); + } if !quiet { eprintln!("No .mds files found in {}", dir.display()); } @@ -698,24 +930,34 @@ fn run_lint_directory( let mut json_files: Vec = Vec::new(); let mut any_truncated = false; + let mut any_would_fix = false; + + let ctx = LintDirCtx { + lint_root: dir, + flags, + runtime_vars: &runtime_vars, + config: &config, + }; + for file in &files { let tally = if format == LintFormat::Json { lint_one_file_accumulating( file, - flags, - &runtime_vars, - &config, + &ctx, &mut json_files, &mut any_truncated, + &mut any_would_fix, ) } else { - lint_one_file_human(file, flags, &runtime_vars, &config, &mut any_truncated) + lint_one_file_human(file, &ctx, &mut any_truncated, &mut any_would_fix) }; if tally > max_tally { max_tally = tally; } } + // Emit JSON envelope BEFORE any early exit so consumers always receive parseable + // output regardless of exit code (AC-F-14 / issue #36). if format == LintFormat::Json { let json = serde_json::json!({ "version": 1, @@ -728,6 +970,12 @@ fn run_lint_directory( )); } + // --fix --check: exit 1 if any file would have been modified (accumulate-and-continue, + // so every file is checked before exiting). + if flags.check && any_would_fix { + std::process::exit(1); + } + if max_tally.exit_code() != 0 { std::process::exit(max_tally.exit_code()); } @@ -737,25 +985,38 @@ fn run_lint_directory( /// Lint one file in directory mode, accumulating results into a JSON array. fn lint_one_file_accumulating( file: &Path, - flags: LintFlags, - runtime_vars: &Option>, - config: &mds::LintConfig, + ctx: &LintDirCtx<'_>, json_files: &mut Vec, any_truncated: &mut bool, + any_would_fix: &mut bool, ) -> FileTally { + // Bind quiet so the PartiallyFixed arm can honour it (issue #43 / #173). let LintFlags { - fix, check, diff, .. - } = flags; + fix, + check, + diff, + quiet, + .. + } = ctx.flags; + + // Compute a display path relative to the lint root so JSON `file` keys + // are navigable and unique across the whole directory tree (not just basenames). + let display_path = file + .strip_prefix(ctx.lint_root) + .unwrap_or(file) + .display() + .to_string(); // `source` is only consumed in the fix branch (below); the report-only/JSON // path does not need it — mds::lint() reads the file independently (I-06). - let base_dir = file.parent().unwrap_or(Path::new(".")); + // effective_parent maps "" (bare filename) to "." — avoids PF-006. + let base_dir = effective_parent(file); - let result = match mds::lint(file, runtime_vars.clone(), config) { + let mut result = match mds::lint(file, ctx.runtime_vars.clone(), ctx.config) { Ok(r) => r, Err(ref e) => { json_files.push(serde_json::json!({ - "file": file.display().to_string(), + "file": display_path, "error": e.serialize() })); return if matches!(e, MdsError::ResourceLimit { .. }) { @@ -765,6 +1026,8 @@ fn lint_one_file_accumulating( }; } }; + // Remap basename-only file field → relative display path. + set_diag_display_path(&mut result, &display_path); if result.truncated { *any_truncated = true; @@ -787,19 +1050,46 @@ fn lint_one_file_accumulating( Err(e) => { // Per-file I/O failure in directory mode: accumulate structured error (AC-F-14). json_files.push(serde_json::json!({ - "file": file.display().to_string(), + "file": display_path, "error": e.serialize() })); return FileTally::Error; } }; - let fix_outcome = - plan_and_apply_fixes(result, &source, base_dir, runtime_vars.clone(), config); + let fix_outcome = plan_and_apply_fixes( + result, + &source, + base_dir, + ctx.runtime_vars.clone(), + ctx.config, + ); match fix_outcome { FixFileOutcome::Fixed { new_source, - residual, + mut residual, } => { + set_diag_display_path(&mut residual, &display_path); + accumulate_result_json(&residual, json_files); + if let Err(e) = atomic_write_file(file, &new_source) { + eprintln!("error writing {}: {e}", file.display()); + return FileTally::Error; + } + tally_from_result(&residual) + } + FixFileOutcome::PartiallyFixed { + new_source, + mut residual, + applied_count, + total_count, + } => { + // Unified message + quiet guard (issue #43 / #173). + if !quiet { + eprintln!( + "Partially fixed: {} ({applied_count} of {total_count} fixes applied)", + file.display() + ); + } + set_diag_display_path(&mut residual, &display_path); accumulate_result_json(&residual, json_files); if let Err(e) = atomic_write_file(file, &new_source) { eprintln!("error writing {}: {e}", file.display()); @@ -817,6 +1107,40 @@ fn lint_one_file_accumulating( tally_from_result(&original) } } + } else if fix && (check || diff) { + // Directory-mode preview — route through gated pipeline. + let source = match read_source_file(file) { + Ok(s) => s, + Err(e) => { + json_files.push(serde_json::json!({ + "file": display_path, + "error": e.serialize() + })); + return FileTally::Error; + } + }; + match preview_fixes( + &result, + &source, + base_dir, + ctx.runtime_vars.clone(), + ctx.config, + ) { + PreviewOutcome::WouldFix(ref fixed) => { + *any_would_fix = true; + if diff { + let label = file.display().to_string(); + let diff_str = render_diff_lint(&source, fixed, &label); + let _ = write_stdout(&diff_str); + } + } + PreviewOutcome::Rejected(ref reason) => { + eprintln!("{}: fix rejected: {reason}", file.display()); + } + PreviewOutcome::NothingToFix => {} + } + accumulate_result_json(&result, json_files); + tally_from_result(&result) } else { accumulate_result_json(&result, json_files); tally_from_result(&result) @@ -826,10 +1150,9 @@ fn lint_one_file_accumulating( /// Lint one file in directory mode, rendering diagnostics to stderr (human mode). fn lint_one_file_human( file: &Path, - flags: LintFlags, - runtime_vars: &Option>, - config: &mds::LintConfig, + ctx: &LintDirCtx<'_>, any_truncated: &mut bool, + any_would_fix: &mut bool, ) -> FileTally { let LintFlags { fix, @@ -837,25 +1160,32 @@ fn lint_one_file_human( diff, quiet, .. - } = flags; + } = ctx.flags; + + // Compute a display path relative to the lint root for human rendering. + let display_path = file + .strip_prefix(ctx.lint_root) + .unwrap_or(file) + .display() + .to_string(); let source = match read_source_file(file) { Ok(s) => s, Err(e) => { - eprintln!("{:?}", miette::Report::from(e)); + crate::output::eprint_error(miette::Report::from(e)); return FileTally::Error; } }; - let base_dir = file.parent().unwrap_or(Path::new(".")); - // Named source for span rendering: file display name + source text. - let file_display = file.to_str().unwrap_or(""); - let named_source = Some((file_display, source.as_str())); + // effective_parent maps "" (bare filename) to "." — avoids PF-006. + let base_dir = effective_parent(file); + // Named source for span rendering: relative display path + source text. + let named_source = Some((display_path.as_str(), source.as_str())); - let result = match mds::lint(file, runtime_vars.clone(), config) { + let mut result = match mds::lint(file, ctx.runtime_vars.clone(), ctx.config) { Ok(r) => r, Err(ref e) => { - eprintln!("{:?}", miette::Report::from(e.clone())); + crate::output::eprint_error(miette::Report::from(e.clone())); return if matches!(e, MdsError::ResourceLimit { .. }) { FileTally::ResourceLimit } else { @@ -863,6 +1193,8 @@ fn lint_one_file_human( }; } }; + // Remap basename-only file field → relative display path. + set_diag_display_path(&mut result, &display_path); if result.truncated { *any_truncated = true; @@ -877,17 +1209,44 @@ fn lint_one_file_human( } if fix && !check && !diff { - let fix_outcome = - plan_and_apply_fixes(result, &source, base_dir, runtime_vars.clone(), config); + let fix_outcome = plan_and_apply_fixes( + result, + &source, + base_dir, + ctx.runtime_vars.clone(), + ctx.config, + ); match fix_outcome { FixFileOutcome::Fixed { new_source, - residual, + mut residual, } => { + set_diag_display_path(&mut residual, &display_path); render_result_human(&residual, quiet, named_source); + if let Err(e) = atomic_write_file(file, &new_source) { + eprintln!("error writing {}: {e}", file.display()); + return FileTally::Error; + } if !quiet { eprintln!("Fixed: {}", file.display()); } + tally_from_result(&residual) + } + FixFileOutcome::PartiallyFixed { + new_source, + mut residual, + applied_count, + total_count, + } => { + // Unified message format (issue #43 / #173). + if !quiet { + eprintln!( + "Partially fixed: {} ({applied_count} of {total_count} fixes applied)", + file.display() + ); + } + set_diag_display_path(&mut residual, &display_path); + render_result_human(&residual, quiet, named_source); if let Err(e) = atomic_write_file(file, &new_source) { eprintln!("error writing {}: {e}", file.display()); return FileTally::Error; @@ -904,6 +1263,35 @@ fn lint_one_file_human( tally_from_result(&original) } } + } else if fix && (check || diff) { + // Directory-mode preview — route through gated pipeline. + match preview_fixes( + &result, + &source, + base_dir, + ctx.runtime_vars.clone(), + ctx.config, + ) { + PreviewOutcome::WouldFix(ref fixed) => { + *any_would_fix = true; + if diff { + let label = file.display().to_string(); + let diff_str = render_diff_lint(&source, fixed, &label); + let _ = write_stdout(&diff_str); + } + if check && !quiet { + eprintln!("Would fix: {}", file.display()); + } + } + PreviewOutcome::Rejected(ref reason) => { + if !quiet { + eprintln!("{}: fix rejected: {reason}", file.display()); + } + } + PreviewOutcome::NothingToFix => {} + } + render_result_human(&result, quiet, named_source); + tally_from_result(&result) } else { render_result_human(&result, quiet, named_source); tally_from_result(&result) @@ -945,7 +1333,10 @@ fn emit_analysis_failure_json_or_stderr(e: &MdsError, format: LintFormat) { serde_json::to_string(&envelope).expect("canonical lint JSON is always serializable") )); } else { - eprintln!("{:?}", miette::Report::from(e.clone())); + // Sanitize at the render boundary: MdsError::Syntax embeds user-controlled + // source fragments that may contain raw ESC bytes (avoids terminal escape injection). + let rendered = format!("{:?}", miette::Report::from(e.clone())); + eprintln!("{}", mds::sanitize_control_chars(&rendered)); } } diff --git a/crates/mds-cli/src/main.rs b/crates/mds-cli/src/main.rs index 607ee633..58e79abf 100644 --- a/crates/mds-cli/src/main.rs +++ b/crates/mds-cli/src/main.rs @@ -27,7 +27,7 @@ use build::{ struct Cli { #[command(subcommand)] command: Commands, - /// Suppress status messages + /// Suppress status and diagnostic output; errors always print; exit codes unaffected #[arg(long, short = 'q', global = true)] quiet: bool, } @@ -65,7 +65,7 @@ enum Commands { /// Set a runtime variable as a string (repeatable, no type coercion; e.g. --set-string count=3 sets count to the string "3") #[arg(long = "set-string", value_name = "KEY=VALUE", value_parser = parse_key_value)] set_string_vars: Vec<(String, String)>, - /// Generate a source map alongside the compiled output (sidecar: .map). + /// Generate a source map alongside the compiled output (sidecar: .map, e.g. -o out.md → out.md.map). /// Conflicts with --no-source-map. #[arg(long = "source-map", conflicts_with = "no_source_map")] source_map: bool, @@ -109,7 +109,7 @@ enum Commands { /// structure, whitespace-only lines), frontmatter / code-fence internals, /// or the byte-for-byte content of `@message` / `@define` bodies. #[command( - after_help = "Examples:\n mds fmt Auto-detect and format the .mds file in current dir\n mds fmt template.mds Format a file in place\n mds fmt . Format every .mds file recursively (incl. partials)\n mds fmt --check template.mds Exit 1 if the file would change; writes nothing\n mds fmt --diff template.mds Print a unified diff of pending changes; writes nothing\n mds fmt --check --diff . Show diffs for every file that would change, exit 1 if any would\n echo \"Hello {name}!\" | mds fmt - Format from stdin, write to stdout; creates no file" + after_help = "Examples:\n mds fmt Auto-detect and format the .mds file in current dir\n mds fmt template.mds Format a file in place\n mds fmt . Format every .mds file recursively (incl. partials)\n mds fmt --check template.mds Exit 1 if the file would change; writes nothing\n mds fmt --diff template.mds Print a unified diff of pending changes; writes nothing\n mds fmt --check --diff . Show diffs for every file that would change, exit 1 if any would\n printf '@if ready: \\nGo\\n@end\\n' | mds fmt - Format from stdin, write to stdout; creates no file" )] Fmt { /// Input .mds file, directory, or "-" for stdin (omit to auto-detect in current directory) @@ -124,13 +124,13 @@ enum Commands { }, /// Check MDS files for style and correctness issues beyond `mds check` /// - /// Runs 9 static-analysis rules (3 error-level, 6 warning-level) on the file + /// Runs 9 static-analysis rules (3 error-level, 5 warning-level, 1 default-off) on the file /// without executing it. Partials and imported files are included in directory mode. /// /// Exit codes: 0 = clean, 1 = warnings only, 2 = errors or analysis failure, /// 3 = resource limit. #[command( - after_help = "Examples:\n mds lint template.mds Lint a single file\n mds lint . Lint all .mds files recursively\n mds lint --fix template.mds Fix auto-fixable issues in place\n mds lint --fix --check template.mds Preview fixes (exit 1 if any would apply)\n mds lint --fix --diff template.mds Show diff of pending fixes\n mds lint --format json template.mds Machine-readable JSON output\n mds lint --quiet template.mds Suppress warnings; exit 2 on errors only\n cat template.mds | mds lint - Lint from stdin\n cat template.mds | mds lint --fix - Fix from stdin, write fixed source to stdout" + after_help = "Examples:\n mds lint template.mds Lint a single file\n mds lint . Lint all .mds files recursively\n mds lint --fix template.mds Fix auto-fixable issues in place\n mds lint --fix --check template.mds Preview fixes (exit 1 if any would apply)\n mds lint --fix --diff template.mds Show diff of pending fixes\n mds lint --format json template.mds Machine-readable JSON output\n mds lint --quiet template.mds Suppress output; exits 1 on warnings, 2 on errors\n cat template.mds | mds lint - Lint from stdin\n cat template.mds | mds lint --fix - Fix from stdin, write fixed source to stdout" )] Lint { /// Input .mds file, directory, or `-` for stdin (omit to auto-detect) @@ -225,7 +225,11 @@ fn main() { let result = run(cli); if let Err(e) = result { - eprintln!("{e:?}"); + // Sanitize at the last-resort render boundary: every subcommand's error propagates + // here, and MdsError::Syntax embeds user-controlled source fragments that may contain + // raw ESC bytes. Guarding here makes the protection hold by construction for any + // future error path, not just the ones we remember to sanitize individually (PF-004). + eprintln!("{}", mds::sanitize_control_chars(&format!("{e:?}"))); process::exit(exit_code(&e)); } } @@ -246,7 +250,7 @@ fn run_check( // Resolve the input: explicit path/stdin, or auto-detect from cwd. // run_check does not print a banner on auto-detect — check is a silent validation. - let (input, _) = resolve_input(input)?; + let (input, _) = resolve_input(input, "check")?; // Directory mode: validate every non-partial .mds file in the tree. if input != std::path::Path::new("-") && input.is_dir() { @@ -297,13 +301,23 @@ fn run_check_directory( runtime_vars: Option>, quiet: bool, ) -> Result<()> { - use output::{collect_mds_files, is_partial}; + use output::{collect_mds_files_detailed, is_partial}; const MAX_DEPTH: usize = 64; - let files = collect_mds_files(dir, MAX_DEPTH, None); + let walk = collect_mds_files_detailed(dir, MAX_DEPTH, None); + let files = walk.files; if files.is_empty() { + if walk.excluded_by_default > 0 { + // Always emit — not suppressed by --quiet (avoids silent CI green pass). + eprintln!( + "{} .mds file(s) found but all are under default-excluded directories \ + (hidden dirs, node_modules); nothing was checked", + walk.excluded_by_default + ); + std::process::exit(1); + } if !quiet { eprintln!("No .mds files found in {}", dir.display()); } @@ -329,14 +343,16 @@ fn run_check_directory( ok_count += 1; } Err(e) => { - eprintln!("{e:?}"); + // Sanitize at the render boundary: MdsError::Syntax embeds user-controlled + // source fragments that may contain raw ESC bytes (PF-004 parallel-path guard). + eprintln!("{}", mds::sanitize_control_chars(&format!("{e:?}"))); fail_count += 1; } } } if !quiet || fail_count > 0 { - eprintln!("{ok_count} checked, {fail_count} failed"); + eprintln!("{ok_count} passed, {fail_count} failed"); } if fail_count > 0 { diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index f602035d..6b7934ab 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -1,4 +1,4 @@ -//! Shared output-path machinery for build, check, and watch subcommands. +//! Shared output-path machinery for build, check, watch, fmt, and lint subcommands. //! //! # What lives here //! @@ -6,11 +6,14 @@ //! path resolution used by watch and build-directory. //! - [`collect_mds_files`] / [`is_partial`]: directory traversal helpers. //! - [`probe_and_remove_stale`]: stale-output cleanup for format-flip (AC-FUNC-23). +//! - [`eprint_error`]: sanitized stderr render for directory-mode error loops (PF-004). +//! - [`atomic_write_file`]: temp-file-then-rename writer shared by `fmt` and `lint --fix`. //! //! Single-file path helpers (`OutputKind`, `compile_to_content`, `compile_and_write`, //! `resolve_output_path_for_kind`) remain in `build.rs`; they are imported here when //! callers need both single-file and directory logic. +use std::io::Write as _; use std::path::{Path, PathBuf}; use miette::Result; @@ -133,20 +136,113 @@ pub(crate) fn output_path_for(source: &Path, root: &Path, base: &OutputBase, ext // ── Directory traversal ─────────────────────────────────────────────────────── +/// Result of a directory walk, carrying both the collected files and a count +/// of `.mds` files that were skipped because they reside inside +/// default-excluded directories (hidden dirs, `node_modules`). +/// +/// A non-zero `excluded_by_default` with an empty `files` list means every +/// candidate was filtered out by the default exclusions — distinguishable from +/// a genuinely empty tree (where both are zero). +pub(crate) struct WalkResult { + /// Files that were collected and are eligible for processing. + pub files: Vec, + /// Count of `.mds` files found inside default-excluded directories. + pub excluded_by_default: usize, +} + +/// Recursively collect all `.mds` files under `root`, bounded by `max_depth`, +/// returning a [`WalkResult`] that also carries the count of files skipped due +/// to default exclusions (hidden dirs, `node_modules`). +/// +/// Use this at call sites that need to distinguish "genuinely empty tree" from +/// "all candidates excluded". Use [`collect_mds_files`] at call sites (e.g. +/// watch) that only need the file list. +pub(crate) fn collect_mds_files_detailed( + root: &Path, + max_depth: usize, + exclude_prefix: Option<&Path>, +) -> WalkResult { + let mut files = Vec::new(); + let mut excluded_by_default = 0; + collect_mds_files_inner( + root, + 0, + max_depth, + exclude_prefix, + &mut files, + &mut excluded_by_default, + ); + WalkResult { + files, + excluded_by_default, + } +} + /// Recursively collect all `.mds` files under `root`, bounded by `max_depth`. /// /// Symlinked directories AND symlinked files are skipped to avoid cycles and /// to maintain build parity with the single-file symlink guard (PF-004 / commit aa0c538). /// When `exclude_prefix` is `Some(p)`, any path that starts with `p` is skipped /// (used to exclude the out-dir when it is inside the watched root). +/// +/// For callers that need to distinguish "genuinely empty tree" from "all candidates +/// excluded", use [`collect_mds_files_detailed`] instead. pub(crate) fn collect_mds_files( root: &Path, max_depth: usize, exclude_prefix: Option<&Path>, ) -> Vec { - let mut results = Vec::new(); - collect_mds_files_inner(root, 0, max_depth, exclude_prefix, &mut results); - results + collect_mds_files_detailed(root, max_depth, exclude_prefix).files +} + +/// Return `true` when a directory name should be excluded from recursive +/// traversal by default (PF-004: enforced on the shared walker so ALL +/// subcommands — build / check / lint / fmt / watch — inherit the behaviour). +/// +/// Excluded directory names: +/// - Any name that starts with `.` (hidden directories, e.g. `.git`, `.cache`) +/// - `node_modules` +/// +/// Note: this gate applies to the RECURSION step only — the root directory +/// that was explicitly passed to `collect_mds_files` is always processed, +/// even if its own name happens to start with `.`. Hidden *files* (e.g. +/// `.dotfile.mds`) at the traversed directory level are still collected. +pub(crate) fn is_default_excluded_dir(name: &str) -> bool { + name.starts_with('.') || name == "node_modules" +} + +/// Return `true` when `path` lives inside a default-excluded sub-directory +/// of `root` (i.e. traversal would have been skipped there by +/// `is_default_excluded_dir`). +/// +/// Used by the watch guards to detect events that should be treated as external +/// dependencies rather than normal output-producing sources (PF-004 class: +/// the same limit must be enforced on the parallel event-processing path as on +/// the initial walker path — avoids the "limit on one path but not another" +/// bug class). +pub(crate) fn is_within_default_excluded_dir(root: &Path, path: &Path) -> bool { + // Strip the root prefix to get a relative path, then walk the ancestor + // chain using Path::parent() — avoids allocating a Vec just to + // drop the final component (issue #69: this runs on the watch per-event + // hot path). + // + // Edge case: when `rel` is a single component (e.g. "foo.mds"), + // rel.parent() returns Some("") whose file_name() is None, and + // "".parent() returns None, ending the loop correctly. + let rel = match path.strip_prefix(root) { + Ok(r) => r, + Err(_) => return false, // path is not under root at all + }; + let mut ancestor = rel.parent(); + while let Some(dir) = ancestor { + if let Some(name) = dir.file_name().and_then(|n| n.to_str()) { + if is_default_excluded_dir(name) { + return true; + } + } + ancestor = dir.parent(); + } + false } fn collect_mds_files_inner( @@ -155,6 +251,7 @@ fn collect_mds_files_inner( max_depth: usize, exclude_prefix: Option<&Path>, results: &mut Vec, + excluded_count: &mut usize, ) { if depth > max_depth { eprintln!( @@ -189,13 +286,59 @@ fn collect_mds_files_inner( continue; } if file_type.is_dir() { - collect_mds_files_inner(&path, depth + 1, max_depth, exclude_prefix, results); + // Skip hidden directories (e.g. .git, .cache) and node_modules on the + // RECURSION step so all subcommands inherit the default exclusions via + // the shared walker (PF-004). The root dir that was explicitly passed + // to collect_mds_files() is NEVER checked here — this guard applies + // only to directory ENTRIES discovered during traversal. Since entries + // are always children of the current dir, they are never the explicit root. + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if is_default_excluded_dir(name) { + // Count the .mds files we're skipping so callers can emit a + // meaningful diagnostic when all candidates are excluded. + count_mds_in_excluded_dir(&path, depth + 1, max_depth, excluded_count); + continue; + } + } + collect_mds_files_inner( + &path, + depth + 1, + max_depth, + exclude_prefix, + results, + excluded_count, + ); } else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("mds") { results.push(path); } } } +/// Count `.mds` files inside a directory that is being skipped due to a +/// default exclusion. Symlinks are still skipped. Does not apply further +/// exclusion filtering — we are already inside an excluded root, so every +/// `.mds` descendant is a skipped candidate regardless of name. +fn count_mds_in_excluded_dir(dir: &Path, depth: usize, max_depth: usize, count: &mut usize) { + if depth > max_depth { + return; + } + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for entry in rd.flatten() { + let path = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + count_mds_in_excluded_dir(&path, depth + 1, max_depth, count); + } else if ft.is_file() && path.extension().and_then(|e| e.to_str()) == Some("mds") { + *count += 1; + } + } +} + /// Return `true` if `path`'s file name starts with `_` (partial convention, DD2). pub(crate) fn is_partial(path: &Path) -> bool { path.file_name() @@ -268,6 +411,97 @@ pub(crate) fn output_base_no_ext(source: &Path, root: &Path, base: &OutputBase) } } +// ── Atomic file write ───────────────────────────────────────────────────────── + +/// Write `content` to `path` atomically via a temp-file-then-rename cycle. +/// +/// Centralising this helper in `output.rs` ensures both `fmt` and `lint --fix` +/// route through the same write path (avoids PF-004 — a check enforced on the +/// primary path silently absent on a sibling path). +/// +/// Safety properties: +/// - Re-checks for symlink immediately before the write (TOCTOU guard, AC-F-21). +/// - Temp file lives in the SAME directory as the target so the rename is +/// always intra-filesystem (atomic on POSIX, near-atomic on Windows). +/// - On Unix, captures and restores the original file mode (masked to `& 0o7777` +/// to strip filesystem-type bits before passing to `Permissions::from_mode`). +/// `tempfile::Builder` defaults to mode 0600; without this step a 0644 source +/// file would silently become owner-only after the rename. +/// - Calls `sync_all()` (not `flush()` — `flush()` is a no-op on unbuffered +/// `File`) for crash durability before the rename. +pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { + use mds::{effective_parent, NativeFs}; + + // Re-check for symlink right before writing (TOCTOU guard). + NativeFs::check_symlink(path) + .map_err(|e| miette::miette!("cannot write {}: {e}", path.display()))?; + + // effective_parent maps "" (bare filename) and None to "." — avoids PF-006. + let parent = effective_parent(path); + + // Capture original permissions before creating the temp file. + #[cfg(unix)] + let original_mode: Option = { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(path).map(|m| m.permissions().mode()).ok() + }; + + // Temp file in same directory so rename is always intra-filesystem. + let mut tmp = tempfile::Builder::new() + .prefix(".mds-tmp-") + .suffix(".tmp") + .tempfile_in(parent) + .map_err(|e| miette::miette!("cannot create temp file for {}: {e}", path.display()))?; + + // Restore original permissions before writing; mask off file-type bits + // (high bits of st_mode) so only the permission bits reach from_mode. + #[cfg(unix)] + if let Some(mode) = original_mode { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(mode & 0o7777)) + .map_err(|e| { + miette::miette!( + "cannot set permissions on temp file for {}: {e}", + path.display() + ) + })?; + } + + tmp.write_all(content.as_bytes()) + .map_err(|e| miette::miette!("cannot write {}: {e}", path.display()))?; + + // sync_all() flushes data + metadata to storage (flush() is a no-op on + // unbuffered File and provides no crash durability guarantee). + tmp.as_file() + .sync_all() + .map_err(|e| miette::miette!("cannot fsync {}: {e}", path.display()))?; + + // persist() atomically renames the temp file to the target path. + tmp.persist(path) + .map_err(|e| miette::miette!("cannot rename temp file to {}: {e}", path.display()))?; + + Ok(()) +} + +// ── Sanitized stderr render ─────────────────────────────────────────────────── + +/// Render a miette Report to stderr with control-character sanitization applied. +/// +/// Per-file error handlers in directory-mode loops (e.g. `lint_one_file_human`, +/// `format_one_file`) MUST use this helper instead of bare +/// `eprintln!("{:?}", miette::Report::from(e))`. Centralising the render here +/// means the sanitizer cannot be forgotten on any future parallel path +/// (avoids PF-004: a check enforced on the primary path silently absent on a +/// sibling path). +/// +/// The `mds::sanitize_control_chars` function strips C0, C1, and DEL codepoints +/// while preserving `\n`, `\t`, and printable Unicode — miette box-drawing and +/// carets therefore survive intact; only raw ESC bytes and other non-printing +/// controls are escaped to `\uXXXX` literals. +pub(crate) fn eprint_error(report: miette::Report) { + eprintln!("{}", mds::sanitize_control_chars(&format!("{report:?}"))); +} + // ── Unit tests ──────────────────────────────────────────────────────────────── #[cfg(test)] @@ -335,6 +569,207 @@ mod tests { assert!(!is_partial(Path::new("/dir/not_partial.mds"))); } + // ── is_default_excluded_dir ─────────────────────────────────────────────── + + #[test] + fn hidden_dir_is_excluded() { + assert!(is_default_excluded_dir(".git")); + assert!(is_default_excluded_dir(".cache")); + assert!(is_default_excluded_dir(".hidden")); + } + + #[test] + fn node_modules_is_excluded() { + assert!(is_default_excluded_dir("node_modules")); + } + + #[test] + fn ordinary_dirs_are_not_excluded() { + assert!(!is_default_excluded_dir("src")); + assert!(!is_default_excluded_dir("prompts")); + assert!(!is_default_excluded_dir("templates")); + } + + // ── is_within_default_excluded_dir ─────────────────────────────────────── + + #[test] + fn path_inside_node_modules_is_excluded() { + assert!(is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/node_modules/foo.mds") + )); + } + + #[test] + fn path_inside_git_dir_is_excluded() { + assert!(is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/.git/config") + )); + } + + #[test] + fn path_inside_hidden_subdir_is_excluded() { + assert!(is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/.cache/something.mds") + )); + } + + #[test] + fn normal_path_under_root_is_not_excluded() { + assert!(!is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/src/main.mds") + )); + } + + #[test] + fn hidden_file_at_root_level_is_not_excluded() { + // Hidden files at the top level are not inside an excluded DIR. + assert!(!is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/.dotfile.mds") + )); + } + + #[test] + fn path_outside_root_is_not_excluded() { + // Paths not under root at all are not affected by the root-relative check. + assert!(!is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/other/node_modules/foo.mds") + )); + } + + // ── collect_mds_files walker exclusions ─────────────────────────────────── + + #[test] + fn walker_skips_node_modules_subdir() { + let dir = tempfile::tempdir().unwrap(); + // Create a normal .mds file and one inside node_modules. + std::fs::write(dir.path().join("main.mds"), "hello").unwrap(); + let nm = dir.path().join("node_modules"); + std::fs::create_dir(&nm).unwrap(); + std::fs::write(nm.join("lib.mds"), "lib").unwrap(); + + let files = collect_mds_files(dir.path(), 64, None); + assert_eq!( + files.len(), + 1, + "node_modules/lib.mds should be excluded; found: {files:?}" + ); + assert!(files[0].ends_with("main.mds")); + } + + #[test] + fn walker_skips_hidden_subdir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.mds"), "hello").unwrap(); + let hidden = dir.path().join(".git"); + std::fs::create_dir(&hidden).unwrap(); + std::fs::write(hidden.join("config.mds"), "not a real file").unwrap(); + + let files = collect_mds_files(dir.path(), 64, None); + assert_eq!( + files.len(), + 1, + ".git/*.mds should be excluded; found: {files:?}" + ); + } + + #[test] + fn walker_collects_hidden_file_at_root_level() { + // Hidden FILES (not directories) at the traversed level are still collected. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.mds"), "hello").unwrap(); + std::fs::write(dir.path().join(".dotfile.mds"), "dot").unwrap(); + + let mut files = collect_mds_files(dir.path(), 64, None); + files.sort(); + assert_eq!( + files.len(), + 2, + "hidden file should still be collected; found: {files:?}" + ); + } + + #[test] + fn walker_processes_explicitly_passed_hidden_root() { + // The root dir itself is always processed even if its name starts with '.'. + let dir = tempfile::tempdir().unwrap(); + let hidden_root = dir.path().join(".myhidden"); + std::fs::create_dir(&hidden_root).unwrap(); + std::fs::write(hidden_root.join("template.mds"), "hello").unwrap(); + + let files = collect_mds_files(&hidden_root, 64, None); + assert_eq!( + files.len(), + 1, + "explicitly-passed hidden root should be processed; found: {files:?}" + ); + } + + // ── collect_mds_files_detailed / WalkResult ─────────────────────────────── + + #[test] + fn walk_result_empty_dir_has_zero_excluded() { + let dir = tempfile::tempdir().unwrap(); + let result = collect_mds_files_detailed(dir.path(), 64, None); + assert_eq!(result.files.len(), 0); + assert_eq!( + result.excluded_by_default, 0, + "genuinely empty dir must have 0 excluded" + ); + } + + #[test] + fn walk_result_all_excluded_counts_skipped_files() { + let dir = tempfile::tempdir().unwrap(); + // All files inside a hidden dir → excluded_by_default > 0, files empty. + let hidden = dir.path().join(".prompts"); + std::fs::create_dir(&hidden).unwrap(); + std::fs::write(hidden.join("a.mds"), "a").unwrap(); + std::fs::write(hidden.join("b.mds"), "b").unwrap(); + + let result = collect_mds_files_detailed(dir.path(), 64, None); + assert_eq!(result.files.len(), 0, "no files should be in results"); + assert_eq!( + result.excluded_by_default, 2, + "excluded_by_default must equal the count of skipped .mds files; got {}", + result.excluded_by_default + ); + } + + #[test] + fn walk_result_mixed_counts_excluded_and_collects_normal() { + let dir = tempfile::tempdir().unwrap(); + // One normal file + one in node_modules. + std::fs::write(dir.path().join("normal.mds"), "hello").unwrap(); + let nm = dir.path().join("node_modules"); + std::fs::create_dir(&nm).unwrap(); + std::fs::write(nm.join("excluded.mds"), "lib").unwrap(); + + let result = collect_mds_files_detailed(dir.path(), 64, None); + assert_eq!(result.files.len(), 1, "only normal.mds should be collected"); + assert_eq!( + result.excluded_by_default, 1, + "one file in node_modules should be counted as excluded" + ); + } + + // ── is_within_default_excluded_dir single-component edge case ───────────── + + #[test] + fn single_component_path_is_not_inside_excluded_dir() { + // rel = "foo.mds" (single component): rel.parent() = Some(""), which has + // no file_name(), so the loop terminates without false-positive. + assert!(!is_within_default_excluded_dir( + Path::new("/root"), + Path::new("/root/foo.mds") + )); + } + #[test] fn output_base_no_ext_dir_mode() { let source = PathBuf::from("/root/src/chat.mds"); diff --git a/crates/mds-cli/src/watch.rs b/crates/mds-cli/src/watch.rs index af149125..92232b67 100644 --- a/crates/mds-cli/src/watch.rs +++ b/crates/mds-cli/src/watch.rs @@ -39,8 +39,8 @@ use crate::build::{ resolve_output_path_for_kind, write_output, OutputKind, RuntimeVarArgs, }; use crate::output::{ - canonicalize_out_dir, collect_mds_files, is_partial, output_base_no_ext, output_path_for, - probe_and_remove_stale, resolve_output_base, OutputBase, + canonicalize_out_dir, collect_mds_files, is_partial, is_within_default_excluded_dir, + output_base_no_ext, output_path_for, probe_and_remove_stale, resolve_output_base, OutputBase, }; // ── Public args struct ──────────────────────────────────────────────────────── @@ -87,14 +87,11 @@ pub(crate) fn dirs_to_watch( let mut dirs = BTreeSet::new(); let push_parent = |path: &Path, set: &mut BTreeSet| { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - set.insert(parent.to_path_buf()); - } else { - // Relative path with no directory component: watch "." - set.insert(PathBuf::from(".")); - } - } + // Route through mds::effective_parent so that bare filenames — where + // Path::parent() returns Some("") rather than None — are handled by the + // single canonical implementation rather than an inline re-implementation. + // Avoids PF-006: one owner, one place to maintain or regress. + set.insert(mds::effective_parent(path).to_path_buf()); }; push_parent(entry, &mut dirs); @@ -200,12 +197,15 @@ pub(crate) fn graph_key(p: &Path) -> PathBuf { if let Ok(c) = p.canonicalize() { return c; } - // File doesn't exist (just deleted): canonicalize parent + rejoin filename. - if let Some(parent) = p.parent() { - if let Ok(cp) = parent.canonicalize() { - if let Some(name) = p.file_name() { - return cp.join(name); - } + // File doesn't exist (just deleted): canonicalize effective parent + rejoin filename. + // mds::effective_parent maps Some("") (bare filename, e.g. "hello.mds") to + // Path::new(".") so that "".canonicalize() never runs — avoids PF-006 in the + // graph-key lookup-miss path: without this guard a bare-named file that is + // deleted cannot be matched against the absolute-path keys stored in forward_deps. + let parent = mds::effective_parent(p); + if let Ok(cp) = parent.canonicalize() { + if let Some(name) = p.file_name() { + return cp.join(name); } } p.to_path_buf() @@ -525,7 +525,7 @@ pub(crate) fn run_watch(args: WatchArgs) -> Result<()> { // Resolve the input path (may trigger auto-detect). let resolved_input = match input { - None => auto_detect_mds_file()?, + None => auto_detect_mds_file("watch")?, Some(p) => p, }; @@ -1539,6 +1539,13 @@ fn handle_fs_event_dir( changed.retain(|p| !p.starts_with(od)); } + // PF-004: drop events from default-excluded subdirectories (hidden dirs and + // node_modules/) inside the watch root. The initial walker never seeds files + // from those dirs, so they are not in the dep graph and processing their + // events would cause spurious rebuilds (e.g. npm install writing to + // node_modules/ triggers a full re-scan on every package update). + changed.retain(|p| !is_within_default_excluded_dir(&ctx.root, p)); + // Check if the vars file changed. let vars_changed = ctx .vars_path @@ -2038,6 +2045,12 @@ fn process_dir_batch_incremental( for src in &affected { // External-only deps are graph nodes but never emit output (DD3). let is_in_root = src.starts_with(root); + // PF-004: paths inside default-excluded subdirs (hidden dirs, node_modules/) + // that happen to be under root are treated as external deps — they get a quiet + // dep-refresh compile but never emit output. This is the same invariant as the + // initial walker (which never recurses into those dirs), applied here on the + // parallel event-processing path so the two paths stay consistent. + let is_excluded_in_root = is_in_root && is_within_default_excluded_dir(root, src); let is_known_external = state .external_dep_dirs .iter() @@ -2065,8 +2078,9 @@ fn process_dir_batch_incremental( continue; } - // External deps are graph nodes but never emit their own output (DD3). - if !is_in_root { + // External deps (out-of-root) AND excluded-in-root paths (node_modules/, .git/, + // hidden dirs) are graph nodes but never emit their own output (DD3 pattern). + if !is_in_root || is_excluded_in_root { // Compile to refresh deps only; suppress output by using quiet=true. match compile_to_content( src, diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index f061afb0..5e9ac006 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -758,3 +758,370 @@ fn build_mds_json_discovery_walks_up() { md_path.display() ); } + +// ── Bare-filename regression tests (release blocker — effective_parent fix) ── + +/// `mds build hello.mds` from the directory containing `hello.mds` must succeed. +/// Before the effective_parent fix, Path::parent() returned Some("") for a bare +/// filename, causing "".canonicalize() to fail with file_not_found on EVERY +/// subcommand when the user passed a bare relative filename as the argument. +#[test] +fn build_bare_filename_from_cwd_succeeds() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.mds"), "Hello!\n").unwrap(); + + let output = mds_bin() + .current_dir(dir.path()) + .args(["build", "hello.mds"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "mds build from cwd should succeed; stderr: {stderr}" + ); + assert!( + dir.path().join("hello.md").exists(), + "hello.md should be created next to hello.mds" + ); +} + +/// `mds check hello.mds` from the directory containing it must succeed. +#[test] +fn check_bare_filename_from_cwd_succeeds() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.mds"), "Hello!\n").unwrap(); + + let output = mds_bin() + .current_dir(dir.path()) + .args(["check", "hello.mds"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "mds check from cwd should succeed; stderr: {stderr}" + ); +} + +/// `mds fmt hello.mds` from the directory containing it must succeed. +#[test] +fn fmt_bare_filename_from_cwd_succeeds() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.mds"), "Hello!\n").unwrap(); + + let output = mds_bin() + .current_dir(dir.path()) + .args(["fmt", "hello.mds"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "mds fmt from cwd should succeed; stderr: {stderr}" + ); +} + +/// `mds lint hello.mds` from the directory containing it must succeed (exit 0 for clean file). +#[test] +fn lint_bare_filename_from_cwd_succeeds() { + let dir = tempfile::tempdir().unwrap(); + // Clean file: no lint findings expected. + std::fs::write(dir.path().join("hello.mds"), "Hello!\n").unwrap(); + + let output = mds_bin() + .current_dir(dir.path()) + .args(["lint", "hello.mds"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + output.status.success(), + "mds lint from cwd should succeed for a clean file; stderr: {stderr}" + ); +} + +/// `mds build --vars ` with a malformed JSON vars file exits 1 and the +/// error message names the vars file so the user knows which file to fix. +#[test] +fn vars_file_malformed_json_error_names_the_file() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("hello.mds"); + let vars = dir.path().join("vars.json"); + std::fs::write(&src, "Hello!\n").unwrap(); + // Write intentionally invalid JSON. + std::fs::write(&vars, "{ not valid json }").unwrap(); + + let output = mds_bin() + .args([ + "build", + src.to_str().unwrap(), + "--vars", + vars.to_str().unwrap(), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "malformed vars JSON should cause a non-zero exit" + ); + assert_eq!( + output.status.code(), + Some(1), + "malformed vars JSON should exit 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("vars.json"), + "error must name the vars file; got: {stderr}" + ); +} + +/// `mds build --vars ` with a non-object JSON root (array) exits 1 and +/// the error message names the vars file. +#[test] +fn vars_file_non_object_json_error_names_the_file() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("hello.mds"); + let vars = dir.path().join("myvars.json"); + std::fs::write(&src, "Hello!\n").unwrap(); + // Write a JSON array — valid JSON but not a JSON object. + std::fs::write(&vars, r#"["a", "b"]"#).unwrap(); + + let output = mds_bin() + .args([ + "build", + src.to_str().unwrap(), + "--vars", + vars.to_str().unwrap(), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "non-object vars JSON should cause a non-zero exit" + ); + assert_eq!( + output.status.code(), + Some(1), + "non-object vars JSON should exit 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("myvars.json"), + "error must name the vars file; got: {stderr}" + ); +} + +// ── Bare-filename regression (PF-006 / issue #11) ──────────────────────────── + +/// `mds watch hello.mds` from the directory containing `hello.mds` must start up, +/// complete the initial compile, and write `hello.md` with the correct content. +/// +/// PF-006 fifth sibling: bare-filename watch invocation. The other four siblings +/// (build / check / fmt / lint) are above. Watch is the one subcommand that +/// resolves parents through its own call sites; this test locks in that startup path. +/// +/// Only asserts the INITIAL BUILD (bounded 10-second wait) — no event-timing +/// assertions that would be timing-flaky on Linux CI. +#[test] +fn watch_bare_filename_from_cwd_succeeds() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + // RAII guard — kills + waits the child on drop so the test never leaks processes. + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + let dir = tempfile::tempdir().unwrap(); + // Use a distinguishable sentinel so "exit 0 + empty file" can't pass. + std::fs::write(dir.path().join("hello.mds"), "Hello from watch!\n").unwrap(); + let out = dir.path().join("hello.md"); + + let _child = ChildGuard( + mds_bin() + .current_dir(dir.path()) + .args(["watch", "hello.mds", "--debounce", "0", "-q"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn mds watch"), + ); + + // Poll until the output file appears and contains the compiled content. + // Bounded to 10 s; the initial compile typically finishes in < 100 ms. + let deadline = Instant::now() + Duration::from_secs(10); + let found = loop { + if let Ok(content) = std::fs::read_to_string(&out) { + if content.contains("Hello from watch!") { + break true; + } + } + if Instant::now() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!( + found, + "mds watch from cwd should complete initial compile and write hello.md \ + containing 'Hello from watch!'" + ); +} + +/// `load_config` must find `mds.json` in a grandparent directory when building +/// from a bare filename in a deeply nested subdirectory. +/// +/// Regression for issue #11: a relative `start_dir` (`"."`) caused +/// `current.parent()` to return `None` after just one iteration, making any +/// `mds.json` beyond the immediate parent unreachable even when +/// `MAX_TRAVERSAL_DEPTH` would allow it. +/// +/// Verifier: we configure `build.output_dir = "out"` in the root `mds.json`. +/// If `load_config` finds the config, output lands in `root/out/hello.md`. +/// If it silently skips it, output falls back to the input directory +/// (`root/sub/nested/hello.md`), failing the assertion. +#[test] +fn build_load_config_finds_grandparent_mds_json() { + let root = tempfile::tempdir().unwrap(); + // Create: root/sub/nested/hello.mds + let nested = root.path().join("sub").join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("hello.mds"), "Hello!\n").unwrap(); + // Place mds.json at root/ with an output_dir that differs from the input dir. + std::fs::write( + root.path().join("mds.json"), + r#"{"build": {"output_dir": "out"}}"#, + ) + .unwrap(); + + let output = mds_bin() + .arg("build") + .arg("hello.mds") // bare filename — not an absolute path + .current_dir(&nested) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "build from bare filename in nested dir must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + // `load_config` found root/mds.json → output goes to root/out/hello.md. + // Without the fix it would fall back to root/sub/nested/hello.md. + let config_output = root.path().join("out").join("hello.md"); + assert!( + config_output.exists(), + "output must be in root/out/ (per grandparent mds.json); stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +// ── ESC injection regression (issue #5 / ESC-INJECTION) ────────────────────── + +/// Regression gate: `mds build` (single-file mode) must not emit raw ESC bytes to +/// stderr when the source file embeds a raw ESC byte (U+001B) in content that reaches +/// `MdsError::Syntax`. +/// +/// Single-file builds do not go through the per-file handler in `run_build_directory`; +/// the error propagates all the way to `main()`, which is the last-resort render boundary. +/// This test validates that boundary specifically. +/// +/// Companion to `build_esc_byte_in_syntax_error_is_sanitized_on_stderr` which covers +/// directory mode (the first guarded path). Both paths must be sanitized to prevent +/// terminal escape injection from untrusted source files (PF-004 / ESC-INJECTION). +#[test] +fn build_single_file_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("esc.mds"); + // Raw ESC byte (0x1B) in a .mds file that has a syntax error (unclosed @define). + // The ESC is on the error line so miette renders it inside the source context frame. + std::fs::write(&path, b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = mds_bin() + .arg("build") + .arg(&path) // single-file mode (not a directory) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + // Build must fail (syntax error in the file). + assert_ne!( + out.status.code(), + Some(0), + "build with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Raw ESC byte (0x1B) must not appear anywhere in stderr. + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (single-file mode); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); +} + +/// Regression gate: `mds build` (directory mode) must not emit raw ESC bytes to +/// stderr when a source file embeds a raw ESC byte (U+001B) in content that reaches +/// `MdsError::Syntax`. +/// +/// Uses directory mode so the error is rendered by the per-file error handler in +/// `run_build_directory` (the `build.rs` render boundary that was vulnerable). +/// For single-file builds the error propagates through `main`; this test +/// validates the directory-mode path specifically. +#[test] +fn build_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + // Raw ESC byte (0x1B) in a .mds file that has a syntax error (unclosed @define). + // The ESC is on the error line so miette renders it inside the source context frame. + std::fs::write(dir.path().join("esc.mds"), b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = mds_bin() + .arg("build") + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + // Build must fail (syntax error in the file). + assert_ne!( + out.status.code(), + Some(0), + "build with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Raw ESC byte (0x1B) must not appear anywhere in stderr. + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr; \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); +} diff --git a/crates/mds-cli/tests/cli_commands.rs b/crates/mds-cli/tests/cli_commands.rs index 28e667ef..3f100671 100644 --- a/crates/mds-cli/tests/cli_commands.rs +++ b/crates/mds-cli/tests/cli_commands.rs @@ -608,3 +608,89 @@ fn cli_init_rejects_path_traversal() { "error should mention path traversal, got: {stderr}" ); } + +// ── ESC injection regression — mds check (issue #5 / ESC-INJECTION) ────────── + +/// Regression gate: `mds check ` (single-file mode) must not emit raw +/// ESC bytes to stderr when the source file contains a raw ESC byte that reaches +/// `MdsError::Syntax`. +/// +/// Single-file check errors propagate to `main()` via `?`, which is the +/// last-resort sanitizer boundary at `main.rs`. This test validates that +/// boundary for the check subcommand specifically. +#[test] +fn check_single_file_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("esc_check.mds"); + std::fs::write(&path, b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = mds_bin() + .args(["check"]) + .arg(&path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert_ne!( + out.status.code(), + Some(0), + "check with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (check single-file); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); + assert!( + !out.stdout.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not appear in stdout (check single-file); \ + got (hex): {:02x?}", + &out.stdout[..out.stdout.len().min(512)] + ); +} + +/// Regression gate: `mds check ` (directory mode) must not emit raw ESC bytes +/// to stderr when a source file contains a raw ESC byte that reaches `MdsError::Syntax`. +/// +/// Directory check errors are handled in `run_check_directory` (main.rs) which +/// explicitly calls `mds::sanitize_control_chars` at the per-file render site. +/// This test validates that boundary for the check directory path. +#[test] +fn check_directory_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("esc_check.mds"), + b"@define \x1bfoo:\nhello\n", + ) + .unwrap(); + + let out = mds_bin() + .args(["check"]) + .arg(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert_ne!( + out.status.code(), + Some(0), + "check dir with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (check directory); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); + assert!( + !out.stdout.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not appear in stdout (check directory); \ + got (hex): {:02x?}", + &out.stdout[..out.stdout.len().min(512)] + ); +} diff --git a/crates/mds-cli/tests/cli_fmt.rs b/crates/mds-cli/tests/cli_fmt.rs index 5c3098ec..80bf451b 100644 --- a/crates/mds-cli/tests/cli_fmt.rs +++ b/crates/mds-cli/tests/cli_fmt.rs @@ -944,3 +944,350 @@ fn pre_existing_config_without_fmt_section_still_loads() { String::from_utf8_lossy(&output.stderr) ); } + +// ── Walker default exclusion: node_modules ──────────────────────────────────── + +/// `mds fmt ` must leave files inside `node_modules/` untouched. +/// The summary must not count the `node_modules` file in the formatted/unchanged +/// total — the directory is simply not traversed. +#[test] +fn dir_fmt_skips_node_modules() { + let dir = tempfile::tempdir().unwrap(); + // One normal file that needs formatting (has \r so it changes). + fs::write(dir.path().join("main.mds"), "Hello \r\nworld\r\n").unwrap(); + // File inside node_modules — must not be touched. + let nm = dir.path().join("node_modules"); + std::fs::create_dir(&nm).unwrap(); + fs::write(nm.join("lib.mds"), "lib content\r\n").unwrap(); + + let output = fmt_path(dir.path(), &[]); + let stderr = String::from_utf8_lossy(&output.stderr); + + // The node_modules file must not have been modified. + let nm_content = fs::read_to_string(nm.join("lib.mds")).unwrap(); + assert_eq!( + nm_content, "lib content\r\n", + "node_modules/lib.mds must not be reformatted" + ); + + // Summary must say "1 formatted" (only the root-level main.mds), not "2". + assert!( + stderr.contains("1 formatted"), + "summary must show exactly 1 formatted file (node_modules excluded); got: {stderr}" + ); + assert!( + output.status.success(), + "fmt should succeed; stderr: {stderr}" + ); +} + +// ── RELEASE BLOCKER 3: formatter gate must not false-positive on trailing blank ── + +/// `mds fmt` on a non-compiling source (undefined variable → structural_equivalent +/// fallback) with a trailing blank line after the final directive must exit 0 and +/// write the formatted file — NOT exit 1 with "formatter_invariant". +/// +/// Before the fix, R2's `trim_end()` deleted the trailing Text("\n") token from the +/// formatted output while the source still had it, triggering a spurious +/// FormatterInvariant error in structural_equivalent's token-count guard. +#[test] +fn gate_fallback_no_false_positive_on_trailing_blank_line_exits_zero() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("trailing_blank.mds"); + // Non-compiling source (undefined_var): takes structural_equivalent path. + // Trailing blank line after @end: was the trigger for the false positive. + fs::write(&target, "@if undefined_var:\nx\n@end\n\n").unwrap(); + + let output = fmt_path(&target, &[]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("formatter_invariant") && !stderr.contains("file an issue"), + "trailing blank line must NOT produce a FormatterInvariant, got: {stderr}" + ); + assert!( + output.status.success(), + "fmt must succeed (exit 0) for a non-compiling source with trailing blank; stderr: {stderr}" + ); + + // The file must have been formatted (trailing blank trimmed). + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + after, "@if undefined_var:\nx\n@end\n", + "formatted file must have trailing blank removed" + ); +} + +// ── format_str_named: file path appears in error output ─────────────────────── + +/// Single-file mode: when the source has a genuine syntax error (unclosed @if), +/// `mds fmt` must show the file path in stderr so the user can locate the problem. +/// The file name must appear regardless of whether it is a lex- or parse-level error. +#[test] +fn single_file_syntax_error_includes_path_in_stderr() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("broken.mds"); + fs::write(&target, "@if cond:\nHello\n").unwrap(); // unclosed @if -> parse-level Syntax + + let output = fmt_path(&target, &[]); + + assert!( + !output.status.success(), + "fmt must fail (exit non-zero) on syntax error" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("broken.mds"), + "stderr must contain the file name so the user can locate the problem; got: {stderr}" + ); +} + +/// Directory mode: each failing file should be identified in stderr. +/// The file path prefix (`{file}: `) must appear before the error so large +/// directory runs show which file triggered each failure. +#[test] +fn dir_mode_format_error_includes_file_prefix_in_stderr() { + let dir = tempfile::tempdir().unwrap(); + let bad = dir.path().join("bad.mds"); + let good = dir.path().join("good.mds"); + fs::write(&bad, "@if cond:\nHello\n").unwrap(); // unclosed @if -> format error + fs::write(&good, "Hello!\n").unwrap(); + + let output = fmt_path(dir.path(), &[]); + + // Exit non-zero because bad.mds failed. + assert!( + !output.status.success(), + "fmt should exit non-zero when a file in the directory fails" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + // The bad file's name must appear in stderr (either as a {file}: prefix or in the error). + assert!( + stderr.contains("bad.mds"), + "dir-mode stderr must identify the failing file; got: {stderr}" + ); +} + +/// `mds fmt --check` summary includes the unchanged count alongside the would-reformat count. +/// New format: `{changed} would reformat, {unchanged} unchanged, {fail} failed` +#[test] +fn dir_check_summary_includes_unchanged_count() { + let dir = tempfile::tempdir().unwrap(); + // One dirty file (would reformat) + one already-clean file (unchanged). + fs::write( + dir.path().join("dirty.mds"), + read_fixture("fmt_unformatted.mds"), + ) + .unwrap(); + fs::write( + dir.path().join("clean.mds"), + read_fixture("fmt_formatted.mds"), + ) + .unwrap(); + + let output = fmt_path(dir.path(), &["--check"]); + assert!( + !output.status.success(), + "dir --check with a dirty file must exit non-zero" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("would reformat") && stderr.contains("unchanged"), + "check summary must include both 'would reformat' and 'unchanged'; got: {stderr}" + ); + // Verify the exact format: "N would reformat, M unchanged, K failed" + assert!( + stderr.contains("1 would reformat") && stderr.contains("1 unchanged"), + "expected '1 would reformat' and '1 unchanged' in summary; got: {stderr}" + ); +} + +// ── Bare-filename regression (PF-006) ──────────────────────────────────────── + +/// A syntax error in a bare-filename source must exit non-zero and propagate +/// the syntax diagnostic. +/// +/// Regression for PF-006: `path.parent()` on a bare filename returns `Some("")`. +/// `NativeFs::canonicalize("")` failed with `MdsError::Io`, which the +/// `assert_equivalent` fallback path silently swallowed (fell through to +/// `structural_equivalent`) instead of propagating the `MdsError::Syntax` error. +/// After the `resolve_base_dir` + `effective_parent` fix the syntax error must +/// surface as a non-zero exit. +/// +/// Uses `.current_dir(tempdir)` with a bare argument — absolute paths go through +/// a different code path and never triggered the bug. +#[test] +fn fmt_bare_filename_propagates_syntax_error() { + let dir = tempfile::tempdir().unwrap(); + // An unclosed @message block is a parse-level syntax error (missing @end). + // This same source is used by unclosed_directive_block_exits_one_with_syntax_not_formatter_invariant + // (which passes an absolute path); our test exercises the bare-filename path. + let src = "@message user:\nHi there\n"; + fs::write(dir.path().join("broken.mds"), src).unwrap(); + + let output = mds_bin() + .arg("fmt") + .arg("broken.mds") // bare filename — the only form that triggered the bug + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "fmt of a bare broken filename must exit non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("syntax") || stderr.contains("@end"), + "stderr must name the syntax problem; got: {stderr}" + ); + // File must be untouched — the formatter must never write garbled output. + let after = fs::read_to_string(dir.path().join("broken.mds")).unwrap(); + assert_eq!(after, src, "broken file must be left untouched"); +} + +// ── ESC injection regression — mds fmt (issue #5 / ESC-INJECTION) ──────────── + +/// Regression gate: `mds fmt ` (single-file mode) must not emit raw ESC +/// bytes to stderr when the source file contains a raw ESC byte that reaches +/// `MdsError::Syntax`. +/// +/// Single-file fmt errors propagate to `main()` via `?`, which is the +/// last-resort sanitizer boundary at `main.rs`. This test validates that +/// boundary for the fmt subcommand specifically. +#[test] +fn fmt_single_file_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("esc_fmt.mds"); + fs::write(&path, b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = fmt_path(&path, &[]); + + assert_ne!( + out.status.code(), + Some(0), + "fmt with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (fmt single-file); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); + assert!( + !out.stdout.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not appear in stdout (fmt single-file); \ + got (hex): {:02x?}", + &out.stdout[..out.stdout.len().min(512)] + ); +} + +// ── Permission preservation (issue #25 — atomic write regression gate) ────── + +/// Regression gate: `mds fmt ` (single-file mode) must preserve the +/// original Unix file mode after formatting. +/// +/// The write path was changed from bare `std::fs::write` (which truncates the +/// existing file in place, keeping its permissions) to `atomic_write_file` (temp +/// file + rename). `tempfile::Builder` defaults to mode 0600; without permission +/// preservation the rename would silently turn a 0644 source file into owner-only. +/// +/// `atomic_write_file` already handles this (commit c5aa086 hardened the lint +/// path) — this test locks in the same guarantee for the fmt path. +#[cfg(unix)] +#[test] +fn fmt_single_file_preserves_mode_0644() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("perm_test.mds"); + // Write unformatted content that fmt will actually rewrite. + fs::write(&target, read_fixture("fmt_unformatted.mds")).unwrap(); + // Set 0644 explicitly (some systems may default differently). + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + + let output = fmt_path(&target, &[]); + assert!( + output.status.success(), + "fmt should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o644, + "fmt single-file must preserve file mode 0644 after atomic write; got 0{mode:o}" + ); +} + +/// Regression gate: `mds fmt ` (directory mode) must preserve the original +/// Unix file mode after formatting. +/// +/// Directory mode routes through `format_one_file` → `atomic_write_file`. +/// Same tempfile-0600 hazard as the single-file path above. +#[cfg(unix)] +#[test] +fn fmt_directory_mode_preserves_mode_0644() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("perm_dir_test.mds"); + fs::write(&target, read_fixture("fmt_unformatted.mds")).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + + // Run fmt on the DIRECTORY — exercises format_one_file, not run_fmt_file. + let output = fmt_path(dir.path(), &[]); + assert!( + output.status.success(), + "fmt dir should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o644, + "fmt directory mode must preserve file mode 0644 after atomic write; got 0{mode:o}" + ); +} + +/// Regression gate: `mds fmt ` (directory mode) must not emit raw ESC bytes +/// to stderr when a source file contains a raw ESC byte that reaches `MdsError::Syntax`. +/// +/// Directory mode routes through `format_one_file`, which previously called +/// `eprintln!("{file_name}: {e:?}")` without sanitization. That path is now +/// guarded by `crate::output::eprint_error` (avoids PF-004 parallel-path gap). +#[test] +fn fmt_directory_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + // Raw ESC byte (0x1B) on the error line so miette renders it in the source context frame. + fs::write( + dir.path().join("esc_fmt_dir.mds"), + b"@define \x1bfoo:\nhello\n", + ) + .unwrap(); + + let out = fmt_path(dir.path(), &[]); + + assert_ne!( + out.status.code(), + Some(0), + "fmt dir with syntax error should fail; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (fmt directory); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); + assert!( + !out.stdout.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not appear in stdout (fmt directory); \ + got (hex): {:02x?}", + &out.stdout[..out.stdout.len().min(512)] + ); +} diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index d690af34..9139c2c0 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -53,12 +53,9 @@ fn lint_stdin(input: &str, extra_args: &[&str]) -> std::process::Output { .stderr(std::process::Stdio::piped()) .spawn() .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(input.as_bytes()) - .unwrap(); + // Ignore BrokenPipe — the child may exit before reading stdin + // (e.g. a usage error detected before the process reads any input). + let _ = child.stdin.take().unwrap().write_all(input.as_bytes()); child.wait_with_output().unwrap() } @@ -816,3 +813,893 @@ fn json_format_malformed_config_emits_error_envelope() { "config error must go to stdout in JSON mode, not stderr; got stderr: {stderr}" ); } + +// ── Phase B pin tests ───────────────────────────────────────────────────────── + +// ── Test (a): Dir-mode JSON distinct paths for same-basename files ──────────── +// +// Pins bug-4 fix: in directory mode with --format json, the `file` key in each +// JSON diagnostic entry must be the relative path from the lint root, NOT the +// basename. Two files with the same basename in different subdirectories must +// produce two distinct `file` keys. +// +// Pre-Phase-B behavior: mds::lint() sets diag.file to the basename only +// (path.file_name()), so all three entries would share the key "template.mds". + +#[test] +fn dir_mode_json_same_basename_files_have_distinct_paths() { + let dir = tempfile::tempdir().unwrap(); + // Create two subdirs each with a file of the same basename but a diagnostic. + for sub in &["sub_a", "sub_b"] { + let subdir = dir.path().join(sub); + fs::create_dir_all(&subdir).unwrap(); + // lint_warn_only content: unused-variable warning → appears in JSON output. + fs::copy(fixture("lint_warn_only.mds"), subdir.join("template.mds")).unwrap(); + } + + let out = lint_path(dir.path(), &["--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // At least one file has a warning → exit 1. + assert_eq!( + out.status.code(), + Some(1), + "dir with warn-only files must exit 1; stderr: {stderr}" + ); + + let json: serde_json::Value = serde_json::from_str(&stdout).expect("stdout must be valid JSON"); + let files = json["files"].as_array().expect("must have files[]"); + assert_eq!( + files.len(), + 2, + "both files must appear in JSON output; got: {files:?}" + ); + + let paths: Vec<&str> = files + .iter() + .map(|f| { + f["file"] + .as_str() + .expect("each entry must have a file string") + }) + .collect(); + + // Each path must contain its subdirectory prefix — not just "template.mds". + assert!( + paths.iter().any(|p| p.contains("sub_a")), + "sub_a path must appear in file keys; got: {paths:?}" + ); + assert!( + paths.iter().any(|p| p.contains("sub_b")), + "sub_b path must appear in file keys; got: {paths:?}" + ); + + // The two entries must be distinct (not both "template.mds"). + let mut sorted = paths.clone(); + sorted.dedup(); + assert_eq!( + sorted.len(), + 2, + "file keys must be distinct; got: {paths:?}" + ); +} + +// ── Test (b): --fix --format json dir-mode residuals keyed by relative path ── +// +// Pins that after --fix in directory mode, residual diagnostics in the JSON output +// are keyed by the relative display path, NOT by "input.mds" or the raw basename. +// +// Fixture: a file with duplicate-export (Tier A, auto-fixed) + unused-variable +// (Tier C, not auto-fixed). After --fix, the residual unused-variable diagnostic +// must appear under the relative path key, not "input.mds". + +#[test] +fn dir_fix_json_residuals_keyed_by_relative_path_not_input_mds() { + let dir = tempfile::tempdir().unwrap(); + // Put the fixture one level deep so display_path = "subdir/mixed.mds". + let subdir = dir.path().join("subdir"); + fs::create_dir_all(&subdir).unwrap(); + + // Construct a file that has both duplicate-export (fixable) and unused-variable + // (residual after fix): reuse lint_error.mds (duplicate-export) content plus the + // unused frontmatter key from lint_warn_only.mds. + let mixed = "---\ngreeting: Hello\nunused_key: not referenced\n---\n\n\ + @define greet(name):\n Hello {name}!\n@end\n\n\ + @export greet\n@export greet\n"; + let target = subdir.join("mixed.mds"); + fs::write(&target, mixed).unwrap(); + + let out = lint_path(dir.path(), &["--fix", "--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be valid JSON; err: {e}; stdout: {stdout}; stderr: {stderr}") + }); + + // After fixing duplicate-export, unused-variable residual remains → exit 1. + assert_eq!( + out.status.code(), + Some(1), + "residual unused-variable must produce exit 1; stderr: {stderr}" + ); + + let files = json["files"].as_array().expect("must have files[]"); + assert!(!files.is_empty(), "files[] must be non-empty after fix"); + + // Every file key must be the relative path, not "input.mds". + for entry in files { + let file_key = entry["file"].as_str().unwrap_or(""); + assert!( + !file_key.contains("input.mds"), + "file key must NOT be 'input.mds'; got: {file_key}" + ); + assert!( + file_key.contains("subdir") || file_key.contains("mixed"), + "file key must reference the actual file; got: {file_key}" + ); + } +} + +// ── Test (c): --fix --check on refused-fix fixture → prints "fix rejected" ─── +// +// Pins bug-5 / PF-004 fix for the check path: preview_fixes now returns a +// PreviewOutcome::Rejected so --fix --check can surface the rejection reason. +// +// Pre-Phase-B behavior: preview_fixes returned Option and mapped Rejected +// to None — --fix --check never printed "fix rejected" even when the reverify gate +// refused the edit. +// +// Fixture: lint_block_span_empty.mds (multi-line empty @define). The fix removes +// the opening @define line, orphaning @end → reverify gate fails → Rejected. + +#[test] +fn fix_check_refused_fix_prints_rejected_not_would_fix() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_block_span_empty.mds"); + fs::copy(fixture("lint_block_span_empty.mds"), &target).unwrap(); + let original = fs::read_to_string(&target).unwrap(); + + let out = lint_path(&target, &["--fix", "--check"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // "fix rejected" must appear: the reverify gate refused the empty-block removal. + assert!( + stderr.contains("fix rejected"), + "--fix --check must print 'fix rejected' when the reverify gate refuses; got stderr: {stderr}" + ); + + // "Would fix" must NOT appear: the fix was rejected, not pending. + assert!( + !stderr.contains("Would fix"), + "--fix --check must NOT print 'Would fix' when fix is rejected; got stderr: {stderr}" + ); + + // File must be untouched — check mode never writes. + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + original, after, + "--fix --check must never write to the file" + ); +} + +// ── Test (d): --fix --check fixable fixture → "Would fix", exit 1, no write ── +// +// Pins the positive case: when a fix WOULD succeed, --fix --check prints "Would fix", +// exits 1, and leaves the file unchanged on disk. + +#[test] +fn fix_check_fixable_prints_would_fix_and_exits_1_file_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_error.mds"); + fs::copy(fixture("lint_error.mds"), &target).unwrap(); + let original = fs::read_to_string(&target).unwrap(); + + let out = lint_path(&target, &["--fix", "--check"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // "Would fix" must appear. + assert!( + stderr.contains("Would fix"), + "--fix --check must print 'Would fix' for a fixable file; got stderr: {stderr}" + ); + + // exit 1 (fix pending). + assert_eq!( + out.status.code(), + Some(1), + "--fix --check must exit 1 when a fix is pending; got stderr: {stderr}" + ); + + // File must be unchanged on disk. + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + original, after, + "--fix --check must never write to the file" + ); +} + +// ── Test (e): Directory --fix --check exits 1 iff any file would change ────── +// +// Pins the directory-mode any_would_fix accumulation (--fix --check exits 1 after +// processing all files when at least one would be modified, exit 0 when none). + +#[test] +fn dir_fix_check_exits_1_when_any_file_fixable_exits_0_when_none() { + // Case 1: directory with one fixable file → exit 1. + let dir1 = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_error.mds"), dir1.path().join("a.mds")).unwrap(); + fs::copy(fixture("lint_clean.mds"), dir1.path().join("b.mds")).unwrap(); + + let out1 = lint_path(dir1.path(), &["--fix", "--check"]); + let stderr1 = String::from_utf8_lossy(&out1.stderr); + assert_eq!( + out1.status.code(), + Some(1), + "dir with fixable file must exit 1 under --fix --check; stderr: {stderr1}" + ); + // Neither file must have been modified. + assert_eq!( + fs::read_to_string(dir1.path().join("a.mds")).unwrap(), + fs::read_to_string(fixture("lint_error.mds")).unwrap(), + "fixable file must not be written by --fix --check" + ); + + // Case 2: directory with only clean files → exit 0. + let dir2 = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_clean.mds"), dir2.path().join("c.mds")).unwrap(); + + let out2 = lint_path(dir2.path(), &["--fix", "--check"]); + let stderr2 = String::from_utf8_lossy(&out2.stderr); + assert_eq!( + out2.status.code(), + Some(0), + "dir with only clean files must exit 0 under --fix --check; stderr: {stderr2}" + ); +} + +// ── Test (f): Overlap fixture → visible "fix rejected"/overlap message ──────── +// +// Pins bug-12 / preview-honesty: when two Tier-A edits target the same line +// (overlap detected), the fix is refused with "Overlapping fix spans detected" +// and the output is NOT silent. +// +// Fixture: lint_overlap.mds — a @define containing an @if "a"=="a" block with +// a @elseif "a"=="a" that is both unreachable AND has an empty body. Both +// empty-block and unreachable-branch fire on the same @elseif line → same byte +// range → overlap detected → Rejected. +// +// Tests --fix (apply) path: the rejection message appears on stderr, the file is +// untouched, and exit code reflects the residual diagnostics. + +#[test] +fn fix_overlap_surfaced_not_silent() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_overlap.mds"); + fs::copy(fixture("lint_overlap.mds"), &target).unwrap(); + let original = fs::read_to_string(&target).unwrap(); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // "fix rejected" must appear — not silent. + assert!( + stderr.contains("fix rejected"), + "--fix on overlap fixture must print 'fix rejected'; got stderr: {stderr}" + ); + + // The overlap reason must mention "overlap" or "Overlapping". + assert!( + stderr.to_lowercase().contains("overlap"), + "rejection reason must mention 'overlap'; got stderr: {stderr}" + ); + + // File must be unchanged (fix was refused, no write). + let after = fs::read_to_string(&target).unwrap(); + assert_eq!( + original, after, + "overlap-rejected file must be left unchanged" + ); +} + +// ── Test (g): PartiallyFixed end-to-end: applied count in summary ───────────── +// +// Pins the PartiallyFixed outcome: when some edits pass the reverify gate and +// some fail, the CLI writes the partially-fixed file and emits a +// "{applied} of {total} fixes applied" summary. +// +// Fixture: lint_partial_fix.mds — contains a multi-line empty @define (Tier A, +// fix fails reverify because @end is orphaned after removing the @define line) +// and a duplicate @export (Tier A, fix passes — just removes a line). +// +// Expected behaviour: +// - Batch attempt: fails (empty-block removal + dup-export removal together → +// @end orphaned → reverify rejects). +// - Per-edit fallback right-to-left: +// 1. duplicate-export (higher offset) → applied, reverify passes. +// 2. empty-block (lower offset) → reverify fails → rejected. +// - File written with one @export greet remaining; empty @define still present. +// - Stderr: "1 of 2 fixes applied" (or "Partially fixed: … (1 of 2 fixes applied)"). + +#[test] +fn partially_fixed_end_to_end_count_in_summary() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lint_partial_fix.mds"); + fs::copy(fixture("lint_partial_fix.mds"), &target).unwrap(); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // Stderr must contain the count summary. + assert!( + stderr.contains("1 of 2"), + "stderr must contain '1 of 2 fixes applied' summary; got: {stderr}" + ); + + let after = fs::read_to_string(&target).unwrap(); + + // The duplicate export must have been removed (duplicate-export fix was applied). + assert_eq!( + after.matches("@export greet").count(), + 1, + "file must have exactly one @export greet after partial fix; got:\n{after}" + ); + + // The empty @define must still be present (empty-block fix was rejected). + assert!( + after.contains("@define empty_fn():"), + "empty @define must still be present after partial fix; got:\n{after}" + ); + + // Residual diagnostics remain (empty-block Warn) → exit 1. + assert_eq!( + out.status.code(), + Some(1), + "residual empty-block Warn must produce exit 1; got stderr: {stderr}" + ); +} + +// ── Test (h): Stdin lint with diagnostic includes code frame ───────────────── +// +// Pins bug-19 fix: when lint runs in stdin (report-only) mode and emits a +// human diagnostic, the named source "input.mds" + source text must be attached +// so miette renders the annotated source context (code frame with caret underline). +// +// Pre-Phase-B behavior: named_source was None for stdin report-only mode, so no +// source context was rendered — diagnostics lacked the code frame entirely. + +#[test] +fn stdin_lint_diagnostic_includes_code_frame() { + let source = "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n"; + let out = lint_stdin(source, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + + // "duplicate-export" diagnostic must appear. + assert!( + stderr.contains("duplicate-export"), + "diagnostic must appear in stdin report-only mode; got: {stderr}" + ); + + // "input.mds" must appear: miette renders it as the file reference in the span header. + assert!( + stderr.contains("input.mds"), + "stdin mode must show 'input.mds' in the code frame; got: {stderr}" + ); + + // At least one token from the source must appear in the code frame context. + // miette renders the offending line; "@export greet" is on that line. + assert!( + stderr.contains("@export"), + "code frame must include the offending source line '@export greet'; got: {stderr}" + ); +} + +// ── Test (i): Auto-detect hint names the invoking subcommand ───────────────── +// +// Pins bugs 22/23: auto_detect_mds_file and resolve_input now take a `subcommand: +// &str` parameter. When multiple .mds files are present in the current directory +// and no file argument is given, the error hint must include the specific subcommand +// name (e.g. "mds lint "), NOT a generic "mds build ". +// +// Tests two subcommands (lint, fmt) to cover separate call sites. + +#[test] +fn auto_detect_hint_names_subcommand_lint_and_fmt() { + // ── lint subcommand ────────────────────────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_clean.mds"), dir.path().join("a.mds")).unwrap(); + fs::copy(fixture("lint_warn_only.mds"), dir.path().join("b.mds")).unwrap(); + + let out = mds_bin() + .arg("lint") + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("mds lint"), + "auto-detect hint for 'lint' must contain 'mds lint'; got: {stderr}" + ); + // Must NOT say "mds build" or "mds fmt" (wrong subcommand). + assert!( + !stderr.contains("mds build"), + "hint must not name a different subcommand; got: {stderr}" + ); + } + + // ── fmt subcommand ─────────────────────────────────────────────────────── + { + let dir = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_clean.mds"), dir.path().join("a.mds")).unwrap(); + fs::copy(fixture("lint_warn_only.mds"), dir.path().join("b.mds")).unwrap(); + + let out = mds_bin() + .arg("fmt") + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("mds fmt"), + "auto-detect hint for 'fmt' must contain 'mds fmt'; got: {stderr}" + ); + assert!( + !stderr.contains("mds build"), + "fmt hint must not name 'mds build'; got: {stderr}" + ); + } +} + +// ── resolve-w2 regression tests ────────────────────────────────────────────── + +// ── resolve-w2 #36: dir --fix --check --format json emits JSON before exit ─── +// +// Regression: the `any_would_fix` early `std::process::exit(1)` in +// `run_lint_directory` was sequenced BEFORE the JSON envelope emit block, so +// stdout was empty on exit. `JSON.parse("")` throws. +// +// Fix: emit the JSON envelope BEFORE the `any_would_fix` exit so that consumers +// always receive parseable output regardless of the exit code (AC-F-14 / ADR-004). + +#[test] +fn dir_fix_check_json_emits_parseable_json_before_exit_1() { + let dir = tempfile::tempdir().unwrap(); + // One fixable file — triggers any_would_fix = true. + fs::copy(fixture("lint_error.mds"), dir.path().join("fixable.mds")).unwrap(); + + let out = lint_path(dir.path(), &["--fix", "--check", "--format", "json"]); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // --fix --check exits 1 when fixes are pending. + assert_eq!( + out.status.code(), + Some(1), + "--fix --check must exit 1 when fixes are pending; stderr: {stderr}" + ); + + // stdout must be parseable JSON — not empty. + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "--fix --check --format json must emit parseable JSON before exiting; \ + parse error: {e}; stdout: '{stdout}'" + ) + }); + assert_eq!( + json["version"], 1, + "envelope must have version:1; got: {json}" + ); + assert!( + json["files"].is_array(), + "envelope must have files[]; got: {json}" + ); +} + +// ── resolve-w2 #59: stdin --fix --check never writes fixed source ───────────── +// +// Regression: `run_lint_stdin` destructured `LintFlags` with `..`, silently +// dropping `check` and `diff`. `mds lint - --fix --check` APPLIED FIXES AND WROTE +// THE RESULT TO STDOUT instead of exiting 1 without mutating anything. +// `--check` must never mutate (avoids PF-004). + +#[test] +fn stdin_fix_check_exits_1_and_writes_nothing_to_stdout() { + // A fixable source — duplicate-export, Tier A. + let source = "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n"; + let out = lint_stdin(source, &["--fix", "--check"]); + + // Must exit 1: fix is pending, --check signals "would change". + assert_eq!( + out.status.code(), + Some(1), + "--fix --check stdin must exit 1 when fixes are pending; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // stdout must be EMPTY — --check never writes. + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.is_empty(), + "--fix --check stdin must not write the fixed source to stdout; got: {stdout}" + ); +} + +// ── resolve-w2 #43: dir-mode and single-file-mode agree on --quiet for PartiallyFixed +// +// Regression: `lint_one_file_accumulating` destructured `LintFlags` without binding +// `quiet`, so `mds lint dir/ --fix --format json --quiet` emitted "partial fix:" +// lines to stderr that the single-file equivalent suppressed. Three different message +// texts across four call sites was the root cause. Refs: issue #173. + +#[test] +fn dir_and_single_file_agree_on_quiet_for_partially_fixed() { + // Case A: single-file mode --fix --quiet must suppress "Partially fixed" on stderr. + { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("partial.mds"); + fs::copy(fixture("lint_partial_fix.mds"), &target).unwrap(); + + let out = lint_path(&target, &["--fix", "--quiet"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("Partially fixed") && !stderr.to_lowercase().contains("partial fix"), + "single-file --fix --quiet must suppress 'Partially fixed'; got: {stderr}" + ); + } + + // Case B: directory-mode --fix --format json --quiet must ALSO suppress + // "Partially fixed" — this is the realized defect from #43. + { + let dir = tempfile::tempdir().unwrap(); + fs::copy( + fixture("lint_partial_fix.mds"), + dir.path().join("partial.mds"), + ) + .unwrap(); + + let out = lint_path(dir.path(), &["--fix", "--format", "json", "--quiet"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("Partially fixed") && !stderr.to_lowercase().contains("partial fix"), + "dir-mode --fix --format json --quiet must suppress 'Partially fixed'; got: {stderr}" + ); + + // Stdout must still be parseable JSON. + let stdout = String::from_utf8_lossy(&out.stdout); + let _: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("dir-mode --quiet must still emit valid JSON; err: {e}; stdout: {stdout}") + }); + } + + // Case C (positive): without --quiet, both modes print the unified message. + // Uses fresh copies so the previous partial-fix writes don't interfere. + { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("partial.mds"); + fs::copy(fixture("lint_partial_fix.mds"), &target).unwrap(); + + let out = lint_path(&target, &["--fix"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Partially fixed"), + "single-file --fix without --quiet must print 'Partially fixed'; got: {stderr}" + ); + } + { + let dir = tempfile::tempdir().unwrap(); + fs::copy( + fixture("lint_partial_fix.mds"), + dir.path().join("partial.mds"), + ) + .unwrap(); + + let out = lint_path(dir.path(), &["--fix", "--format", "json"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Partially fixed"), + "dir-mode --fix --format json without --quiet must print 'Partially fixed'; got: {stderr}" + ); + } +} + +// ── Bare-filename regression (PF-006) ──────────────────────────────────────── + +/// `mds lint --fix` on a bare filename must apply fixes in place. +/// +/// Regression for PF-006: `path.parent()` on a bare filename returns `Some("")`. +/// `NativeFs::check_symlink("")` failed because `"".file_name()` returns `None`, +/// making `atomic_write_file` reject every fix edit with an Io error — silently +/// turning `--fix` into a no-op for any file passed as a bare name. +/// +/// Uses `.current_dir(tempdir)` with a bare argument. +#[test] +fn lint_fix_bare_filename_applies_fix() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("dup.mds"); + fs::copy(fixture("lint_error.mds"), &target).unwrap(); + + let original = fs::read_to_string(&target).unwrap(); + assert!( + original.contains("@export greet\n@export greet"), + "fixture must have duplicate export" + ); + + let out = mds_bin() + .arg("lint") + .arg("--fix") + .arg("dup.mds") // bare filename — the only form that triggered the bug + .current_dir(dir.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "--fix on bare filename should exit 0 after fixing; stderr: {stderr}" + ); + + let after = fs::read_to_string(&target).unwrap(); + assert_ne!(after, original, "--fix must have rewritten the file"); + assert_eq!( + after.matches("@export greet").count(), + 1, + "exactly one @export greet should remain after --fix; got:\n{after}" + ); +} + +// ── ESC injection regression (issue #5 / ESC-INJECTION) ────────────────────── + +/// Regression gate: a .mds file containing a raw ESC byte (U+001B) that reaches +/// `MdsError::Syntax` must not emit raw ESC bytes to stderr — single-file mode. +/// +/// Background: `MdsError::Syntax` embeds user-controlled source fragments via +/// miette's NamedSource. Before the fix, those fragments printed with raw ESC +/// bytes intact, enabling terminal escape injection when linting untrusted repos. +/// The fix sanitizes at the CLI render boundary in `emit_analysis_failure_json_or_stderr`. +#[test] +fn lint_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("esc_test.mds"); + // Raw ESC byte (0x1B) on the same line as the syntax error so miette renders it + // as part of the source context. Unclosed @define → guaranteed syntax error. + fs::write(&file, b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = lint_path(&file, &[]); + // Must exit 2 (analysis/gate failure, not lint-severity exit 1). + assert_eq!( + out.status.code(), + Some(2), + "syntax error should exit 2; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Raw ESC byte (0x1B) must not appear anywhere in stderr output. + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr; \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); +} + +// ── atomic_write_file: mode preservation and error-message coverage ────────── + +/// Regression gate: `mds lint --fix ` must preserve the original Unix file +/// mode after applying auto-fixes via `atomic_write_file`. +/// +/// `tempfile::Builder` defaults to mode 0600; without the permission-restoration +/// step a 0644 source file turns owner-only after the rename. The masking of +/// file-type bits (`mode & 0o7777`) ensures `Permissions::from_mode` receives +/// only the permission bits. This test locks in that guarantee for the lint path +/// now that `atomic_write_file` lives in `output.rs` and is shared with `fmt`. +#[cfg(unix)] +#[test] +fn lint_fix_preserves_mode_0644() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("perm_lint.mds"); + // Write content with a single Tier A auto-fixable issue (duplicate-export) + // and no residual warning, so --fix exits 0 (clean after fix). + fs::write( + &target, + "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n", + ) + .unwrap(); + // Set 0644 explicitly before invoking --fix. + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + + let out = lint_path(&target, &["--fix"]); + // Must succeed (duplicate-export is Tier A; residual after fix is clean). + assert!( + out.status.success(), + "lint --fix should succeed on duplicate-export fixture; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o644, + "lint --fix must preserve file mode 0644 after atomic write; got 0{mode:o}" + ); +} + +/// Regression gate: when `atomic_write_file` fails (e.g. directory not writable), +/// the error message emitted to stderr MUST include the target filename so the +/// user can diagnose which file caused the failure. +/// +/// This gates that all error paths in `atomic_write_file` carry `path.display()`. +/// Previously only the `persist()` error carried the path; all other paths +/// (temp-file creation, permission set, write, fsync) emitted generic messages. +/// Fixed by step 9.1 (all 5 non-persist errors now include `path.display()`). +#[cfg(unix)] +#[test] +fn lint_write_failure_includes_filename_in_stderr() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("write_fail.mds"); + // A single Tier A auto-fixable issue so lint will attempt to write the file. + fs::write( + &target, + "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n", + ) + .unwrap(); + + // Make the parent directory read-only so temp-file creation fails. + // This triggers the "cannot create temp file for {path}" error path. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap(); + + let out = lint_path(&target, &["--fix"]); + + // Restore writability so tempdir cleanup can succeed. + let _ = fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)); + + // The write must have failed (non-zero exit). + assert_ne!( + out.status.code(), + Some(0), + "lint --fix must fail when the parent dir is read-only" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + // The filename "write_fail.mds" must appear in the error message. + assert!( + stderr.contains("write_fail.mds"), + "error stderr must contain the target filename; got: {stderr}" + ); +} + +/// Regression gate (single-file mode): when `atomic_write_file` fails, +/// stderr must NOT contain "Fixed: " — the success label must only +/// appear after a successful write, never before. +/// +/// Previously `lint.rs` emitted `eprintln!("Fixed: ...")` BEFORE calling +/// `atomic_write_file`, so a failed write printed "Fixed: …/e1.mds" followed +/// immediately by "error writing …/e1.mds" — actively lying about the +/// outcome. `fmt.rs:284` already does this correctly (write first, label on +/// `Ok(())`); this test locks in parity for both lint modes. +#[cfg(unix)] +#[test] +fn lint_fix_write_failure_does_not_print_fixed_label_single_file() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("no_fixed_label.mds"); + // Tier A auto-fixable content. + fs::write( + &target, + "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n", + ) + .unwrap(); + + // Make the parent directory read-only so atomic_write_file fails. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap(); + + let out = lint_path(&target, &["--fix"]); + + // Restore writability before any assertions (ensures tempdir cleanup succeeds + // even if the test panics). + let _ = fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // The write must have failed. + assert_ne!( + out.status.code(), + Some(0), + "lint --fix must fail when the parent dir is read-only; stderr: {stderr}" + ); + + // "Fixed:" must NOT appear — emitting it before the write would be a lie. + assert!( + !stderr.contains("Fixed:"), + "stderr must not contain 'Fixed:' when the write failed; got: {stderr}" + ); +} + +/// Regression gate (directory mode): when `atomic_write_file` fails, +/// stderr must NOT contain "Fixed: " — mirrors the single-file check +/// above for the `lint_one_file_human` code path (lint.rs:1227). +#[cfg(unix)] +#[test] +fn lint_fix_write_failure_does_not_print_fixed_label_directory() { + use std::os::unix::fs::PermissionsExt as _; + + let outer = tempfile::tempdir().unwrap(); + let inner = outer.path().join("files"); + fs::create_dir(&inner).unwrap(); + + let target = inner.join("no_fixed_label_dir.mds"); + fs::write( + &target, + "@define greet(name):\n Hello {name}!\n@end\n\n@export greet\n@export greet\n", + ) + .unwrap(); + + // Make the inner directory read-only so temp-file creation fails on write. + fs::set_permissions(&inner, fs::Permissions::from_mode(0o555)).unwrap(); + + // Run lint --fix on the directory (directory mode routes through lint_one_file_human). + let out = lint_path(&inner, &["--fix"]); + + let _ = fs::set_permissions(&inner, fs::Permissions::from_mode(0o755)); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // The write must have failed (directory mode tallies the error and may still + // exit non-zero, but "Fixed:" must not appear for the failed file). + assert!( + !stderr.contains("Fixed:"), + "stderr must not contain 'Fixed:' when the directory-mode write failed; got: {stderr}" + ); +} + +/// Regression gate: `mds lint ` (directory mode) must not emit raw ESC bytes to +/// stderr when a source file embeds a raw ESC byte (U+001B) in content that reaches +/// `MdsError::Syntax`. +/// +/// Directory mode routes through `lint_one_file_human`, which previously called +/// `eprintln!("{:?}", miette::Report::from(e.clone()))` directly without sanitization. +/// That path is now guarded by `crate::output::eprint_error` (avoids PF-004 parallel-path +/// gap — the sibling that slipped past rounds 1 and 2). +#[test] +fn lint_directory_esc_byte_in_syntax_error_is_sanitized_on_stderr() { + let dir = tempfile::tempdir().unwrap(); + // Raw ESC byte (0x1B) in a .mds file that has a syntax error (unclosed @define). + // The ESC is on the error line so miette renders it inside the source context frame. + fs::write(dir.path().join("esc_dir.mds"), b"@define \x1bfoo:\nhello\n").unwrap(); + + let out = lint_path(dir.path(), &[]); + // Must exit non-zero (syntax error aborts lint analysis). + assert_ne!( + out.status.code(), + Some(0), + "lint dir with syntax error should exit non-zero; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // Raw ESC byte (0x1B) must not appear anywhere in stderr. + assert!( + !out.stderr.contains(&0x1Bu8), + "raw ESC byte (0x1B) must be sanitized before writing to stderr (directory mode); \ + got (hex): {:02x?}", + &out.stderr[..out.stderr.len().min(512)] + ); + // Stdout must also be clean (JSON path not taken in human mode). + assert!( + !out.stdout.contains(&0x1Bu8), + "raw ESC byte (0x1B) must not appear in stdout; \ + got (hex): {:02x?}", + &out.stdout[..out.stdout.len().min(512)] + ); +} diff --git a/crates/mds-cli/tests/cli_source_map.rs b/crates/mds-cli/tests/cli_source_map.rs index 7883fec2..bfe5187c 100644 --- a/crates/mds-cli/tests/cli_source_map.rs +++ b/crates/mds-cli/tests/cli_source_map.rs @@ -105,6 +105,90 @@ fn read_map_json(map_path: &Path) -> serde_json::Value { .unwrap_or_else(|e| panic!("map file is not valid JSON at {}: {e}", map_path.display())) } +/// Assert that a `sources[]` entry is safe to embed in a shipped source map. +/// +/// Verifies that `s` is: +/// 1. Not a Windows `\\?\` verbatim-prefix path. +/// 2. Not drive-qualified (`C:\…`, `C:/…`, or bare `C:`). +/// 3. Not an absolute path (leading `/`). +/// 4. Lexically contained within `root` (resolving against `base` or `root`). +/// 5. Free of every component listed in `forbidden`. +/// +/// Sentinels delimited by `<` and `>` (e.g. ``) pass through — they are +/// display labels, not filesystem paths, and never need containment checking. +fn assert_source_is_contained(s: &str, root: &Path, base: &Path, forbidden: &[&str]) { + // Sentinels are display labels — not paths. + if s.starts_with('<') && s.ends_with('>') { + return; + } + + let unified = s.replace('\\', "/"); + + // No verbatim prefix. + assert!( + !unified.starts_with("//?/"), + "source must not contain Windows verbatim prefix: {s:?}" + ); + + // No drive-qualified forms (ADR-005 / AC-SEC-01). + assert!( + !unified.contains(":\\"), + "source must not contain backslash-qualified Windows drive: {s:?}" + ); + assert!( + !unified.contains(":/"), + "source must not contain forward-slash-qualified Windows drive: {s:?}" + ); + let drive_lead = unified.len() >= 2 + && (unified.as_bytes()[0] as char).is_ascii_alphabetic() + && unified.as_bytes()[1] == b':'; + assert!( + !drive_lead, + "source must not start with a bare drive designator: {s:?}" + ); + + // Not absolute. + assert!( + !unified.starts_with('/'), + "source must not be an absolute path: {s:?}" + ); + + // Containment: accept either root-relative OR base-relative resolution. + // When `base` is outside `root`, core emits root-relative paths; when + // `base` is inside `root`, it emits base-relative paths. Normalizing + // through both anchors lets this helper cover both cases. + let contained = [root, base].iter().any(|anchor| { + let joined = anchor.join(&unified); + let mut comps: Vec<_> = Vec::new(); + for c in joined.components() { + match c { + std::path::Component::ParentDir => { + comps.pop(); + } + std::path::Component::CurDir => {} + other => comps.push(other), + } + } + let norm: std::path::PathBuf = comps.iter().collect(); + norm.starts_with(root) + }); + assert!( + contained, + "source path {s:?} is not contained in root {root:?} \ + (tried root-join and base-join with {base:?})" + ); + + // No forbidden component names. + for comp in unified.split('/') { + for &f in forbidden { + assert_ne!( + comp, f, + "source path {s:?} contains forbidden component {f:?}" + ); + } + } +} + // ── SM-1: --source-map creates a sidecar file (AC-FUNC-01) ─────────────────── #[test] @@ -168,23 +252,22 @@ fn sm3_sources_are_relative_not_absolute() { let map_path = dir.path().join("out.md.map"); let v = read_map_json(&map_path); + // Workspace root: two levels up from crates/mds-cli (CARGO_MANIFEST_DIR). + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = crate_dir + .parent() + .and_then(|p| p.parent()) + .expect("workspace root must be two levels above crate dir"); + // Forbid the output temp-dir name — it must never appear as a path component. + let dir_name = dir + .path() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + for src in v["sources"].as_array().unwrap() { let s = src.as_str().unwrap(); - // Must NOT be an absolute path. - assert!( - !s.starts_with('/'), - "source must not start with '/' (absolute): {s}" - ); - // Must NOT be a Windows verbatim prefix (PF-003 / AC-SEC-01). - assert!( - !s.starts_with(r"\\?\"), - "source must not contain Windows verbatim prefix: {s}" - ); - // Must NOT be a Windows absolute path. - assert!( - !s.contains(":\\"), - "source must not contain Windows drive letter: {s}" - ); + assert_source_is_contained(s, workspace, workspace, &[dir_name]); } } @@ -633,23 +716,20 @@ fn sm_det_sources_never_absolute_across_build_dirs() { let map_a = read_map_json(&dir_a.path().join("out_a.md.map")); let map_b = read_map_json(&dir_b.path().join("out_b.md.map")); - for (label, v) in [("dir_a", &map_a), ("dir_b", &map_b)] { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = crate_dir + .parent() + .and_then(|p| p.parent()) + .expect("workspace root must be two levels above crate dir"); + + for (_label, v, dir_path) in [ + ("dir_a", &map_a, dir_a.path()), + ("dir_b", &map_b, dir_b.path()), + ] { + let dir_name = dir_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); for src in v["sources"].as_array().unwrap() { let s = src.as_str().unwrap(); - assert!( - !s.starts_with('/'), - "[{label}] source must not be absolute: {s}" - ); - assert!( - !s.starts_with(r"\\?\"), - "[{label}] source must not have Windows verbatim prefix: {s}" - ); - // No colons indicating a drive letter (e.g., C:\...). - let after_scheme = s.split_once(':').map(|(_, r)| r).unwrap_or(""); - assert!( - !after_scheme.starts_with('\\'), - "[{label}] source must not be a Windows absolute path: {s}" - ); + assert_source_is_contained(s, workspace, workspace, &[dir_name]); } } @@ -924,3 +1004,337 @@ fn sm16_stale_map_not_smv3_preserved_with_warning() { "--quiet must suppress the warning; got stderr: {quiet_stderr:?}" ); } + +// ── SM-14b: stdin --source-map sidecar relabels source to ──────────── +// +// After the STRING_SOURCE_MAP_LABEL fix, stdin builds emit "input.mds" in +// sources[0]. relativize_source_path Rule 1 must relabel it to "" +// (AC-FUNC-12). Verifies both the relabeling AND that "input.mds" / "" +// never leak into the sidecar map. + +#[test] +fn sm14b_stdin_source_map_sidecar_relabels_source_to_stdin() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("stdin_out.md"); + let map_path = dir.path().join("stdin_out.md.map"); + + let mut child = mds_bin() + .args(["build", "-", "--source-map", "-o", out.to_str().unwrap()]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("mds binary should spawn"); + + use std::io::Write as _; + child + .stdin + .take() + .unwrap() + .write_all(b"---\nname: World\n---\nHello {name}!\n") + .unwrap(); + + let result = child.wait_with_output().unwrap(); + assert!( + result.status.success(), + "stdin --source-map sidecar should succeed" + ); + + let map_json = read_map_json(&map_path); + let sources = map_json["sources"] + .as_array() + .expect("sources must be array"); + + // Must use "" — never "input.mds" or "". + assert!( + sources.iter().any(|s| s.as_str() == Some("")), + "stdin sidecar must label source as \"\"; got: {sources:?}" + ); + assert!( + !sources.iter().any(|s| s.as_str() == Some("input.mds")), + "\"input.mds\" must not leak into stdin sidecar sources[]; got: {sources:?}" + ); + assert!( + !sources.iter().any(|s| s.as_str() == Some("")), + "\"\" must not appear in stdin sidecar sources[]; got: {sources:?}" + ); +} + +// ── SM-16: --inline -o - (stdout) produces relativized map, no absolute paths─ +// +// Before the relativize_source_map_fields fix, the early return on None +// output bypassed relativization entirely, leaking absolute filesystem paths +// into inline data-URI source maps. After the fix, map_dir = PathBuf::new() +// so paths are relativized against CWD unconditionally. + +/// SM-16a: file input + --inline + -o - → stdout carrier has no absolute paths. +#[test] +fn sm16a_file_inline_stdout_no_absolute_paths() { + // Run the binary against a real fixture with --inline -o - + let result = mds_bin() + .args([ + "build", + fixture("sm_basic.mds").to_str().unwrap(), + "--source-map", + "--inline", + "-o", + "-", + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("mds binary should run"); + + assert!( + result.status.success(), + "file --inline -o - must succeed; stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); + + let stdout = String::from_utf8_lossy(&result.stdout); + // Extract and decode the inline data-URI carrier. + let prefix = ""; + let start = stdout + .find(prefix) + .expect("stdout must contain inline source-map carrier"); + let rest = &stdout[start + prefix.len()..]; + let end = rest + .find(suffix) + .expect("carrier must be closed with \" -->\""); + let b64 = &rest[..end]; + let decoded = base64_decode(b64); + let json: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = crate_dir + .parent() + .and_then(|p| p.parent()) + .expect("workspace root must be two levels above crate dir"); + let cwd = std::env::current_dir().unwrap_or_else(|_| workspace.to_path_buf()); + let home = std::env::var("HOME").unwrap_or_default(); + let home_name = std::path::Path::new(&home) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + let sources = json["sources"].as_array().expect("sources must be array"); + for src in sources { + let s = src.as_str().expect("source must be string"); + assert_source_is_contained(s, workspace, &cwd, &[home_name]); + } + + // `file` field must be absent for stdout output (no output filename). + assert!( + json.get("file").map(|v| v.is_null()).unwrap_or(true), + "`file` field must be absent or null for stdout inline map; got: {:?}", + json.get("file") + ); +} + +/// SM-16b: stdin + --inline + -o - → previously rejected, now allowed. +/// +/// The false rejection "--inline cannot be used with -o -" was deleted. +/// The inline carrier embeds in the output stream identically to file input. +#[test] +fn sm16b_stdin_inline_stdout_now_allowed() { + let mut child = mds_bin() + .args(["build", "-", "--source-map", "--inline", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("mds binary should spawn"); + + use std::io::Write as _; + child + .stdin + .take() + .unwrap() + .write_all(b"Hello World!\n") + .unwrap(); + + let result = child.wait_with_output().unwrap(); + assert!( + result.status.success(), + "stdin --inline -o - must succeed after removing the false rejection; \ + stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); + + let stdout = String::from_utf8_lossy(&result.stdout); + // Must contain an inline carrier. + assert!( + stdout.contains(""; + let start = stdout.find(prefix).unwrap(); + let rest = &stdout[start + prefix.len()..]; + let end = rest.find(suffix).unwrap(); + let decoded = base64_decode(&rest[..end]); + let json: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = crate_dir + .parent() + .and_then(|p| p.parent()) + .expect("workspace root must be two levels above crate dir"); + let cwd = std::env::current_dir().unwrap_or_else(|_| workspace.to_path_buf()); + let home = std::env::var("HOME").unwrap_or_default(); + let home_name = std::path::Path::new(&home) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + let sources = json["sources"].as_array().expect("sources must be array"); + // Stdin source must be relabeled to "" by apply_source_map_file_label. + assert!( + sources.iter().any(|s| s.as_str() == Some("")), + "stdin --inline stdout must label source as \"\"; got: {sources:?}" + ); + for src in sources { + let s = src.as_str().expect("source must be string"); + // "" is a sentinel — assert_source_is_contained returns early for it. + assert_source_is_contained(s, workspace, &cwd, &[home_name]); + } +} + +// ── SM-SEC3: `..`-escape guard — deepCWD/output dir must not leak path layout ── + +/// Regression gate for the SEC-3 fix (ADR-005 / AC-SEC-01 / avoids PF-004). +/// +/// Before the fix, `relativize_source_path` only guarded against paths that were +/// still ABSOLUTE after relativization (starts with `/` or Windows drive form). +/// A source file that lives OUTSIDE the map directory produces a `../`-escaping +/// relative path whose `..` chain grows with the nesting depth — reconstructing +/// the full absolute path and leaking USERNAME + directory layout. +/// +/// Covers BOTH output modes to prevent the per-mode divergence PF-004 describes: +/// - sidecar map: `map_dir = effective_parent(out_path)` (absolute, deep) +/// - inline stdout: `map_dir = CWD` (process runs from a deep directory) +/// +/// Assertion: no source entry contains `../` components, and no entry contains +/// the random source-tempdir name that would confirm path reconstruction. +#[test] +fn sm_sec3_dotdot_escape_falls_back_to_basename() { + let src_dir = tempfile::tempdir().unwrap(); + let out_dir = tempfile::tempdir().unwrap(); + + // Record the random dir-name; if it appears in sources[] the path escaped. + let src_dir_name = src_dir + .path() + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + + // Source file is in src_dir — completely separate from the output tree. + let src_path = src_dir.path().join("input.mds"); + std::fs::write(&src_path, "Hello SEC-3!\n").unwrap(); + + // Deep output directory — enough levels that the old relative_path() call + // produces a long `../../../...` chain back to src_dir under the old code. + let deep_out_dir = out_dir.path().join("a/b/c/d/e/f/g/h/i/j"); + std::fs::create_dir_all(&deep_out_dir).unwrap(); + let out_path = deep_out_dir.join("out.md"); + + // ── sidecar case ───────────────────────────────────────────────────────── + // map_dir = effective_parent(out_path) = deep_out_dir (absolute). + // relative_path(deep_out_dir, src_path) escapes via `../` under the old code. + let result = mds_bin() + .arg("build") + .arg(&src_path) + .arg("--source-map") + .arg("-o") + .arg(&out_path) + .output() + .expect("mds build (sidecar) should run"); + assert!( + result.status.success(), + "sidecar build should succeed; stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); + + let map_path = deep_out_dir.join("out.md.map"); + let v = read_map_json(&map_path); + let sidecar_sources = v["sources"].as_array().expect("sources must be array"); + + for src in sidecar_sources { + let s = src.as_str().expect("source must be string"); + // SEC-3: must NOT start with `../` — the `..`-escape guard must have fired. + assert!( + !s.starts_with("../"), + "sidecar sources[] must not contain a `..`-escaping path \ + (SEC-3 guard must fire); got: {s:?}" + ); + assert_ne!(s, "..", "sidecar sources[] must not be bare `..`"); + // Pre-existing guard: must NOT be an absolute path. + assert!( + !s.starts_with('/'), + "sidecar sources[] must not be absolute; got: {s:?}" + ); + // Key assertion: the random src-tempdir name must NOT appear. If it does, + // the `..` chain reconstructed the filesystem path — the exact leak the + // PR description claimed was fixed but was not. + assert!( + !s.contains(src_dir_name.as_str()), + "sidecar sources[] must not contain the source tempdir name \ + (path reconstruction leak); dir={src_dir_name:?} entry={s:?}" + ); + } + + // ── inline stdout case ──────────────────────────────────────────────────── + // map_dir = CWD when -o - is used (PathBuf::new() → abs_map_dir = current_dir). + // Running from deep_out_dir makes CWD == deep_out_dir, so the relative path + // from CWD to src_path escapes via `../` under the old code. + let inline_result = mds_bin() + .current_dir(&deep_out_dir) + .arg("build") + .arg(&src_path) + .args(["--source-map", "--inline", "-o", "-"]) + .output() + .expect("mds build --inline should run"); + assert!( + inline_result.status.success(), + "inline build should succeed; stderr: {}", + String::from_utf8_lossy(&inline_result.stderr) + ); + + let stdout = String::from_utf8_lossy(&inline_result.stdout); + let prefix = ""; + let start = stdout + .find(prefix) + .expect("stdout must contain inline source-map carrier"); + let rest = &stdout[start + prefix.len()..]; + let end = rest + .find(suffix) + .expect("carrier must be closed with \" -->\""); + let decoded = base64_decode(&rest[..end]); + let inline_json: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + let inline_sources = inline_json["sources"] + .as_array() + .expect("inline sources must be array"); + + for src in inline_sources { + let s = src.as_str().expect("source must be string"); + assert!( + !s.starts_with("../"), + "inline sources[] must not contain a `..`-escaping path \ + (SEC-3 guard must fire); got: {s:?}" + ); + assert_ne!(s, "..", "inline sources[] must not be bare `..`"); + assert!( + !s.starts_with('/'), + "inline sources[] must not be absolute; got: {s:?}" + ); + assert!( + !s.contains(src_dir_name.as_str()), + "inline sources[] must not contain the source tempdir name \ + (path reconstruction leak); dir={src_dir_name:?} entry={s:?}" + ); + } +} diff --git a/crates/mds-cli/tests/dir_build.rs b/crates/mds-cli/tests/dir_build.rs index 05c619a3..c656731b 100644 --- a/crates/mds-cli/tests/dir_build.rs +++ b/crates/mds-cli/tests/dir_build.rs @@ -369,7 +369,7 @@ fn dir_check_validates_tree_exits_zero_on_all_ok() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("checked"), + stderr.contains("passed"), "stderr should contain check summary; got: {stderr}" ); } @@ -392,7 +392,7 @@ fn dir_check_continues_on_error_nonzero_exit() { // Summary should show counts. let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("checked") || stderr.contains("failed"), + stderr.contains("passed") || stderr.contains("failed"), "stderr must contain check summary; got: {stderr}" ); } @@ -500,3 +500,291 @@ fn dir_build_empty_dir_exits_zero() { "stderr should mention no .mds files; got: {stderr}" ); } + +// ── Additional test helpers for lint/fmt subcommands ───────────────────────── + +fn lint_dir(dir: &Path, extra_args: &[&str]) -> std::process::Output { + mds_bin() + .arg("lint") + .arg(dir) + .args(extra_args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() +} + +fn fmt_dir(dir: &Path, extra_args: &[&str]) -> std::process::Output { + mds_bin() + .arg("fmt") + .arg(dir) + .args(extra_args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() +} + +// ── T-CLI-ALL-EXCLUDED: all candidates in excluded dirs exits non-zero ──────── +// +// Covers issue #2: when every .mds file is under a default-excluded directory +// (hidden dir or node_modules), the subcommand must: +// (a) exit non-zero, not 0 — distinguishing from a genuinely empty tree +// (b) emit a distinct message carrying the skip count +// (c) emit the message even under --quiet (the CI guard case) + +#[test] +fn dir_build_all_excluded_exits_nonzero() { + let src = tempfile::tempdir().unwrap(); + + // .github/prompts/ is a prime template location for prompt-template compilers. + let hidden = src.path().join(".github"); + fs::create_dir_all(hidden.join("prompts")).unwrap(); + fs::write(hidden.join("prompts").join("system.mds"), "Hello!\n").unwrap(); + + let output = build_dir(src.path(), &[]); + + assert!( + !output.status.success(), + "build with all candidates in excluded dirs must exit non-zero; exit: {:?}; stderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + // Message must be distinct from "No .mds files found" and carry the skip count. + assert!( + stderr.contains("excluded") || stderr.contains("default-excluded"), + "stderr must mention excluded directories; got: {stderr}" + ); + assert!( + stderr.contains('1'), + "stderr must carry the skip count (1 file excluded); got: {stderr}" + ); +} + +#[test] +fn dir_build_all_excluded_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let nm = src.path().join("node_modules"); + fs::create_dir(&nm).unwrap(); + fs::write(nm.join("lib.mds"), "Hello!\n").unwrap(); + fs::write(nm.join("other.mds"), "World!\n").unwrap(); + + // --quiet must NOT suppress the all-excluded diagnostic — this is the exact + // CI invocation where a silent green pass is the danger. + let output = build_dir(src.path(), &["--quiet"]); + + assert!( + !output.status.success(), + "build with all candidates excluded must exit non-zero even under --quiet" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.is_empty(), + "stderr must not be empty under --quiet when all candidates are excluded" + ); + assert!( + stderr.contains("excluded") || stderr.contains("default-excluded"), + "all-excluded diagnostic must appear under --quiet; got: {stderr}" + ); + // Skip count must appear in message. + assert!( + stderr.contains('2'), + "stderr must report 2 skipped files; got: {stderr}" + ); +} + +#[test] +fn dir_build_genuinely_empty_still_exits_zero() { + // Regression guard: a genuinely empty directory (no .mds files anywhere) + // must still exit 0 — only the all-excluded case exits non-zero. + let src = tempfile::tempdir().unwrap(); + + let output = build_dir(src.path(), &[]); + + assert!( + output.status.success(), + "genuinely empty dir must still exit 0; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + // Message must be the original "no files" message, not the excluded diagnostic. + assert!( + stderr.contains("No .mds files") || stderr.contains("no .mds"), + "empty-dir message should mention no .mds files; got: {stderr}" + ); + assert!( + !stderr.contains("excluded"), + "empty-dir must NOT show the excluded diagnostic; got: {stderr}" + ); +} + +#[test] +fn dir_build_mixed_excluded_and_normal_processes_normal() { + // Non-excluded files must still be processed even when some are in excluded dirs. + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + + // Normal file — must be built. + create_plain_mds(src.path(), "normal.mds"); + + // Excluded file — must be skipped. + let hidden = src.path().join(".prompts"); + fs::create_dir(&hidden).unwrap(); + fs::write(hidden.join("excluded.mds"), "Hello!\n").unwrap(); + + let output = build_dir(src.path(), &["--out-dir", out.path().to_str().unwrap()]); + + // The build must succeed since normal.mds was found and processed. + assert!( + output.status.success(), + "build with mixed excluded+normal files must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // normal.mds → out/normal.md + assert!( + out.path().join("normal.md").exists(), + "normal.md must be built" + ); + + // .prompts/excluded.md must NOT exist (excluded subdir was skipped). + assert!( + !out.path().join(".prompts").join("excluded.md").exists(), + "excluded.md must not be built" + ); +} + +#[test] +fn dir_check_all_excluded_exits_nonzero() { + let src = tempfile::tempdir().unwrap(); + + let hidden = src.path().join(".claude"); + fs::create_dir(&hidden).unwrap(); + fs::write(hidden.join("prompt.mds"), "Hello!\n").unwrap(); + + let output = check_dir(src.path(), &[]); + + assert!( + !output.status.success(), + "check with all candidates in excluded dirs must exit non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("excluded") || stderr.contains("default-excluded"), + "stderr must mention excluded directories; got: {stderr}" + ); + assert!( + stderr.contains('1'), + "stderr must carry the skip count; got: {stderr}" + ); +} + +#[test] +fn dir_check_all_excluded_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let hidden = src.path().join(".cursor"); + fs::create_dir_all(hidden.join("rules")).unwrap(); + fs::write(hidden.join("rules").join("rule.mds"), "Hello!\n").unwrap(); + + let output = check_dir(src.path(), &["--quiet"]); + + assert!( + !output.status.success(), + "check with all candidates excluded must exit non-zero even under --quiet" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.is_empty() && (stderr.contains("excluded") || stderr.contains("default-excluded")), + "all-excluded diagnostic must appear under --quiet; got: {stderr:?}" + ); +} + +#[test] +fn dir_lint_all_excluded_exits_nonzero() { + let src = tempfile::tempdir().unwrap(); + + let nm = src.path().join("node_modules"); + fs::create_dir(&nm).unwrap(); + fs::write(nm.join("template.mds"), "Hello!\n").unwrap(); + + let output = lint_dir(src.path(), &[]); + + assert!( + !output.status.success(), + "lint with all candidates in excluded dirs must exit non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("excluded") || stderr.contains("default-excluded"), + "stderr must mention excluded directories; got: {stderr}" + ); +} + +#[test] +fn dir_lint_all_excluded_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let hidden = src.path().join(".github"); + fs::create_dir(&hidden).unwrap(); + fs::write(hidden.join("workflow.mds"), "Hello!\n").unwrap(); + + let output = lint_dir(src.path(), &["--quiet"]); + + assert!( + !output.status.success(), + "lint with all candidates excluded must exit non-zero even under --quiet" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.is_empty() && (stderr.contains("excluded") || stderr.contains("default-excluded")), + "all-excluded diagnostic must appear under --quiet; got: {stderr:?}" + ); +} + +#[test] +fn dir_fmt_all_excluded_exits_nonzero() { + let src = tempfile::tempdir().unwrap(); + + let hidden = src.path().join(".prompts"); + fs::create_dir(&hidden).unwrap(); + fs::write(hidden.join("system.mds"), "Hello!\n").unwrap(); + + let output = fmt_dir(src.path(), &[]); + + assert!( + !output.status.success(), + "fmt with all candidates in excluded dirs must exit non-zero; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("excluded") || stderr.contains("default-excluded"), + "stderr must mention excluded directories; got: {stderr}" + ); +} + +#[test] +fn dir_fmt_all_excluded_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let nm = src.path().join("node_modules"); + fs::create_dir(&nm).unwrap(); + fs::write(nm.join("component.mds"), "Hello!\n").unwrap(); + + let output = fmt_dir(src.path(), &["--quiet"]); + + assert!( + !output.status.success(), + "fmt with all candidates excluded must exit non-zero even under --quiet" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.is_empty() && (stderr.contains("excluded") || stderr.contains("default-excluded")), + "all-excluded diagnostic must appear under --quiet; got: {stderr:?}" + ); +} diff --git a/crates/mds-cli/tests/errors.rs b/crates/mds-cli/tests/errors.rs index 929044fd..616e2add 100644 --- a/crates/mds-cli/tests/errors.rs +++ b/crates/mds-cli/tests/errors.rs @@ -467,3 +467,35 @@ fn if_negation_undefined_variable_is_error() { "error must mention the undefined variable, got: {err}" ); } + +#[test] +fn type_mismatch_cli_shows_miette_code_frame() { + // D2: a type mismatch in @if now carries a span, so the CLI renders a miette + // code frame with ╭─[file:line:col] (or the equivalent inline code block). + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("mismatch.mds"); + // frontmatter: x is a YAML integer (number); condition compares to a string. + std::fs::write( + &input, + "---\nx: 3\n---\n@if x == \"3\":\nyes\n@else:\nno\n@end\n", + ) + .unwrap(); + + let output = mds_bin() + .args(["build", input.to_str().unwrap()]) + .output() + .unwrap(); + + assert!(!output.status.success(), "D2: type mismatch must fail"); + let stderr = String::from_utf8(output.stderr).unwrap(); + // miette renders a code frame with ╭─[file:line:col] when a span is present. + assert!( + stderr.contains("type mismatch") || stderr.contains("mds::type_mismatch"), + "D2: stderr must mention type_mismatch; got: {stderr}" + ); + // A span is present → miette shows the file path in the frame. + assert!( + stderr.contains("mismatch.mds") || stderr.contains(":4:") || stderr.contains("4 │"), + "D2: miette code frame must reference file or line 4; got: {stderr}" + ); +} diff --git a/crates/mds-cli/tests/fixtures/lint_overlap.mds b/crates/mds-cli/tests/fixtures/lint_overlap.mds new file mode 100644 index 00000000..574b6c24 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_overlap.mds @@ -0,0 +1,6 @@ +@define test(): +@if "a" == "a": + always true +@elseif "a" == "a": +@end +@end diff --git a/crates/mds-cli/tests/fixtures/lint_partial_fix.mds b/crates/mds-cli/tests/fixtures/lint_partial_fix.mds new file mode 100644 index 00000000..dc2c4209 --- /dev/null +++ b/crates/mds-cli/tests/fixtures/lint_partial_fix.mds @@ -0,0 +1,12 @@ +@define empty_fn(): + +@end + +@export empty_fn + +@define greet(name): + Hello {name}! +@end + +@export greet +@export greet diff --git a/crates/mds-core/src/ast.rs b/crates/mds-core/src/ast.rs index a6891509..cdad4393 100644 --- a/crates/mds-core/src/ast.rs +++ b/crates/mds-core/src/ast.rs @@ -187,15 +187,40 @@ pub enum Arg { }, } +/// A single `@elseif` branch in an `@if` block. +/// +/// Replaces the prior `(Condition, Vec)` tuple to carry a per-branch +/// byte offset, closing the span-accuracy gap tracked in #181: lint rules can +/// now anchor diagnostics at the exact `@elseif` line rather than the +/// enclosing `@if` offset. +/// +/// # Structural equality +/// +/// `structural_eq.rs` compares `ElseifBranch` by `condition` and `body` only +/// — `offset` is intentionally excluded (it is a span annotation, not part of +/// the template's logical identity). +#[derive(Debug, Clone)] +pub struct ElseifBranch { + pub condition: Condition, + pub body: Vec, + /// Byte offset of the `@elseif` token in the source (for diagnostic spans). + pub offset: usize, +} + #[derive(Debug, Clone)] pub struct IfBlock { /// The primary condition (`@if :`). pub condition: Condition, pub then_body: Vec, /// Zero or more `@elseif` branches, evaluated in order (short-circuit). - pub elseif_branches: Vec<(Condition, Vec)>, + pub elseif_branches: Vec, pub else_body: Option>, pub offset: usize, + /// Byte offset of the `@else:` token in the source, when present. + /// + /// `None` when there is no `@else` clause. Used by lint rules to anchor + /// diagnostics at the exact `@else` line rather than the enclosing `@if`. + pub else_offset: Option, } #[derive(Debug, Clone)] diff --git a/crates/mds-core/src/error.rs b/crates/mds-core/src/error.rs index 45c752f8..ac5a5066 100644 --- a/crates/mds-core/src/error.rs +++ b/crates/mds-core/src/error.rs @@ -134,7 +134,7 @@ pub enum MdsError { #[diagnostic(code(mds::syntax))] Syntax { message: String, - #[label("{message}")] + #[label("syntax error occurred here")] span: Option, #[source_code] src: Option>>, @@ -167,7 +167,10 @@ pub enum MdsError { }, #[error("arity mismatch for '{name}': {}, got {got}", format_arity(*expected_min, *expected_max))] - #[diagnostic(code(mds::arity))] + #[diagnostic( + code(mds::arity), + help("check the call site — '{name}' requires a different number of arguments than were provided") + )] ArityMismatch { name: String, expected_min: usize, @@ -528,6 +531,23 @@ impl MdsError { } } + pub(crate) fn type_mismatch_at( + lhs_type: impl Into, + rhs_type: impl Into, + file: &str, + source: &str, + offset: usize, + len: usize, + ) -> Self { + let (span, src) = at(file, source, offset, len); + MdsError::TypeMismatch { + lhs_type: lhs_type.into(), + rhs_type: rhs_type.into(), + span, + src, + } + } + pub(crate) fn name_collision(name: impl Into) -> Self { MdsError::NameCollision { name: name.into(), diff --git a/crates/mds-core/src/evaluator.rs b/crates/mds-core/src/evaluator.rs index dd0e075a..803752d4 100644 --- a/crates/mds-core/src/evaluator.rs +++ b/crates/mds-core/src/evaluator.rs @@ -67,15 +67,34 @@ pub(crate) struct EvalContext<'a> { /// /// Irrelevant (always `false`) when `map` is `None` — zero-cost path. fn_body_owned: bool, + /// Display path of the current module, used for `type_mismatch_at` spans. + /// + /// Matches the `file_str` field in `ModuleCtx` (resolver.rs). Set to `""` + /// on the simple `evaluate()` path where no file context is available — + /// the `build_type_mismatch` helper degrades to spanless when this is empty + /// (ADR-005 / DECISIONS_CONTEXT: degrade rather than mis-attribute). + pub(crate) file: &'a str, + /// Raw source of the current module, used for span byte-offset computation. + /// + /// Set to `""` when no source context is threaded in (e.g. unit-test paths + /// that call `evaluate()` directly). The `build_type_mismatch` helper checks + /// `source.is_empty()` before attempting span construction. + pub(crate) source: &'a str, } /// Evaluate a module body into a final rendered string. /// /// Warnings (e.g. empty `@include`) are appended to `warnings`. +/// `file` and `source` are the display path and raw source of the module being +/// evaluated; they are threaded into `EvalContext` so that diagnostics raised +/// during condition evaluation (e.g. `mds::type_mismatch`) can carry source spans. +/// Pass `""` for both when no source context is available (e.g. in unit tests). pub fn evaluate( nodes: &[Node], scope: &mut Scope, warnings: &mut Vec, + file: &str, + source: &str, ) -> Result { let mut ctx = EvalContext { call_stack: Vec::new(), @@ -85,6 +104,8 @@ pub fn evaluate( map: None, fragment_remap_cache: std::collections::HashMap::new(), fn_body_owned: false, + file, + source, }; evaluate_nodes(nodes, scope, &mut ctx) } @@ -96,6 +117,10 @@ pub fn evaluate( /// finalization stage. The builder's `cursor` is guaranteed to equal /// `output.len() as u32` when this function returns. /// +/// `file` and `source` for `EvalContext` diagnostic spans are derived from +/// `builder.current_src` — single source of truth, no redundant parameter +/// (avoids the dual-channel mis-attribution class fixed in c5a4d65; issue #58). +/// /// Delegates to [`evaluate_with_map_seeded`] with seed counters of 0. /// Use [`evaluate_with_map_seeded`] directly when you need to carry cumulative /// resource budgets across multiple invocations (e.g. `@extends` regions). @@ -116,6 +141,12 @@ pub(crate) fn evaluate_with_map( /// `@extends` regions). Returns `(output, builder, total_iterations, total_msg_bytes)` /// so the caller can thread the running totals into the next invocation. /// +/// `file` and `source` for `EvalContext` diagnostic spans are derived from +/// `builder.current_src` — the single source of truth. Callers must update +/// `builder.current_src` before each call (as `evaluate_regions_with_map` does) +/// so that the derived values reflect the correct region origin (issue #58; +/// avoids the dual-channel mis-attribution class fixed in c5a4d65). +/// /// Used by `evaluate_regions_with_map` (resolver.rs) to enforce a single cumulative /// iteration cap across all spliced `@extends` regions (REL-1, applies PF-004). /// @@ -129,6 +160,20 @@ pub(crate) fn evaluate_with_map_seeded( seed_iterations: usize, seed_msg_bytes: usize, ) -> Result<(String, crate::sourcemap::MapBuilder, usize, usize), MdsError> { + // Clone file/source from the builder's current source entry before moving + // builder into EvalContext.map. This is the single source of truth for + // diagnostic span attribution — eliminates the redundant explicit params + // that caused mis-attributed spans before c5a4d65 (issue #58). + let file_owned = builder + .sources + .get(builder.current_src as usize) + .cloned() + .unwrap_or_default(); + let source_owned = builder + .sources_content + .get(builder.current_src as usize) + .cloned() + .unwrap_or_default(); let mut ctx = EvalContext { call_stack: Vec::new(), total_iterations: seed_iterations, @@ -137,6 +182,8 @@ pub(crate) fn evaluate_with_map_seeded( map: Some(builder), fragment_remap_cache: std::collections::HashMap::new(), fn_body_owned: false, + file: &file_owned, + source: &source_owned, }; let output = evaluate_nodes(nodes, scope, &mut ctx)?; let map = ctx.map.take().ok_or_else(|| { @@ -775,6 +822,36 @@ fn values_equal_same_type(lhs: &Value, rhs: &Value) -> Option { } } +/// Build a `TypeMismatch` error, with or without a source span. +/// +/// If `anchor` is `Some(offset)` and the offset falls within `ctx.source`, +/// the error carries a span anchored to the entire enclosing `@if`/`@elseif` +/// directive line (from `offset` to the first `\n`). If the offset is out +/// of bounds or the source is empty (e.g. a cross-source `@extends` splice +/// where the condition's AST offset is relative to the base template, not the +/// current module), the function degrades to a spanless error — never +/// mis-attributes a span from a different source (DECISIONS_CONTEXT ADR-005 / +/// general rule: degrade rather than misattribute). +fn build_type_mismatch( + ctx: &EvalContext<'_>, + anchor: Option, + lhs_type: &str, + rhs_type: &str, +) -> MdsError { + if let Some(off) = anchor { + if !ctx.source.is_empty() && off <= ctx.source.len() && ctx.source.is_char_boundary(off) { + // Span covers the full directive line (from `off` to just before `\n`). + let line_len = ctx.source[off..] + .find('\n') + .unwrap_or(ctx.source[off..].len()); + return MdsError::type_mismatch_at( + lhs_type, rhs_type, ctx.file, ctx.source, off, line_len, + ); + } + } + MdsError::type_mismatch(lhs_type, rhs_type) +} + /// Evaluate a condition to a boolean, resolving expressions from scope. /// /// `scope` is `&mut` because conditions can now contain arbitrary expressions @@ -783,10 +860,17 @@ fn values_equal_same_type(lhs: &Value, rhs: &Value) -> Option { /// evaluation (`&&`, `||`) limits which operands are evaluated — the /// right-hand side of `&&` is skipped on a false left-hand side, and /// vice-versa for `||`. +/// +/// `anchor` is the byte offset of the enclosing `@if`/`@elseif` directive in +/// `ctx.source`. It is passed through `And`/`Or` recursion unchanged so that +/// nested comparisons always report the enclosing directive line, not a nested +/// sub-expression location. Pass `None` when no anchor is available (callers +/// that do not thread file/source context into `ctx`). fn evaluate_condition( condition: &Condition, scope: &mut Scope, ctx: &mut EvalContext, + anchor: Option, ) -> Result { match condition { Condition::Truthy(expr) => Ok(evaluate_expr(expr, scope, ctx)?.is_truthy()), @@ -794,15 +878,18 @@ fn evaluate_condition( Condition::Eq(lhs, rhs) => { let lhs_val = evaluate_expr(lhs, scope, ctx)?; let rhs_val = evaluate_expr(rhs, scope, ctx)?; - values_equal_same_type(&lhs_val, &rhs_val) - .ok_or_else(|| MdsError::type_mismatch(lhs_val.type_name(), rhs_val.type_name())) + values_equal_same_type(&lhs_val, &rhs_val).ok_or_else(|| { + build_type_mismatch(ctx, anchor, lhs_val.type_name(), rhs_val.type_name()) + }) } Condition::NotEq(lhs, rhs) => { let lhs_val = evaluate_expr(lhs, scope, ctx)?; let rhs_val = evaluate_expr(rhs, scope, ctx)?; values_equal_same_type(&lhs_val, &rhs_val) .map(|eq| !eq) - .ok_or_else(|| MdsError::type_mismatch(lhs_val.type_name(), rhs_val.type_name())) + .ok_or_else(|| { + build_type_mismatch(ctx, anchor, lhs_val.type_name(), rhs_val.type_name()) + }) } // Short-circuit And: return false on first false operand. // Parser invariant: And operands are always leaf conditions (parse_and_level calls @@ -817,7 +904,7 @@ fn evaluate_condition( !matches!(operand, Condition::And(_) | Condition::Or(_)), "And operand should be a leaf condition, not And/Or" ); - if !evaluate_condition(operand, scope, ctx)? { + if !evaluate_condition(operand, scope, ctx, anchor)? { return Ok(false); } } @@ -834,7 +921,7 @@ fn evaluate_condition( !matches!(operand, Condition::Or(_)), "Or operand should not be Or (parser flattens same-level operators)" ); - if evaluate_condition(operand, scope, ctx)? { + if evaluate_condition(operand, scope, ctx, anchor)? { return Ok(true); } } @@ -848,12 +935,14 @@ fn evaluate_if( scope: &mut Scope, ctx: &mut EvalContext, ) -> Result { - // Evaluate the primary condition - if evaluate_condition(&block.condition, scope, ctx)? { + // Evaluate the primary condition; anchor span on the @if directive line. + if evaluate_condition(&block.condition, scope, ctx, Some(block.offset))? { return evaluate_nodes(&block.then_body, scope, ctx); } - // Evaluate @elseif branches in order (short-circuit: first true branch wins) + // Evaluate @elseif branches in order (short-circuit: first true branch wins). + // Each branch's anchor is its own offset so type_mismatch points to the + // @elseif line that triggered the error, not the @if line. // Parser enforces MAX_ELSEIF_BRANCHES at construction time; assert the invariant // holds so evaluator correctness cannot silently depend on the parser limit alone. debug_assert!( @@ -862,9 +951,9 @@ fn evaluate_if( block.elseif_branches.len(), MAX_ELSEIF_BRANCHES, ); - for (cond, body) in &block.elseif_branches { - if evaluate_condition(cond, scope, ctx)? { - return evaluate_nodes(body, scope, ctx); + for branch in &block.elseif_branches { + if evaluate_condition(&branch.condition, scope, ctx, Some(branch.offset))? { + return evaluate_nodes(&branch.body, scope, ctx); } } @@ -1097,6 +1186,8 @@ pub fn evaluate_messages_intrinsic( map: None, fragment_remap_cache: std::collections::HashMap::new(), fn_body_owned: false, + file, + source, }; let mut messages = Vec::new(); collect_messages_strict(nodes, scope, &mut ctx, &mut messages, file, source)?; @@ -1249,7 +1340,7 @@ fn collect_messages_from_if( file: &str, source: &str, ) -> Result<(), MdsError> { - if evaluate_condition(&block.condition, scope, ctx)? { + if evaluate_condition(&block.condition, scope, ctx, Some(block.offset))? { return collect_messages_strict(&block.then_body, scope, ctx, out, file, source); } // Parser enforces MAX_ELSEIF_BRANCHES at construction time; assert the invariant @@ -1261,9 +1352,9 @@ fn collect_messages_from_if( block.elseif_branches.len(), MAX_ELSEIF_BRANCHES, ); - for (cond, body) in &block.elseif_branches { - if evaluate_condition(cond, scope, ctx)? { - return collect_messages_strict(body, scope, ctx, out, file, source); + for branch in &block.elseif_branches { + if evaluate_condition(&branch.condition, scope, ctx, Some(branch.offset))? { + return collect_messages_strict(&branch.body, scope, ctx, out, file, source); } } if let Some(else_body) = &block.else_body { @@ -1304,7 +1395,7 @@ mod tests { let mut scope = Scope::new(); let mut warnings = vec![]; assert_eq!( - evaluate(&nodes, &mut scope, &mut warnings).unwrap(), + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), "Hello world!" ); } @@ -1324,7 +1415,7 @@ mod tests { let mut warnings = vec![]; scope.set_var("name", Value::String("Alice".to_string())); assert_eq!( - evaluate(&nodes, &mut scope, &mut warnings).unwrap(), + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), "Hello Alice!" ); } @@ -1338,7 +1429,7 @@ mod tests { })]; let mut scope = Scope::new(); let mut warnings = vec![]; - let err = evaluate(&nodes, &mut scope, &mut warnings).unwrap_err(); + let err = evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap_err(); let msg = format!("{err}"); assert!( msg.contains("unknown"), @@ -1354,11 +1445,15 @@ mod tests { then_body: vec![text("yes")], else_body: Some(vec![text("no")]), offset: 0, + else_offset: None, })]; let mut scope = Scope::new(); let mut warnings = vec![]; scope.set_var("flag", Value::Boolean(true)); - assert_eq!(evaluate(&nodes, &mut scope, &mut warnings).unwrap(), "yes"); + assert_eq!( + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), + "yes" + ); } #[test] @@ -1369,11 +1464,15 @@ mod tests { then_body: vec![text("yes")], else_body: Some(vec![text("no")]), offset: 0, + else_offset: None, })]; let mut scope = Scope::new(); let mut warnings = vec![]; scope.set_var("flag", Value::Boolean(false)); - assert_eq!(evaluate(&nodes, &mut scope, &mut warnings).unwrap(), "no"); + assert_eq!( + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), + "no" + ); } #[test] @@ -1403,7 +1502,7 @@ mod tests { ]), ); assert_eq!( - evaluate(&nodes, &mut scope, &mut warnings).unwrap(), + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), "- apple\n- banana\n" ); } @@ -1439,7 +1538,7 @@ mod tests { })]; let mut warnings = vec![]; assert_eq!( - evaluate(&nodes, &mut scope, &mut warnings).unwrap(), + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), "Hello Bob!" ); } @@ -1454,7 +1553,7 @@ mod tests { let mut scope = Scope::new(); let mut warnings = vec![]; assert_eq!( - evaluate(&nodes, &mut scope, &mut warnings).unwrap(), + evaluate(&nodes, &mut scope, &mut warnings, "", "").unwrap(), "Use {name} for interpolation" ); } @@ -1552,7 +1651,7 @@ mod tests { len: 4, })]; let mut warnings = vec![]; - let result = evaluate(&call_node, &mut scope, &mut warnings); + let result = evaluate(&call_node, &mut scope, &mut warnings, "", ""); assert!(result.is_err(), "call chain of {n} must be rejected"); let err = format!("{}", result.unwrap_err()); assert!( @@ -1718,7 +1817,7 @@ mod tests { let nodes = vec![text(&chunk), text(&chunk)]; let mut scope = Scope::new(); let mut warnings = vec![]; - let result = evaluate(&nodes, &mut scope, &mut warnings); + let result = evaluate(&nodes, &mut scope, &mut warnings, "", ""); assert!( result.is_err(), "output exceeding MAX_OUTPUT_SIZE must be rejected" @@ -2024,7 +2123,7 @@ mod tests { len: 12, })]; let mut warnings = vec![]; - let err = evaluate(&nodes, &mut scope, &mut warnings) + let err = evaluate(&nodes, &mut scope, &mut warnings, "", "") .expect_err("should fail with arity mismatch"); // Assert the span is None on the evaluator path (no source text available). @@ -2060,7 +2159,7 @@ mod tests { })]; let mut scope = Scope::new(); let mut warnings = vec![]; - let err = evaluate(&nodes, &mut scope, &mut warnings) + let err = evaluate(&nodes, &mut scope, &mut warnings, "", "") .expect_err("upper() with 2 args should fail"); match err { diff --git a/crates/mds-core/src/formatter.rs b/crates/mds-core/src/formatter.rs index 7f5c71c3..cf9406c5 100644 --- a/crates/mds-core/src/formatter.rs +++ b/crates/mds-core/src/formatter.rs @@ -74,6 +74,7 @@ use std::collections::BTreeSet; use std::ops::Range; use std::path::Path; +use std::sync::Arc; use crate::error::MdsError; use crate::lexer::{self, Token}; @@ -115,13 +116,34 @@ pub fn format_str(source: &str) -> Result { /// See [`format_str`]. #[must_use = "the formatted source should be used"] pub fn format_str_with(source: &str, base_dir: Option<&Path>) -> Result { - let tokens = lexer::tokenize(source, "")?; + format_str_named(source, base_dir, "") +} + +/// Format MDS source code with an explicit base directory and file name. +/// +/// Identical to [`format_str_with`] but threads `file_name` into lexer errors +/// and into any [`MdsError::Syntax`] surfaced by the safety gate, so callers +/// get a diagnostic that names the file rather than showing a blank path. +/// +/// `file_name` is used only for error reporting — it does not affect how +/// `@import` paths are resolved (that is controlled by `base_dir`). +/// +/// # Errors +/// +/// See [`format_str`]. +#[must_use = "the formatted source should be used"] +pub fn format_str_named( + source: &str, + base_dir: Option<&Path>, + file_name: &str, +) -> Result { + let tokens = lexer::tokenize(source, file_name)?; let raw_content = raw_content_spans(&tokens, source); let directives = directive_line_offsets(&tokens); let body_start = body_start_offset(&tokens, source); let formatted = rewrite(source, body_start, &raw_content, &directives); - assert_equivalent(source, &formatted, base_dir, &raw_content)?; + assert_equivalent(source, &formatted, base_dir, &raw_content, file_name)?; Ok(formatted) } @@ -426,6 +448,7 @@ fn assert_equivalent( formatted: &str, base_dir: Option<&Path>, raw_content: &[Range], + file_name: &str, ) -> Result<(), MdsError> { match crate::compile_str_collecting_warnings(source, base_dir, None) { Ok(orig) => match crate::compile_str_collecting_warnings(formatted, base_dir, None) { @@ -441,7 +464,17 @@ fn assert_equivalent( // (e.g. an unclosed `@message`/`@if`/`@for` block, which tokenizes but // fails at parse time). There is nothing safe to format: surface the // real error rather than papering over it with the structural check. - Err(e @ MdsError::Syntax { .. }) => Err(e), + // + // Rebuild `src` so the diagnostic names `file_name` rather than the + // blank label used internally by `compile_str_collecting_warnings`. + Err(MdsError::Syntax { message, span, .. }) => Err(MdsError::Syntax { + message, + span, + src: Some(Arc::new(miette::NamedSource::new( + file_name, + source.to_string(), + ))), + }), // Any other compile failure (undefined var/fn, unresolved import, …) // means the token stream is well-formed and only later analysis failed, // so the rule-aware structural comparison is a meaningful fallback. @@ -470,6 +503,39 @@ fn in_raw_content(raw_content: &[Range], offset: usize) -> bool { idx < raw_content.len() && raw_content[idx].start <= offset } +/// Pop trailing `Text` tokens from `tokens` that contribute nothing to compiled output. +/// +/// A tail `Text` token is insignificant when **all** three conditions hold: +/// 1. Its source byte offset is **outside** every span in `raw_content` — inside a +/// `@message`/`@define` body, a trailing blank line is real content that bypasses +/// `clean_output` and reaches the compiled JSON verbatim, so it must not be discarded. +/// 2. `crate::clean_output(text).is_empty()` — the token contributes nothing to compiled +/// output (it is whitespace-only: `\n`, `\r\n`, space-only lines, etc.). +/// 3. The token is at the **tail** of the stream. Interior tokens are never touched, +/// so a real formatter bug (dropped interior blank line, dropped meaningful text) +/// still trips the token-count or content-mismatch checks. +/// +/// This helper is called on **both** the source and formatted token streams before the +/// length comparison in `structural_equivalent`, which prevents R2's `trim_end()` +/// deletion of a trailing blank-line `Text` token from producing a spurious token-count +/// mismatch and a false `FormatterInvariant` error (RELEASE BLOCKER 3). +fn strip_trailing_insignificant_text(tokens: &mut Vec, raw_content: &[Range]) { + loop { + let should_pop = match tokens.last() { + Some(Token::Text(text, offset)) => { + // Condition 1: offset must be outside every raw-content span. + // Condition 2: clean_output of the text must be empty. + !in_raw_content(raw_content, *offset) && crate::clean_output(text).is_empty() + } + _ => false, // Not a Text token — stop immediately. + }; + if !should_pop { + break; + } + tokens.pop(); + } +} + /// Rule-aware structural comparison used when neither `source` nor /// `formatted` can be compiled standalone (e.g. an undefined runtime /// variable). Re-tokenizes both and compares token-for-token: `Directive` @@ -489,13 +555,31 @@ fn in_raw_content(raw_content: &[Range], offset: usize) -> bool { /// The raw-content span lookup uses [`in_raw_content`] (binary search, O(log S)) /// rather than a linear scan, since spans are sorted by [`raw_content_spans`] /// and token offsets from the source tokenization are monotonically increasing. +/// +/// Before the length check, [`strip_trailing_insignificant_text`] removes any +/// tail `Text` tokens that are (a) outside raw-content spans and (b) contribute +/// nothing to compiled output (whitespace-only, `clean_output`-empty). This +/// prevents R2's `trim_end()` from producing a spurious count mismatch when a +/// trailing blank line is deleted from the formatted output but still exists as +/// a `Text` token in the source stream (RELEASE BLOCKER 3). fn structural_equivalent(source: &str, formatted: &str, raw_content: &[Range]) -> bool { - let (Ok(src_tokens), Ok(fmt_tokens)) = + let (Ok(mut src_tokens), Ok(mut fmt_tokens)) = (lexer::tokenize(source, ""), lexer::tokenize(formatted, "")) else { return false; }; + // Recompute raw-content spans for the formatted stream: `raw_content` uses + // SOURCE byte offsets, which are not valid when searching against the offsets + // carried by tokens from the FORMATTED string. + let fmt_raw_content = raw_content_spans(&fmt_tokens, formatted); + + // Drop trailing Text tokens that are outside raw-content spans and contribute + // nothing to compiled output (R2 may have deleted such a token from the + // formatted stream while it still exists in the source stream). + strip_trailing_insignificant_text(&mut src_tokens, raw_content); + strip_trailing_insignificant_text(&mut fmt_tokens, &fmt_raw_content); + if src_tokens.len() != fmt_tokens.len() { return false; } @@ -682,6 +766,139 @@ mod tests { // ── structural_equivalent ───────────────────────────────────────────────── + // ── strip_trailing_insignificant_text ───────────────────────────────────── + + #[test] + fn strip_trailing_insignificant_text_removes_trailing_blank_text() { + // A tail Text token whose clean_output is empty must be popped. + let mut tokens = vec![ + Token::Directive("@if x:".to_string(), 0), + Token::Text("\n".to_string(), 7), // meaningful interior line + Token::Directive("@end".to_string(), 9), + Token::Text("\n".to_string(), 14), // trailing blank — MUST be stripped + ]; + let raw: Vec> = vec![]; + strip_trailing_insignificant_text(&mut tokens, &raw); + assert_eq!( + tokens.len(), + 3, + "trailing blank Text must be removed, remaining: {tokens:?}" + ); + assert!( + matches!(tokens.last(), Some(Token::Directive(..))), + "last token must be the @end directive after stripping" + ); + } + + #[test] + fn strip_trailing_insignificant_text_keeps_meaningful_trailing_text() { + // A tail Text token with non-empty clean_output must NOT be popped. + let mut tokens = vec![ + Token::Directive("@if x:".to_string(), 0), + Token::Text("Hello\n".to_string(), 7), // meaningful — clean_output = "Hello\n" + ]; + let raw: Vec> = vec![]; + strip_trailing_insignificant_text(&mut tokens, &raw); + assert_eq!(tokens.len(), 2, "meaningful tail Text must NOT be removed"); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] // intentional: Vec> with one element + fn strip_trailing_insignificant_text_keeps_text_inside_raw_content() { + // A tail Text inside a raw-content span must NOT be popped, even if + // clean_output-empty, because @message bodies bypass clean_output entirely. + let mut tokens = vec![ + Token::Directive("@message user:".to_string(), 0), + Token::Text("\n".to_string(), 15), // trailing blank INSIDE message body + Token::Directive("@end".to_string(), 16), + ]; + // raw_content span covers offset 15 (the trailing \n inside the message body). + let raw = [0_usize..16_usize]; + strip_trailing_insignificant_text(&mut tokens, &raw); + // The @end directive is not a Text token, so the loop breaks immediately. + // The Text("\n", 15) is NOT at the tail (Directive is); nothing is stripped. + assert_eq!( + tokens.len(), + 3, + "nothing should be stripped when tail is Directive" + ); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] // intentional: array of one Range, not Vec + fn strip_trailing_insignificant_text_inside_raw_content_not_stripped() { + // Directly test: a tail Text inside a raw span must survive even if clean_output is empty. + let mut tokens = vec![ + Token::Directive("@message user:".to_string(), 0), + Token::Text("\n".to_string(), 15), // offset 15 is inside raw span 0..20 + ]; + let raw = [0_usize..20_usize]; // covers offset 15 + strip_trailing_insignificant_text(&mut tokens, &raw); + assert_eq!( + tokens.len(), + 2, + "tail Text inside raw-content span must NOT be stripped" + ); + } + + #[test] + fn strip_trailing_insignificant_text_crlf_is_insignificant() { + // Text("\r\n") — clean_output strips \r then trim_end → "" → insignificant. + let mut tokens = vec![ + Token::Directive("@end".to_string(), 0), + Token::Text("\r\n".to_string(), 5), + ]; + let raw: Vec> = vec![]; + strip_trailing_insignificant_text(&mut tokens, &raw); + assert_eq!( + tokens.len(), + 1, + "CRLF-only trailing Text must be treated as insignificant" + ); + } + + // ── structural_equivalent now ignores trailing insignificant Text ───────── + + #[test] + fn structural_equivalent_no_false_positive_on_trailing_blank_after_directive() { + // The exact RELEASE BLOCKER 3 repro: + // source has a trailing Text("\n") that R2 deletes in formatted. + // Before the fix, the token count mismatch caused a false FormatterInvariant. + let source = "@if undefined_var:\nx\n@end\n\n"; + let formatted = "@if undefined_var:\nx\n@end\n"; + let raw: Vec> = vec![]; + assert!( + structural_equivalent(source, formatted, &raw), + "trailing blank Text must not cause a false negative in structural_equivalent" + ); + } + + #[test] + fn structural_equivalent_still_rejects_dropped_meaningful_trailing_text() { + // A real formatter bug: meaningful trailing text was lost. + // structural_equivalent must still return false in this case. + let source = "@if undefined_var:\nx\n@end\nSome trailing text\n"; + let formatted = "@if undefined_var:\nx\n@end\n"; // trailing text was dropped! + let raw: Vec> = vec![]; + assert!( + !structural_equivalent(source, formatted, &raw), + "dropped meaningful trailing text must still be detected as non-equivalent" + ); + } + + #[test] + fn structural_equivalent_still_rejects_interior_blank_removal() { + // A real formatter bug: interior blank line was removed. + // This must still be detected because strip only removes TAIL tokens. + let source = "@if undefined_var:\nx\n\ny\n@end\n"; + let formatted = "@if undefined_var:\nx\ny\n@end\n"; // interior \n removed! + let raw: Vec> = vec![]; + assert!( + !structural_equivalent(source, formatted, &raw), + "interior blank line removal must still be detected as non-equivalent" + ); + } + #[test] fn structural_equivalent_inside_raw_span_is_byte_exact_not_clean_output() { // A @message body is raw content: blank-line runs are NOT normalised by diff --git a/crates/mds-core/src/fs.rs b/crates/mds-core/src/fs.rs index 129e73a4..15ae96de 100644 --- a/crates/mds-core/src/fs.rs +++ b/crates/mds-core/src/fs.rs @@ -107,6 +107,29 @@ pub trait FileSystem: Send + Sync { fn canonicalize(&self, path: &str) -> Result { Ok(path.to_string()) } + + /// Return the established project root directory as a string, if any. + /// + /// Used by the source-map path-relativization choke-point + /// ([`crate::source_path::relativize_source`]) to determine whether a + /// resolved source path is contained within the project root and should be + /// emitted as a root-relative (or map-relative) reference rather than + /// degraded to a bare filename. + /// + /// # Default + /// + /// Returns `None` — suitable for virtual / in-memory filesystems + /// ([`VirtualFs`] / WASM) where there is no containment concept. + /// + /// # Override + /// + /// [`NativeFs`] returns the path established by `init_root` (the project + /// root found by walking up from the entry-point directory). Returns + /// `None` if the root has not been established yet (before any + /// `normalize` or `set_root` call). + fn source_root(&self) -> Option { + None + } } // ── VirtualFs shared segment logic ─────────────────────────────────────────── @@ -272,6 +295,26 @@ pub struct NativeFs { root_dir: OnceLock, } +/// Return the effective parent directory of `path`, always resolving to +/// `Path::new(".")` for bare filenames. +/// +/// `Path::parent()` returns `Some("")` (an empty path) for bare relative +/// filenames like `"hello.mds"`, NOT `None`. An empty path fails +/// `canonicalize()` with a file-not-found error, which is the root cause of +/// the bare-filename release blocker. This function maps both `Some("")` and +/// `None` to `Path::new(".")` so that `check_symlink` resolves bare filenames +/// against the current working directory, matching the behaviour users expect. +/// +/// Absolute paths and paths with a non-empty parent component are returned +/// unchanged. +pub fn effective_parent(path: &Path) -> &Path { + match path.parent() { + None => Path::new("."), + Some(p) if p.as_os_str().is_empty() => Path::new("."), + Some(p) => p, + } +} + impl NativeFs { /// Create a new `NativeFs` with no root directory set. /// @@ -302,7 +345,10 @@ impl NativeFs { .file_name() .ok_or_else(|| MdsError::file_not_found(path.display().to_string()))?; - let parent = path.parent().unwrap_or(Path::new(".")); + // Use effective_parent: path.parent() returns Some("") for bare filenames + // (not None), so "".canonicalize() would fail on every bare-filename call. + // effective_parent maps empty parents to "." (PF-006). + let parent = effective_parent(path); let canonical_parent = parent .canonicalize() .map_err(|_| MdsError::file_not_found(path.display().to_string()))?; @@ -397,14 +443,16 @@ impl FileSystem for NativeFs { // Root entry point: treat `relative` as a filesystem path. let canonical = Self::check_symlink(Path::new(relative))?; // Anchor the security root on first entry-point resolution. - let entry_dir = canonical.parent().unwrap_or(Path::new(".")); + // effective_parent is safe even if canonical is somehow relative — avoids PF-006. + let entry_dir = effective_parent(&canonical); self.init_root(entry_dir); self.check_path_traversal(&canonical)?; Ok(canonical.display().to_string()) } else { // Import from within a resolved module: resolve against the parent // directory of `base` via the Path-typed helper (no String round-trip). - let base_dir = Path::new(base).parent().unwrap_or(Path::new(".")); + // effective_parent guards against an empty parent — avoids PF-006. + let base_dir = effective_parent(Path::new(base)); self.normalize_in_dir_impl(base_dir, relative) } } @@ -470,6 +518,10 @@ impl FileSystem for NativeFs { other => other, }) } + + fn source_root(&self) -> Option { + self.root_dir.get().map(|p| p.display().to_string()) + } } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -1251,6 +1303,77 @@ mod tests { } } + // ── effective_parent ────────────────────────────────────────────────────── + + #[test] + fn effective_parent_bare_name_returns_dot() { + // "hello.mds" — no directory component; Path::parent() returns Some(""). + // effective_parent must return Path::new("."), not the empty path. + assert_eq!(effective_parent(Path::new("hello.mds")), Path::new(".")); + } + + #[test] + fn effective_parent_dot_slash_prefix_returns_dot() { + // "./hello.mds" — parent is "." (non-empty); returned as-is. + assert_eq!(effective_parent(Path::new("./hello.mds")), Path::new(".")); + } + + #[test] + fn effective_parent_subdir_path_unchanged() { + // "sub/hello.mds" — parent is "sub"; returned unchanged. + assert_eq!( + effective_parent(Path::new("sub/hello.mds")), + Path::new("sub") + ); + } + + #[test] + fn effective_parent_absolute_path_unchanged() { + // Absolute path: parent is the directory, which is non-empty. + let p = Path::new("/tmp/hello.mds"); + assert_eq!(effective_parent(p), Path::new("/tmp")); + } + + // ── check_symlink unit tests (absolute paths) ───────────────────────────── + // + // Note: bare-filename (PF-006) integration testing lives at CLI level in + // cli_build::build_load_config_finds_grandparent_mds_json, + // cli_fmt::fmt_bare_filename_propagates_syntax_error, and + // cli_lint::lint_fix_bare_filename_applies_fix. + + #[test] + fn check_symlink_real_absolute_file_is_accepted() { + // A real file reached via an absolute path must succeed. + // Uses an absolute path to avoid mutating std::env::current_dir (process-global, + // races under nextest); this is the same code path that effective_parent enables + // for a bare filename resolved from cwd. + let dir = TempDir::new().unwrap(); + let file = make_temp_file(&dir, "bare.mds", "hello"); + let result = NativeFs::check_symlink(&file); + assert!( + result.is_ok(), + "check_symlink should succeed for a real absolute-path file: {result:?}" + ); + } + + #[test] + #[cfg(unix)] + fn check_symlink_symlinked_file_is_rejected() { + // A symlinked file must be rejected, regardless of whether it is reached + // via a bare name or an absolute path. + let dir = TempDir::new().unwrap(); + let target = make_temp_file(&dir, "target.mds", "hello"); + let link_path = dir.path().join("link.mds"); + std::os::unix::fs::symlink(&target, &link_path).unwrap(); + let result = NativeFs::check_symlink(&link_path); + let err = result.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("symlinks"), + "expected symlink rejection, got: {msg}" + ); + } + #[test] fn external_impl_resolves_import_via_with_fs() { use crate::resolver::ModuleCache; @@ -1279,4 +1402,72 @@ mod tests { "expected 'Hello World!' from custom fs import, got: {output}" ); } + + // ── source_root ─────────────────────────────────────────────────────────── + + #[test] + fn native_source_root_none_before_any_normalize() { + // Before normalize() or set_root() is called, root has not been established. + let fs = NativeFs::new(); + assert_eq!( + fs.source_root(), + None, + "source_root() must be None before any normalize call" + ); + } + + #[test] + fn native_source_root_set_after_normalize() { + // After the first normalize() call the root is established and + // source_root() returns Some. + let dir = TempDir::new().unwrap(); + let file = make_temp_file(&dir, "main.mds", "hello"); + let fs = NativeFs::new(); + fs.normalize("", &file.display().to_string()).unwrap(); + let root = fs.source_root(); + assert!(root.is_some(), "source_root() must be Some after normalize"); + // The returned root must be an absolute path. + assert!( + root.as_deref().unwrap_or("").starts_with('/'), + "source_root() must be absolute, got {:?}", + root + ); + } + + #[test] + fn native_source_root_no_marker_falls_back_to_entry_dir() { + // In a temp directory with no .git / .mdsroot marker, the root should + // fall back to the entry-point directory itself (not a parent). + // + // Exact equality is required: an ancestor check (starts_with) would + // pass even if find_project_root walked up to /tmp or /, which would + // silently widen the containment envelope the security guard rests on. + let dir = TempDir::new().unwrap(); + let file = make_temp_file(&dir, "main.mds", "hello"); + let fs = NativeFs::new(); + fs.normalize("", &file.display().to_string()).unwrap(); + let root = fs.source_root().expect("root must be set after normalize"); + let file_canon = file.canonicalize().unwrap(); + let root_path = std::path::PathBuf::from(&root); + // Canonicalize root_path to resolve macOS /var → /private/var so the + // comparison is not flaky across platforms. + let root_canon = root_path.canonicalize().unwrap_or(root_path); + assert_eq!( + root_canon, + effective_parent(&file_canon), + "source_root must be exactly the entry-point directory (not a parent); \ + root={root:?} file_canon={file_canon:?}" + ); + } + + #[test] + fn vfs_source_root_always_none() { + // VirtualFs has no containment concept — source_root() always returns None. + let fs = VirtualFs::new(std::collections::HashMap::new()); + assert_eq!( + fs.source_root(), + None, + "VirtualFs source_root() must always be None" + ); + } } diff --git a/crates/mds-core/src/lexer.rs b/crates/mds-core/src/lexer.rs index 64d7859b..f252ca5b 100644 --- a/crates/mds-core/src/lexer.rs +++ b/crates/mds-core/src/lexer.rs @@ -272,7 +272,7 @@ impl<'a> Lexer<'a> { } if depth != 0 { return Err(MdsError::syntax_at( - "unclosed interpolation brace", + "unclosed interpolation brace — to include a literal `{`, escape it as `\\{`", self.file, self.source, start, diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index b76c67b2..2b54bc85 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -53,18 +53,20 @@ pub(crate) mod options; pub(crate) mod parser; pub(crate) mod resolver; pub(crate) mod scope; +pub(crate) mod source_path; pub(crate) mod sourcemap; pub(crate) mod validator; pub(crate) mod value; -pub use formatter::{format_str, format_str_with}; -pub use fs::{FileSystem, NativeFs, VirtualFs}; +pub use formatter::{format_str, format_str_named, format_str_with}; +pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; pub use lint::{fix, sanitize_control_chars, LintConfig, LintDiagnostic, LintResult, Severity}; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, }; pub use resolver::ModuleCache; -pub use sourcemap::{CompileOptions, InvalidOptionsError, SourceMap}; +pub use source_path::relativize_source; +pub use sourcemap::{CompileOptions, InvalidOptionsError, SourceMap, STRING_SOURCE_MAP_LABEL}; /// A single structured message produced by a template containing `@message` blocks. /// @@ -411,18 +413,53 @@ pub fn check_str(source: &str) -> Result<(), MdsError> { /// This is one of two UTF-8 boundary enforcement points; the other is /// [`path_to_str`], which handles the entry-point `path` argument. fn resolve_base_dir(base_dir: Option<&Path>) -> Result { + // Canonicalize to an absolute path so NativeFs::canonicalize() always + // receives a path whose file_name() is non-None. + // + // Path::parent() on a bare filename (e.g. "hello.mds") returns Some(""), + // and effective_parent normalises that to Some("."). Neither "" nor "." + // survive NativeFs::canonicalize() because check_symlink() calls + // file_name() on them, which returns None, causing a FileNotFound error + // that assert_equivalent's Err(_) arm then silently swallows via + // structural_equivalent. avoids PF-006. match base_dir { - Some(d) => d - .to_str() - .ok_or_else(|| MdsError::io("base_dir path is not valid UTF-8")) - .map(str::to_owned), + // None and the empty-string sentinel both mean "current working directory". None => std::env::current_dir() .map_err(|e| MdsError::io(format!("cannot determine current directory: {e}"))) - .and_then(|p| { - p.to_str() + .and_then(|cwd| { + cwd.to_str() .ok_or_else(|| MdsError::io("current directory path is not valid UTF-8")) .map(str::to_owned) }), + Some(d) if d.as_os_str().is_empty() => std::env::current_dir() + .map_err(|e| MdsError::io(format!("cannot determine current directory: {e}"))) + .and_then(|cwd| { + cwd.to_str() + .ok_or_else(|| MdsError::io("current directory path is not valid UTF-8")) + .map(str::to_owned) + }), + // Canonicalize resolves "." → absolute cwd, relative → absolute, and + // strips trailing separators so the last component is a real directory name. + // UTF-8 boundary check runs first so that invalid bytes produce a clear + // error rather than a confusing "No such file" from canonicalize. + Some(d) => { + if d.to_str().is_none() { + return Err(MdsError::io("base_dir path is not valid UTF-8")); + } + d.canonicalize() + .map_err(|e| { + MdsError::io(format!( + "cannot resolve base directory {}: {e}", + d.display() + )) + }) + .and_then(|canonical| { + canonical + .to_str() + .ok_or_else(|| MdsError::io("base_dir path is not valid UTF-8")) + .map(str::to_owned) + }) + } } } @@ -902,7 +939,7 @@ pub fn compile_virtual_with_deps( /// /// ```rust,no_run /// use std::path::Path; -/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions { source_map: true, include_sources_content: false })?; +/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() })?; /// if let Some(sm) = result.source_map { println!("{}", sm.to_json()); } /// # Ok::<(), Box>(()) /// ``` @@ -946,7 +983,7 @@ pub fn compile_with_deps_opts( /// "Hello!\n", /// None, /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false }, +/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) @@ -987,7 +1024,7 @@ pub fn compile_str_with_deps_opts( /// modules, /// "main.mds", /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false }, +/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) @@ -1142,9 +1179,11 @@ pub fn lint_str_with( cache.resolve_source_intrinsic(source, &dir, &vars, &mut warnings)?; } // Step 2: lint the entry source. - // Use the same default filename as the WASM backend so lint(source) produces - // a byte-identical "file" key across all surfaces (AC-API-06). - lint::lint_source(source, "input.mds", config) + // Use STRING_SOURCE_MAP_LABEL ("input.mds") — the shared const that both + // the source-map choke-point (sourcemap.rs) and the WASM DEFAULT_FILENAME + // agree on — so lint(source) produces a byte-identical "file" key across + // all surfaces (AC-API-06). + lint::lint_source(source, crate::sourcemap::STRING_SOURCE_MAP_LABEL, config) } /// Lint an MDS file. @@ -1353,11 +1392,13 @@ pub fn load_vars_file(path: &Path) -> Result, MdsError> { } let content = String::from_utf8(bytes) .map_err(|e| MdsError::io(format!("invalid UTF-8 in vars file {path_str}: {e}")))?; - let json: serde_json::Value = - serde_json::from_str(&content).map_err(|e| MdsError::json_error(e.to_string()))?; + let json: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| MdsError::json_error(format!("{path_str}: {e}")))?; let serde_json::Value::Object(map) = json else { - return Err(MdsError::json_error("vars file must contain a JSON object")); + return Err(MdsError::json_error(format!( + "{path_str}: vars file must contain a JSON object" + ))); }; map.into_iter() diff --git a/crates/mds-core/src/lint/facts.rs b/crates/mds-core/src/lint/facts.rs index ade801be..01291144 100644 --- a/crates/mds-core/src/lint/facts.rs +++ b/crates/mds-core/src/lint/facts.rs @@ -520,9 +520,9 @@ fn walk_if_block( ) -> Result<(), MdsError> { extract_condition_refs(&b.condition, ctx); walk_nodes(&b.then_body, ctx, scope, depth + 1)?; - for (cond, branch_body) in &b.elseif_branches { - extract_condition_refs(cond, ctx); - walk_nodes(branch_body, ctx, scope, depth + 1)?; + for branch in &b.elseif_branches { + extract_condition_refs(&branch.condition, ctx); + walk_nodes(&branch.body, ctx, scope, depth + 1)?; } if let Some(else_body) = &b.else_body { walk_nodes(else_body, ctx, scope, depth + 1)?; @@ -727,6 +727,7 @@ mod tests { elseif_branches: vec![], else_body: None, offset: 0, + else_offset: None, })]; } let module = Module { diff --git a/crates/mds-core/src/lint/fix.rs b/crates/mds-core/src/lint/fix.rs index 8bb9ac16..3830c629 100644 --- a/crates/mds-core/src/lint/fix.rs +++ b/crates/mds-core/src/lint/fix.rs @@ -74,6 +74,15 @@ pub struct ByteEdit { pub rule: String, } +/// A fix edit that was rejected by the per-edit reverify gate in [`apply_fixes_incremental`]. +#[derive(Debug, Clone)] +pub struct RejectedEdit { + /// The edit that was rejected. + pub edit: ByteEdit, + /// Human-readable reason for rejection. + pub reason: String, +} + /// A plan of fix edits for a single file's source. #[derive(Debug, Default)] pub struct FixPlan { @@ -89,7 +98,12 @@ pub struct FixPlan { // ── Outcome ─────────────────────────────────────────────────────────────────── /// The outcome of applying a `FixPlan` to a source string. +/// +/// Marked `#[non_exhaustive]` so that adding new variants in a future release +/// does not constitute a semver-breaking change for downstream crates that match +/// on this enum. External callers must include a `_ => {}` wildcard arm. #[derive(Debug)] +#[non_exhaustive] pub enum FixOutcome { /// All edits applied successfully; the fixed source is returned. Fixed { @@ -98,6 +112,20 @@ pub enum FixOutcome { /// Residual diagnostics after applying fixes (from reverify). residual: LintResult, }, + /// Some edits applied, some individually rejected by the per-edit reverify gate. + /// + /// Returned only by [`apply_fixes_incremental`] when the full batch is refused but at + /// least one individual edit passes the reverify gate. The `source` field holds the + /// partially-fixed text; `residual` carries the residual diagnostics (from the last + /// successful per-edit reverify); `rejected` lists every edit that was turned down. + PartiallyFixed { + /// The partially-fixed source (accepted edits applied, rejected edits untouched). + source: String, + /// Residual diagnostics from the last successful per-edit reverify pass. + residual: LintResult, + /// Edits that were individually rejected by the reverify gate. + rejected: Vec, + }, /// The edit batch was rejected (overlap detected or reverify failed). Rejected { /// The original (unchanged) source. @@ -246,6 +274,46 @@ fn has_overlapping_edits(edits: &[ByteEdit]) -> bool { false } +// ── Shared reverify helpers ─────────────────────────────────────────────────── + +/// Count non-targeted diagnostics per rule (used for regression detection in the reverify gate). +/// +/// Returns a `HashMap<&str, usize>` mapping rule name → occurrence count for every diagnostic +/// whose rule is NOT in `targeted`. Used to build the pre-fix baseline and to count post-fix +/// residuals so the two can be compared (AC-F-23). +/// +/// Keys borrow from `diags` — no allocation per entry (issue #68 regression fix). +fn count_untargeted_per_rule<'a>( + diags: &'a [LintDiagnostic], + targeted: &std::collections::HashSet, +) -> std::collections::HashMap<&'a str, usize> { + let mut counts = std::collections::HashMap::new(); + for d in diags { + if !targeted.contains(d.rule.as_str()) { + *counts.entry(d.rule.as_str()).or_insert(0) += 1; + } + } + counts +} + +/// Return the sorted list of rule names whose count increased vs `baseline` (regressions). +/// +/// A rule is regressed when its count in `residual_counts` is strictly greater than its count in +/// `baseline`. Pre-existing untargeted findings (same or lower count) are allowed through (AC-F-23). +fn regressed_rules( + residual_counts: &std::collections::HashMap<&str, usize>, + baseline: &std::collections::HashMap<&str, usize>, +) -> Vec { + let mut regressed = Vec::new(); + for (&rule, &count) in residual_counts { + if count > baseline.get(rule).copied().unwrap_or(0) { + regressed.push(rule.to_string()); + } + } + regressed.sort_unstable(); + regressed +} + // ── Application ─────────────────────────────────────────────────────────────── /// Apply a `FixPlan` to a source string, returning the fixed source. @@ -287,12 +355,20 @@ pub fn apply_plan_unchecked(source: &str, plan: &FixPlan) -> String { let mut result = source.as_bytes().to_vec(); - // `plan.edits` is already sorted ascending by start offset (guaranteed by + // `plan.edits` must be sorted ascending by start offset (guaranteed by // plan_fixes_with_options which calls `edits.sort()` before returning). // Iterate right-to-left with `.rev()` — no clone or re-sort needed. - debug_assert!( + // + // Unconditional assert (not debug_assert) — avoids PF-005: the sortedness + // precondition for right-to-left accumulation is release-critical; a + // debug_assert! would be compiled out in release builds, allowing unsorted + // edits to silently corrupt the source. The `_unchecked` callers in + // `apply_fixes` and `apply_fixes_incremental` perform their own fail-closed + // guards before reaching here; this assert is defense-in-depth for direct + // external callers who bypass those guards. + assert!( plan.edits.windows(2).all(|w| w[0].start <= w[1].start), - "apply_plan_unchecked: edits must be sorted ascending by start offset" + "apply_plan_unchecked: edits must be sorted ascending by start offset (avoids PF-005)" ); for edit in plan.edits.iter().rev() { let start = edit.start; @@ -359,28 +435,24 @@ where }; } + // Sortedness guard (avoids PF-005): edits must be sorted ascending by start offset for the + // right-to-left application in apply_plan_unchecked to be correct. A debug_assert!-only guard + // is compiled out in release builds, where unsorted edits cause silent source corruption. + // This unconditional check returns Rejected before apply_plan_unchecked is reached. + if plan.edits.windows(2).any(|w| w[0].start > w[1].start) { + return FixOutcome::Rejected { + source: source.to_string(), + reason: "Fix edits are not sorted ascending by start offset; refusing to apply \ + to prevent source corruption (avoids PF-005)." + .to_string(), + }; + } + let fixed_source = apply_plan_unchecked(source, &plan); // Build the set of rules targeted by this fix batch. - let targeted_rules: std::collections::HashSet<&str> = - plan.edits.iter().map(|e| e.rule.as_str()).collect(); - - // Local helper: count non-targeted diagnostic occurrences per rule. - // Called for both the original baseline and the post-fix residual so the - // regression check (below) can compare the two counts in one place. - fn count_untargeted_per_rule<'a>( - diags: &'a [LintDiagnostic], - targeted: &std::collections::HashSet<&str>, - ) -> std::collections::HashMap<&'a str, usize> { - let mut counts = std::collections::HashMap::new(); - for d in diags { - let rule = d.rule.as_str(); - if !targeted.contains(rule) { - *counts.entry(rule).or_insert(0) += 1; - } - } - counts - } + let targeted_rules: std::collections::HashSet = + plan.edits.iter().map(|e| e.rule.clone()).collect(); // Baseline: per-rule count of NON-targeted diagnostics that were already present // before the fix. A pre-existing untargeted finding must not trip the gate — only @@ -400,15 +472,9 @@ where // A regression is an untargeted rule whose count grew vs. the original — // i.e. a NEW problem the edit introduced (pre-existing findings survive // untouched and are allowed through, per AC-F-23). - let mut regressed: Vec<&str> = Vec::new(); - for (rule, count) in &residual_counts { - if *count > baseline.get(rule).copied().unwrap_or(0) { - regressed.push(rule); - } - } + let regressed = regressed_rules(&residual_counts, &baseline); if !regressed.is_empty() { - regressed.sort_unstable(); return FixOutcome::Rejected { source: source.to_string(), reason: format!( @@ -426,6 +492,234 @@ where } } +/// Maximum number of edits for which the per-edit fallback path is attempted when the +/// batch reverify fails. +/// +/// Each per-edit reverify call incurs ~3 full module resolves + 2 dependency disk sweeps +/// (every `lint_str_with` builds a fresh `ModuleCache::new()`). For plans exceeding this +/// cap, `apply_fixes_incremental` returns `FixOutcome::Rejected` fail-closed instead of +/// attempting up to `plan.edits.len()` additional reverify calls. +/// +/// The batch attempt (1 call) is always made regardless of plan size — only the fallback +/// is capped. Applies PF-004 (resource cap must hold on all paths, not just the primary one). +pub const FALLBACK_MAX_EDITS: usize = 50; + +/// Apply a `FixPlan` with a bounded per-edit fallback. +/// +/// Attempts the full edit batch first (one reverify call). If the batch is rejected by the +/// reverify gate, falls back to right-to-left per-edit retry: each edit is tested individually +/// against the running (partially-fixed) source. Accepted edits accumulate; rejected edits are +/// collected in [`RejectedEdit`] entries. +/// +/// **Reverify call bound:** ≤ `plan.edits.len() + 1` total calls across both strategies +/// (1 batch attempt + at most `edits.len()` individual retries), subject to [`FALLBACK_MAX_EDITS`]. +/// When `plan.edits.len() > FALLBACK_MAX_EDITS` and the batch fails, the function returns +/// `Rejected` immediately without attempting per-edit retries. +/// +/// **Right-to-left accumulation:** Edits are sorted ascending by offset (guaranteed by +/// [`plan_fixes`]/[`plan_fixes_with_options`]). Per-edit retry processes them highest-offset-first +/// (`iter().rev()`). Each accepted high-offset edit shortens the source at a higher byte position, +/// leaving lower-offset bytes untouched — so subsequent lower-offset edits remain positionally valid. +/// +/// Returns: +/// - [`FixOutcome::Fixed`] — all edits accepted (full batch or all-per-edit passes). +/// - [`FixOutcome::PartiallyFixed`] — at least one edit accepted and at least one rejected. +/// - [`FixOutcome::Rejected`] — overlap detected, unsorted edits, cap exceeded, or ALL +/// per-edit retries refused. The `reason` field includes the actual reverify failure +/// messages (applies ADR-004). +/// - [`FixOutcome::NothingToFix`] — empty plan with no overlap. +/// +/// Unlike [`apply_fixes`] which requires `F: FnOnce`, this function requires `F: Fn` because +/// `reverify` may be called up to `plan.edits.len() + 1` times. +#[must_use = "a dropped FixOutcome silently discards the fix result"] +pub fn apply_fixes_incremental( + source: &str, + plan: FixPlan, + original: &LintResult, + reverify: F, +) -> FixOutcome +where + F: Fn(&str) -> Result, +{ + // Overlap is detected statically in plan_fixes; edits are cleared when overlap is found. + // Per-edit retry cannot rescue an overlap batch — refuse it fail-closed. + if plan.overlap_rejected { + return FixOutcome::Rejected { + source: source.to_string(), + reason: "Overlapping fix spans detected — batch rejected to avoid data corruption." + .to_string(), + }; + } + + if plan.edits.is_empty() { + return FixOutcome::NothingToFix; + } + + // Sortedness guard (avoids PF-005): edits must be sorted ascending by start offset for the + // right-to-left application to be correct. A debug_assert!-only guard would be compiled out + // in release builds, where unsorted edits silently corrupt the source that is written to disk. + // This unconditional check returns Rejected before apply_plan_unchecked is reached. + if plan.edits.windows(2).any(|w| w[0].start > w[1].start) { + return FixOutcome::Rejected { + source: source.to_string(), + reason: "Fix edits are not sorted ascending by start offset; refusing to apply \ + to prevent source corruption (avoids PF-005)." + .to_string(), + }; + } + + let targeted_rules: std::collections::HashSet = + plan.edits.iter().map(|e| e.rule.clone()).collect(); + let baseline = count_untargeted_per_rule(&original.diagnostics, &targeted_rules); + + // ── Batch attempt (one reverify call) ───────────────────────────────────── + // Saves per-edit calls for the common case where all edits are compatible. + let batch_source = apply_plan_unchecked(source, &plan); + match reverify(&batch_source) { + Ok(residual) => { + let residual_counts = count_untargeted_per_rule(&residual.diagnostics, &targeted_rules); + let regressed = regressed_rules(&residual_counts, &baseline); + if regressed.is_empty() { + return FixOutcome::Fixed { + source: batch_source, + residual, + }; + } + // Batch introduced regressions; fall through to per-edit retry. + } + Err(_) => { + // Batch failed reverify; fall through to per-edit retry. + } + } + + // ── Resource cap (PF-004) ───────────────────────────────────────────────── + // The per-edit fallback calls reverify up to plan.edits.len() more times. Each call + // is ~3 module resolves + 2 disk sweeps (fresh ModuleCache per call). For large plans + // in directory mode this is prohibitively expensive — cap fail-closed. + if plan.edits.len() > FALLBACK_MAX_EDITS { + return FixOutcome::Rejected { + source: source.to_string(), + reason: format!( + "Fix plan has {} edits; per-edit fallback cap is {} — batch was rejected by \ + the reverify gate. Re-run --fix after manually reducing the issue count \ + (avoids PF-004).", + plan.edits.len(), + FALLBACK_MAX_EDITS + ), + }; + } + + // ── Per-edit fallback (≤ edits.len() more reverify calls) ───────────────── + // Process right-to-left: previously accepted high-offset changes do not + // invalidate the byte positions of lower-offset edits processed next. + let mut running_source = source.to_string(); + let mut last_residual: Option = None; + let mut rejected: Vec = Vec::new(); + let mut accepted_count: usize = 0; + + for edit in plan.edits.iter().rev() { + let single_plan = FixPlan { + edits: vec![edit.clone()], + overlap_rejected: false, + truncated: false, + }; + let test_source = apply_plan_unchecked(&running_source, &single_plan); + + let reverify_result = reverify(&test_source); + let reject_reason: Option = match &reverify_result { + Err(err) => Some(format!( + "Reverify failed: fixed source does not compile: {err}" + )), + Ok(residual) => { + // Use the full targeted_rules set (identical to the baseline) so the + // comparison is symmetric: other targeted-rule diagnostics that are + // still present (not yet fixed) must not be counted as regressions. + // Using a single-rule targeted set would cause false positives when + // two rules share the same baseline (e.g. empty-block + duplicate-export + // both targeted → applying dup-export fix alone leaves empty-block in + // residual, which would incorrectly look like a new regression against + // a baseline that excluded it). + let residual_counts = + count_untargeted_per_rule(&residual.diagnostics, &targeted_rules); + let regressed = regressed_rules(&residual_counts, &baseline); + if !regressed.is_empty() { + Some(format!( + "Reverify produced new untargeted diagnostics: {regressed:?}. \ + Edit reverted." + )) + } else { + None + } + } + }; + + if let Some(reason) = reject_reason { + rejected.push(RejectedEdit { + edit: edit.clone(), + reason, + }); + } else { + running_source = test_source; + if let Ok(residual) = reverify_result { + last_residual = Some(residual); + } + accepted_count += 1; + } + } + + if accepted_count == 0 { + // Surface the real per-edit rejection reasons (applies ADR-004): the three-tier + // safety gate is only as useful as its refusal reporting. Include actual reverify + // failure messages so callers can diagnose why every edit was refused. + let reason = if rejected.is_empty() { + // Defensive: should not reach here with an empty rejected vec when + // accepted_count==0 and the plan was non-empty, but fail-safe. + "All fix edits were rejected by the per-edit reverify gate.".to_string() + } else if rejected.len() == 1 { + rejected[0].reason.clone() + } else { + let reasons = rejected + .iter() + .map(|r| r.reason.as_str()) + .collect::>() + .join("; "); + format!("All {} fix edits rejected: {}", rejected.len(), reasons) + }; + return FixOutcome::Rejected { + source: source.to_string(), + reason, + }; + } + + // invariant: accepted_count > 0 → at least one Ok(residual) was stored above, + // because reject_reason is None only when reverify_result is Ok(_). Fail closed + // rather than panic in case the invariant is ever violated. + let residual = match last_residual { + Some(r) => r, + None => { + return FixOutcome::Rejected { + source: source.to_string(), + reason: "internal: reverify residual missing despite accepted_count > 0; \ + fix aborted to preserve correctness" + .to_string(), + }; + } + }; + + if rejected.is_empty() { + FixOutcome::Fixed { + source: running_source, + residual, + } + } else { + FixOutcome::PartiallyFixed { + source: running_source, + residual, + rejected, + } + } +} + // ── LintResult extension ────────────────────────────────────────────────────── /// Extension methods on `LintResult` for fix-tier metadata. @@ -974,4 +1268,418 @@ mod tests { "apply_fixes must return Rejected when reverify detects an output delta; got: {outcome:?}" ); } + + // ── apply_fixes_incremental ──────────────────────────────────────────────── + + /// INC-1: Empty plan with no overlap → NothingToFix (zero reverify calls). + #[test] + fn incremental_nothing_to_fix() { + let source = "Hello!\n"; + let original = make_result(vec![]); + let plan = FixPlan { + edits: vec![], + overlap_rejected: false, + truncated: false, + }; + let outcome = apply_fixes_incremental(source, plan, &original, |_| { + unreachable!("no calls expected") + }); + assert!( + matches!(outcome, FixOutcome::NothingToFix), + "empty plan must return NothingToFix; got: {outcome:?}" + ); + } + + /// INC-2: overlap_rejected = true → Rejected immediately, no reverify calls. + #[test] + fn incremental_overlap_immediate_reject() { + let source = "Hello!\n"; + let original = make_result(vec![]); + let plan = FixPlan { + edits: vec![], + overlap_rejected: true, + truncated: false, + }; + let outcome = apply_fixes_incremental(source, plan, &original, |_| { + unreachable!("no calls expected") + }); + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "overlap must return Rejected; got: {outcome:?}" + ); + } + + /// INC-3: Batch reverify passes → Fixed in exactly 1 reverify call (no per-edit loop). + #[test] + fn incremental_batch_success_single_call() { + // Two removable lines at known offsets. + let source = "LineA\nLineB\nKeep!\n"; + let original = make_result(vec![ + make_diag("duplicate-import", 0, "LineA".len()), + make_diag("duplicate-import", "LineA\n".len(), "LineB".len()), + ]); + let plan = plan_fixes_with_options(&original, source, false); + assert!(!plan.overlap_rejected); + assert_eq!(plan.edits.len(), 2, "both edits must be planned"); + + let call_count = std::cell::Cell::new(0usize); + let outcome = apply_fixes_incremental(source, plan, &original, |_fixed| { + call_count.set(call_count.get() + 1); + Ok(make_result(vec![])) + }); + + assert!( + matches!(outcome, FixOutcome::Fixed { .. }), + "batch success must return Fixed; got: {outcome:?}" + ); + assert_eq!( + call_count.get(), + 1, + "batch success must use exactly 1 reverify call; got: {}", + call_count.get() + ); + } + + /// INC-4: Batch fails, per-edit retry: one edit accepted, one rejected → PartiallyFixed. + /// + /// Source: "LineA\nLineB\n" (12 bytes). + /// edit[0] removes LineA (0..6), edit[1] removes LineB (6..12). + /// Reverify rejects empty strings → batch ("") fails. + /// Per-edit right-to-left: edit[1] first → "LineA\n" (passes); edit[0] → "" (fails). + /// Expected: PartiallyFixed { source: "LineA\n", rejected: [edit[0]] }. + #[test] + fn incremental_partial_batch_fail_per_edit_fallback() { + let source = "LineA\nLineB\n"; + let original = make_result(vec![ + make_diag("duplicate-import", 0, "LineA".len()), + make_diag("duplicate-import", "LineA\n".len(), "LineB".len()), + ]); + let plan = plan_fixes_with_options(&original, source, false); + assert!(!plan.overlap_rejected); + assert_eq!(plan.edits.len(), 2); + + // Reverify: reject empty results (simulates "can't compile an empty file"). + let call_count = std::cell::Cell::new(0usize); + let outcome = apply_fixes_incremental(source, plan, &original, |fixed| { + call_count.set(call_count.get() + 1); + if fixed.trim().is_empty() { + Err(crate::error::MdsError::Io { + message: "empty source rejected".to_string(), + }) + } else { + Ok(make_result(vec![])) + } + }); + + match &outcome { + FixOutcome::PartiallyFixed { + source: fixed_src, + rejected, + .. + } => { + assert_eq!( + fixed_src, "LineA\n", + "accepted edit (LineB removal) should yield 'LineA\\n'; got: {fixed_src:?}" + ); + assert_eq!(rejected.len(), 1, "exactly one edit should be rejected"); + assert_eq!( + rejected[0].edit.rule, "duplicate-import", + "rejected edit must be the LineA removal" + ); + } + other => panic!("expected PartiallyFixed; got: {other:?}"), + } + + // Call count: 1 (batch) + 2 (per-edit for 2 edits) = 3 ≤ edits.len()+1+1 + // (batch fails = 1 call; per-edit = 2 calls; total = 3 = 2+1 = edits.len()+1) + assert_eq!( + call_count.get(), + 3, + "batch(1) + per-edit(2) = 3 calls for 2 edits; got: {}", + call_count.get() + ); + } + + /// INC-5: Batch fails, all per-edit retries fail → Rejected. + #[test] + fn incremental_all_rejected() { + let source = "LineA\nLineB\n"; + let original = make_result(vec![ + make_diag("duplicate-import", 0, "LineA".len()), + make_diag("duplicate-import", "LineA\n".len(), "LineB".len()), + ]); + let plan = plan_fixes_with_options(&original, source, false); + assert_eq!(plan.edits.len(), 2); + + let call_count = std::cell::Cell::new(0usize); + let outcome = apply_fixes_incremental(source, plan, &original, |_fixed| { + call_count.set(call_count.get() + 1); + Err(crate::error::MdsError::Io { + message: "always-fail".to_string(), + }) + }); + + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "all-rejected must return Rejected; got: {outcome:?}" + ); + // 1 (batch) + 2 (per-edit) = 3 = edits.len()+1 + assert_eq!( + call_count.get(), + 3, + "call count must be edits.len()+1 = 3; got: {}", + call_count.get() + ); + } + + /// INC-6: Call count bound — N edits → ≤ N+1 total reverify calls. + #[test] + fn incremental_call_count_bounded() { + // Three-edit source: "A\nB\nC\n" (each line 2 bytes including \n). + let source = "A\nB\nC\n"; + let original = make_result(vec![ + make_diag("duplicate-import", 0, 1), + make_diag("duplicate-import", 2, 1), + make_diag("duplicate-import", 4, 1), + ]); + let plan = plan_fixes_with_options(&original, source, false); + assert_eq!(plan.edits.len(), 3, "all three edits must be planned"); + + let call_count = std::cell::Cell::new(0usize); + // Batch always fails; per-edit always passes → all accepted. + let first_call = std::cell::Cell::new(true); + let outcome = apply_fixes_incremental(source, plan, &original, |_fixed| { + call_count.set(call_count.get() + 1); + if first_call.get() { + first_call.set(false); + Err(crate::error::MdsError::Io { + message: "batch-fail".to_string(), + }) + } else { + Ok(make_result(vec![])) + } + }); + + // Batch fails (1 call) + 3 per-edit (3 calls) = 4 = edits.len()+1. + assert!( + call_count.get() <= 3 + 1, + "call count must be ≤ edits.len()+1 = 4; got: {}", + call_count.get() + ); + // All per-edit passed → Fixed. + assert!( + matches!(outcome, FixOutcome::Fixed { .. }), + "all edits accepted → Fixed; got: {outcome:?}" + ); + } + + /// INC-7: Right-to-left accumulation is correct — applying edits right-to-left + /// preserves lower-offset edit validity after higher-offset edits are accepted. + #[test] + fn incremental_right_to_left_accumulation() { + // Source: "AAAA\nBBBB\nKeep!\n" + // edit[0]: remove line 0 (AAAA\n, bytes 0..5) + // edit[1]: remove line 1 (BBBB\n, bytes 5..10) + // Batch fails; both pass individually. + // Expected fixed source: "Keep!\n" (both lines removed, right-to-left order maintained). + let source = "AAAA\nBBBB\nKeep!\n"; + let original = make_result(vec![ + make_diag("duplicate-import", 0, "AAAA".len()), + make_diag("duplicate-import", "AAAA\n".len(), "BBBB".len()), + ]); + let plan = plan_fixes_with_options(&original, source, false); + assert_eq!(plan.edits.len(), 2); + + let first_call = std::cell::Cell::new(true); + let outcome = apply_fixes_incremental(source, plan, &original, |_fixed| { + if first_call.get() { + first_call.set(false); + Err(crate::error::MdsError::Io { + message: "batch-fail".to_string(), + }) + } else { + Ok(make_result(vec![])) + } + }); + + match &outcome { + FixOutcome::Fixed { + source: fixed_src, .. + } => { + assert_eq!( + fixed_src, "Keep!\n", + "both edits accepted right-to-left must yield 'Keep!\\n'; got: {fixed_src:?}" + ); + } + other => panic!("expected Fixed; got: {other:?}"), + } + } + + // ── PF-005 regression: sortedness guard ────────────────────────────────── + + /// PF-005 regression: `apply_fixes_incremental` with unsorted edits must return + /// `FixOutcome::Rejected`, NOT silently corrupt the source. + /// + /// **Why this test is critical:** In a RELEASE build (`--release`), the pre-fix code's + /// `debug_assert!` in `apply_plan_unchecked` is compiled out. `apply_fixes_incremental` + /// had no sortedness check at all, so passing unsorted edits would silently apply them + /// in the wrong order and WRITE the corrupted source TO DISK with no diagnostic. + /// + /// After the fix, an unconditional guard in `apply_fixes_incremental` catches unsorted + /// edits before `apply_plan_unchecked` is reached, returning `Rejected` in both debug + /// and release builds. + /// + /// This test would PANIC against the pre-fix code in debug mode (the `debug_assert!` + /// in `apply_plan_unchecked` fires) and would produce a WRONG outcome (source silently + /// corrupted, then reverify might return `Fixed`) in a release build. After the fix, + /// it returns `Rejected` in all build modes. + #[test] + fn pf005_unsorted_edits_rejected_in_incremental() { + let source = "LineA\nLineB\n"; + let original = make_result(vec![]); + // Manually construct a plan with DESCENDING offsets — this violates the ascending-sort + // invariant required for correct right-to-left application. + // edit[0]: LineB at offset 6 (higher) listed FIRST — wrong order. + // edit[1]: LineA at offset 0 (lower) listed SECOND — wrong order. + let plan = FixPlan { + edits: vec![ + ByteEdit { + start: 6, + end: 12, + rule: "duplicate-import".to_string(), + }, + ByteEdit { + start: 0, + end: 6, + rule: "duplicate-import".to_string(), + }, + ], + overlap_rejected: false, + truncated: false, + }; + let outcome = apply_fixes_incremental(source, plan, &original, |_| Ok(make_result(vec![]))); + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "unsorted edits must be rejected, not silently applied; got: {outcome:?}" + ); + } + + /// PF-005 regression: `apply_fixes` with unsorted edits must return + /// `FixOutcome::Rejected`, NOT silently corrupt the source. + #[test] + fn pf005_unsorted_edits_rejected_in_apply_fixes() { + let source = "LineA\nLineB\n"; + let original = make_result(vec![]); + let plan = FixPlan { + edits: vec![ + ByteEdit { + start: 6, + end: 12, + rule: "duplicate-import".to_string(), + }, + ByteEdit { + start: 0, + end: 6, + rule: "duplicate-import".to_string(), + }, + ], + overlap_rejected: false, + truncated: false, + }; + let outcome = apply_fixes(source, plan, &original, |_| { + unreachable!("reverify must not be called when edits are unsorted") + }); + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "unsorted edits must be rejected in apply_fixes; got: {outcome:?}" + ); + } + + // ── INC-8: FALLBACK_MAX_EDITS cap (PF-004) ────────────────────────────── + + /// INC-8: A plan with more than `FALLBACK_MAX_EDITS` edits returns `Rejected` + /// fail-closed when the batch fails, using only 1 reverify call (batch only). + /// This prevents the O(N×resolves) per-edit fallback on large plans (avoids PF-004). + #[test] + fn fallback_max_edits_cap_rejects_large_plan() { + // Build a plan with exactly FALLBACK_MAX_EDITS + 1 edits (one over the cap). + let lines: Vec = (0..=FALLBACK_MAX_EDITS) + .map(|i| format!("L{i}\n")) + .collect(); + let source = lines.concat(); + let mut offset = 0usize; + let mut diags = Vec::new(); + for line in &lines { + diags.push(make_diag("duplicate-import", offset, 1)); + offset += line.len(); + } + let original = make_result(diags); + let plan = plan_fixes_with_options(&original, &source, false); + assert_eq!( + plan.edits.len(), + FALLBACK_MAX_EDITS + 1, + "plan must have FALLBACK_MAX_EDITS+1 edits for this test to be valid" + ); + + // Batch always fails (simulates block-spanning Tier A edit defeating the batch). + let call_count = std::cell::Cell::new(0usize); + let outcome = apply_fixes_incremental(&source, plan, &original, |_fixed| { + call_count.set(call_count.get() + 1); + Err(crate::error::MdsError::Io { + message: "batch-fail".to_string(), + }) + }); + + // Must be Rejected (cap exceeded) — no per-edit retries. + assert!( + matches!(outcome, FixOutcome::Rejected { .. }), + "plan exceeding FALLBACK_MAX_EDITS must be Rejected fail-closed; got: {outcome:?}" + ); + // Only 1 reverify call (batch attempt), not N+1. + assert_eq!( + call_count.get(), + 1, + "only the batch reverify call must be made when cap is exceeded; got: {}", + call_count.get() + ); + } + + // ── #7 regression: real rejection reasons surfaced (ADR-004) ──────────── + + /// #7 regression: when all per-edit retries are rejected, the `reason` in + /// `FixOutcome::Rejected` must include the actual reverify failure messages, + /// not the former fixed string "All fix edits were rejected…". + /// + /// Applies ADR-004: a three-tier safety gate is only as useful as its refusal + /// reporting — surfacing the real rejection reason is diagnostic infrastructure + /// for the whole `--fix` feature. + #[test] + fn rejected_reason_includes_per_edit_failure_details() { + let source = "LineA\n"; + let original = make_result(vec![make_diag("duplicate-import", 0, "LineA".len())]); + let plan = plan_fixes_with_options(&original, source, false); + assert_eq!( + plan.edits.len(), + 1, + "must have exactly 1 edit for this test" + ); + + // Reverify always fails with a distinctive message. + let outcome = apply_fixes_incremental(source, plan, &original, |_| { + Err(crate::error::MdsError::Io { + message: "distinctive-rejection-message".to_string(), + }) + }); + + match &outcome { + FixOutcome::Rejected { reason, .. } => { + assert!( + reason.contains("distinctive-rejection-message"), + "rejection reason must include the actual per-edit failure message \ + (applies ADR-004); got reason: {reason:?}" + ); + } + other => panic!("expected Rejected; got: {other:?}"), + } + } } diff --git a/crates/mds-core/src/lint/rules/empty_block.rs b/crates/mds-core/src/lint/rules/empty_block.rs index 1e885eac..18aea793 100644 --- a/crates/mds-core/src/lint/rules/empty_block.rs +++ b/crates/mds-core/src/lint/rules/empty_block.rs @@ -76,7 +76,7 @@ fn check_nodes( make_diag( *severity, filename, - "@for body is empty".to_string(), + "@for body is empty.".to_string(), Some("Add content inside the @for block or remove it.".to_string()), b.offset, "@for".len(), @@ -94,7 +94,7 @@ fn check_nodes( make_diag( *severity, filename, - format!("@define '{}' body is empty", b.name), + format!("@define '{}' body is empty.", b.name), Some("Add a body to the function or remove the definition.".to_string()), b.offset, "@define".len() + 1 + b.name.len(), @@ -112,7 +112,7 @@ fn check_nodes( make_diag( *severity, filename, - "@message body is empty".to_string(), + "@message body is empty.".to_string(), Some( "Add content to the message block or remove it. \ Empty @message is allowed for priming but often accidental." @@ -155,7 +155,7 @@ fn check_if_block( make_diag( *severity, filename, - "@if then-body is empty".to_string(), + "@if then-body is empty.".to_string(), Some("Add content inside the @if block or remove it.".to_string()), b.offset, "@if".len(), @@ -166,18 +166,17 @@ fn check_if_block( } // Check @elseif branches. - for (_, branch_body) in &b.elseif_branches { - // No per-elseif offset stored in the AST — use the @if offset as an approximation. + for branch in &b.elseif_branches { if flag_if_empty( - branch_body, + &branch.body, filename, severity, make_diag( *severity, filename, - "@elseif body is empty".to_string(), + "@elseif body is empty.".to_string(), Some("Add content inside the @elseif block or remove it.".to_string()), - b.offset, // approximate: no per-elseif offset in AST + branch.offset, "@elseif".len(), ), builder, @@ -196,9 +195,9 @@ fn check_if_block( make_diag( *severity, filename, - "@else body is empty".to_string(), + "@else body is empty.".to_string(), Some("Add content inside the @else block or remove it.".to_string()), - b.offset, + b.else_offset.unwrap_or(b.offset), "@else".len(), ), builder, @@ -424,6 +423,61 @@ mod tests { ); } + /// The @elseif diagnostic span is anchored at the @elseif line, not the @if line. + /// + /// The span must point at the `@elseif` directive itself, via `ElseifBranch.offset`. + /// + /// Source layout (ASCII, all bytes): + /// "@if x:\nhello\n@elseif y:\n@end\n" + /// ^0 ^7 ^13 + /// @elseif is at byte offset 13. + #[test] + fn elseif_empty_body_span_at_elseif_offset() { + // @if then-body has content; only @elseif body is empty. + let src = "@if x:\nhello\n@elseif y:\n@end\n"; + let diags = lint_src(src); + let elseif_diag = diags + .iter() + .find(|d| d.rule == RULE && d.message.contains("@elseif")) + .expect("expected an @elseif empty-body diagnostic"); + let span = elseif_diag + .span + .as_ref() + .expect("diagnostic must carry a span"); + assert_eq!( + span.offset, 13, + "@elseif diagnostic span must be at the @elseif directive (byte 13), \ + not at the @if opener (byte 0); got offset {}", + span.offset + ); + } + + /// The @else diagnostic span uses else_offset when present. + /// + /// Source layout: + /// "@if x:\nhello\n@else:\n@end\n" + /// ^0 ^7 ^13 + /// @else is at byte offset 13. + #[test] + fn else_empty_body_span_at_else_offset() { + let src = "@if x:\nhello\n@else:\n@end\n"; + let diags = lint_src(src); + let else_diag = diags + .iter() + .find(|d| d.rule == RULE && d.message.contains("@else")) + .expect("expected an @else empty-body diagnostic"); + let span = else_diag + .span + .as_ref() + .expect("diagnostic must carry a span"); + assert_eq!( + span.offset, 13, + "@else diagnostic span must be at the @else directive (byte 13), \ + not at the @if opener (byte 0); got offset {}", + span.offset + ); + } + /// Turning off the rule via config produces no diagnostics. #[test] fn rule_off_suppresses_all() { diff --git a/crates/mds-core/src/lint/rules/redundant_else.rs b/crates/mds-core/src/lint/rules/redundant_else.rs index 0345da13..6fd3ac53 100644 --- a/crates/mds-core/src/lint/rules/redundant_else.rs +++ b/crates/mds-core/src/lint/rules/redundant_else.rs @@ -91,8 +91,8 @@ fn check_nodes( // Recurse into all branches. check_nodes(&b.then_body, filename, severity, builder); - for (_, branch_body) in &b.elseif_branches { - check_nodes(branch_body, filename, severity, builder); + for branch in &b.elseif_branches { + check_nodes(&branch.body, filename, severity, builder); } if let Some(else_body) = &b.else_body { check_nodes(else_body, filename, severity, builder); diff --git a/crates/mds-core/src/lint/rules/structural_eq.rs b/crates/mds-core/src/lint/rules/structural_eq.rs index a292542e..9fdbdd2b 100644 --- a/crates/mds-core/src/lint/rules/structural_eq.rs +++ b/crates/mds-core/src/lint/rules/structural_eq.rs @@ -118,7 +118,10 @@ pub(crate) fn node_eq(a: &Node, b: &Node) -> bool { .elseif_branches .iter() .zip(&b2.elseif_branches) - .all(|((c1, n1), (c2, n2))| conditions_eq(c1, c2) && nodes_eq(n1, n2)) + .all(|(br1, br2)| { + conditions_eq(&br1.condition, &br2.condition) + && nodes_eq(&br1.body, &br2.body) + }) && match (&b1.else_body, &b2.else_body) { (None, None) => true, (Some(n1), Some(n2)) => nodes_eq(n1, n2), @@ -198,7 +201,7 @@ pub(crate) fn is_literal(expr: &Expr) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::ast::{Expr, Node, TextNode}; + use crate::ast::{Condition, ElseifBranch, Expr, IfBlock, Node, TextNode}; #[test] fn exprs_eq_var() { @@ -253,4 +256,40 @@ mod tests { assert!(is_literal(&Expr::NullLiteral)); assert!(!is_literal(&Expr::Var("x".to_string()))); } + + /// ElseifBranch.offset is intentionally excluded from structural equality. + /// + /// Two IfBlock nodes that are logically identical (same condition, same bodies) + /// but parsed from different source positions must compare equal. This locks in + /// the invariant documented in ast.rs: `offset` is a span annotation, not part + /// of the template's logical identity. + #[test] + fn elseif_branch_offset_excluded_from_structural_eq() { + let make_if = |elseif_offset: usize| { + Node::If(IfBlock { + condition: Condition::Truthy(Expr::Var("a".to_string())), + then_body: vec![Node::Text(TextNode { + text: "A".to_string(), + offset: 0, + })], + elseif_branches: vec![ElseifBranch { + condition: Condition::Truthy(Expr::Var("b".to_string())), + body: vec![Node::Text(TextNode { + text: "B".to_string(), + offset: 0, + })], + offset: elseif_offset, // the only difference between the two nodes + }], + else_body: None, + offset: 0, + else_offset: None, + }) + }; + let a = make_if(9); + let b = make_if(999); + assert!( + node_eq(&a, &b), + "IfBlocks differing only in ElseifBranch.offset must be structurally equal" + ); + } } diff --git a/crates/mds-core/src/lint/rules/unreachable_branch.rs b/crates/mds-core/src/lint/rules/unreachable_branch.rs index 509d917a..5ee782f4 100644 --- a/crates/mds-core/src/lint/rules/unreachable_branch.rs +++ b/crates/mds-core/src/lint/rules/unreachable_branch.rs @@ -73,8 +73,8 @@ fn check_nodes( check_if_block(b, filename, severity, builder); // Recurse into bodies. check_nodes(&b.then_body, filename, severity, builder); - for (_, body) in &b.elseif_branches { - check_nodes(body, filename, severity, builder); + for branch in &b.elseif_branches { + check_nodes(&branch.body, filename, severity, builder); } if let Some(else_body) = &b.else_body { check_nodes(else_body, filename, severity, builder); @@ -111,7 +111,7 @@ fn check_if_block( && !builder.push(make_diag( *severity, filename, - "@if condition is always true — @elseif/@else branches are unreachable" + "@if condition is always true — @elseif/@else branches are unreachable." .to_string(), Some( "Replace the constant condition with a variable or remove later branches." @@ -129,7 +129,7 @@ fn check_if_block( if !builder.push(make_diag( *severity, filename, - "@if condition is always false — the then-body is dead code".to_string(), + "@if condition is always false — the then-body is dead code.".to_string(), Some( "Replace the constant condition with a variable or remove the dead branch." .to_string(), @@ -147,7 +147,8 @@ fn check_if_block( // Collect all seen conditions in order; flag a branch if its condition equals any prior one. let mut seen_conditions: Vec<&Condition> = vec![&b.condition]; - for (cond, _body) in &b.elseif_branches { + for branch in &b.elseif_branches { + let cond = &branch.condition; // Check if this @elseif condition duplicates any prior condition. let is_duplicate = seen_conditions .iter() @@ -163,7 +164,7 @@ fn check_if_block( this branch can never be reached." .to_string(), Some("Remove the duplicate @elseif branch or change its condition.".to_string()), - b.offset, + branch.offset, "@elseif".len(), )) { return; @@ -175,9 +176,9 @@ fn check_if_block( if !builder.push(make_diag( *severity, filename, - "@elseif condition is always true".to_string(), + "@elseif condition is always true.".to_string(), Some("Replace the constant condition with a variable.".to_string()), - b.offset, + branch.offset, "@elseif".len(), )) { return; @@ -187,12 +188,12 @@ fn check_if_block( if !builder.push(make_diag( *severity, filename, - "@elseif condition is always false — this branch is dead code".to_string(), + "@elseif condition is always false — this branch is dead code.".to_string(), Some( "Replace the constant condition with a variable or remove the dead branch." .to_string(), ), - b.offset, + branch.offset, "@elseif".len(), )) { return; @@ -435,6 +436,49 @@ mod tests { ); } + /// Duplicate @elseif diagnostic is anchored at the @elseif line, not the @if line. + /// + /// Source layout (all ASCII): + /// "@if x == \"a\":\nfoo\n@elseif x == \"a\":\nbar\n@end\n" + /// ^0 ^14 ^18 + /// + /// The duplicate-@elseif diagnostic must have span.offset == 18 (start of @elseif), + /// not 0 (start of @if). + #[test] + fn duplicate_elseif_diagnostic_anchored_at_elseif() { + // Need x in scope for check_str to pass. + let src = "---\nx: hello\n---\n@if x == \"a\":\nfoo\n@elseif x == \"a\":\nbar\n@end\n"; + // "---\nx: hello\n---\n" = 17 bytes; @if starts at 17. + // "@if x == \"a\":\n" = 14 bytes, so @elseif starts at 17+14+3 (foo\n) = 34 + // Let's compute: "---\nx: hello\n---\n" has chars: + // '-','-','-','\n','x',':',' ','h','e','l','l','o','\n','-','-','-','\n' = 17 bytes + // "@if x == \"a\":\n" = '@','i','f',' ','x',' ','=','=',' ','"','a','"',':','\n' = 14 bytes → starts at 17, ends at 30 + // "foo\n" = 4 bytes → starts at 31, ends at 34 + // "@elseif x == \"a\":\n" starts at 35 + let diags = lint_src(src); + let dup_diag = diags + .iter() + .find(|d| d.rule == RULE && d.message.contains("structurally identical")) + .expect("expected a duplicate-@elseif diagnostic"); + let span = dup_diag + .span + .as_ref() + .expect("diagnostic must carry a span"); + // Verify the span is NOT at the @if opener (byte 17). + assert_ne!( + span.offset, 17, + "duplicate @elseif diagnostic must NOT be anchored at @if opener (byte 17)" + ); + // Verify the span IS at or after the @elseif directive. + // The @elseif "x == \"a\":" starts at offset 35 in this source. + assert_eq!( + span.offset, 35, + "duplicate @elseif diagnostic must be at the @elseif directive (byte 35); \ + got offset {}", + span.offset + ); + } + /// Rule=error is the default; rule=off suppresses. #[test] fn rule_off_suppresses() { diff --git a/crates/mds-core/src/parser.rs b/crates/mds-core/src/parser.rs index 872ab9fd..75dbe40b 100644 --- a/crates/mds-core/src/parser.rs +++ b/crates/mds-core/src/parser.rs @@ -10,7 +10,7 @@ //! - **`parser_tests.rs`** — integration and unit tests for both modules. use crate::ast::{ - BlockNode, Condition, DefineBlock, Expr, ExtendsDirective, ForBlock, Frontmatter, IfBlock, + BlockNode, DefineBlock, ElseifBranch, Expr, ExtendsDirective, ForBlock, Frontmatter, IfBlock, IncludeDirective, MessageBlock, Module, Node, TextNode, }; use crate::error::MdsError; @@ -183,7 +183,13 @@ impl Parser<'_> { } /// Consume the closing `@end` token, returning an error if absent or wrong. - fn consume_end(&mut self, block_name: &str) -> Result<(), MdsError> { + /// Consume the `@end` token that closes `block_name`, or emit a syntax error. + /// + /// `opener_offset` is the byte offset of the OPENING directive token (e.g. + /// the `@if` that this `@end` must close). When the block is never closed + /// the error is anchored at the opener so the user sees the unclosed line + /// rather than EOF. + fn consume_end(&mut self, block_name: &str, opener_offset: usize) -> Result<(), MdsError> { match self.tokens.get(self.pos) { Some(Token::Directive(d, _)) if d.trim() == "@end" => { self.pos += 1; @@ -193,9 +199,13 @@ impl Parser<'_> { "expected @end to close {block_name} block, got '{}'", d.trim() ))), - _ => Err(MdsError::syntax(format!( - "unclosed {block_name} block (missing @end)" - ))), + _ => Err(MdsError::syntax_at( + format!("unclosed {block_name} block (missing @end)"), + self.file, + self.source, + opener_offset, + block_name.len(), + )), } } @@ -378,15 +388,21 @@ impl Parser<'_> { let elseif_branches = self.collect_elseif_branches()?; - let else_body = if matches!(self.peek(), Some(Token::Directive(d, _)) if d.trim() == "@else:") - { - self.pos += 1; // skip @else: - Some(self.parse_body(&["@end"], &[])?) + // Capture the @else: offset for accurate lint spans, then parse the body. + let (else_body, else_offset) = if let Some(Token::Directive(d, else_off)) = self.peek() { + if d.trim() == "@else:" { + let captured_offset = *else_off; + self.pos += 1; // skip @else: + let body = self.parse_body(&["@end"], &[])?; + (Some(body), Some(captured_offset)) + } else { + (None, None) + } } else { - None + (None, None) }; - self.consume_end("@if")?; + self.consume_end("@if", offset)?; self.depth -= 1; Ok(Node::If(IfBlock { @@ -395,16 +411,20 @@ impl Parser<'_> { then_body, else_body, offset, + else_offset, })) } /// Consume all consecutive `@elseif` directive tokens and return the parsed branches. /// + /// Each branch carries its byte offset (the position of the `@elseif` token) so + /// lint rules can anchor diagnostics at the exact `@elseif` line (#181). + /// /// The limit check runs **before** parsing each branch body so that adversarial /// input that exceeds `MAX_ELSEIF_BRANCHES` cannot force unbounded parse work. - fn collect_elseif_branches(&mut self) -> Result)>, MdsError> { - let mut branches: Vec<(Condition, Vec)> = Vec::with_capacity(4); - while let Some(Token::Directive(d, _)) = self.peek() { + fn collect_elseif_branches(&mut self) -> Result, MdsError> { + let mut branches: Vec = Vec::with_capacity(4); + while let Some(Token::Directive(d, off)) = self.peek() { if !d.trim().starts_with("@elseif ") { break; } @@ -416,8 +436,10 @@ impl Parser<'_> { ))); } - // Consume the @elseif directive token. + // d and off are borrowed from self.tokens[self.pos] via peek(); clone + // before advancing pos so the borrows end before the mutable advance. let elseif_dir = d.clone(); + let elseif_offset = *off; self.pos += 1; // Extract condition string: strip "@elseif " prefix and trailing ":". @@ -430,10 +452,14 @@ impl Parser<'_> { let elseif_cond_str = strip_trailing_directive_colon(elseif_rest) .ok_or_else(|| directive_colon_error("@elseif", elseif_rest))?; - let elseif_cond = parse_condition(elseif_cond_str)?; - let elseif_body = self.parse_body(&["@else:", "@end"], &["@elseif "])?; + let condition = parse_condition(elseif_cond_str)?; + let body = self.parse_body(&["@else:", "@end"], &["@elseif "])?; - branches.push((elseif_cond, elseif_body)); + branches.push(ElseifBranch { + condition, + body, + offset: elseif_offset, + }); } Ok(branches) } @@ -474,7 +500,7 @@ impl Parser<'_> { let body = self.parse_body(&["@end"], &[])?; - self.consume_end("@for")?; + self.consume_end("@for", offset)?; self.depth -= 1; Ok(Node::For(ForBlock { @@ -535,7 +561,7 @@ impl Parser<'_> { let body = _guard.0.parse_body(&["@end"], &[])?; - _guard.0.consume_end("@message")?; + _guard.0.consume_end("@message", offset)?; // Guard drops here, restoring inside_message=false and depth-=1. Ok(Node::Message(MessageBlock { role, body, offset })) @@ -597,7 +623,7 @@ impl Parser<'_> { let body = _guard.0.parse_body(&["@end"], &[])?; - _guard.0.consume_end("@block")?; + _guard.0.consume_end("@block", offset)?; // Guard drops here, restoring inside_block=false and depth-=1. Ok(Node::Block(BlockNode { name, body, offset })) @@ -636,7 +662,7 @@ impl Parser<'_> { let body = self.parse_body(&["@end"], &[])?; - self.consume_end("@define")?; + self.consume_end("@define", offset)?; self.depth -= 1; Ok(Node::Define(DefineBlock { diff --git a/crates/mds-core/src/parser_tests.rs b/crates/mds-core/src/parser_tests.rs index c47bcfff..d70d6ffe 100644 --- a/crates/mds-core/src/parser_tests.rs +++ b/crates/mds-core/src/parser_tests.rs @@ -2,7 +2,7 @@ use super::helpers::*; use super::*; -use crate::ast::{Arg, ExportDirective, Expr, ImportDirective}; +use crate::ast::{Arg, Condition, ExportDirective, Expr, ImportDirective, Node}; use crate::lexer::tokenize; use crate::limits::MAX_DOT_SEGMENTS; @@ -1587,7 +1587,8 @@ fn parse_elseif_call_condition() { let module = parse_with_ctx(&tokens, "", "").unwrap(); if let Node::If(block) = &module.body[0] { assert_eq!(block.elseif_branches.len(), 1); - let (cond, _) = &block.elseif_branches[0]; + let branch = &block.elseif_branches[0]; + let cond = &branch.condition; assert!( matches!(cond, Condition::Eq(Expr::Call { name, .. }, Expr::StringLiteral(s)) if name == "lower" && s == "val"), @@ -1923,6 +1924,127 @@ fn parse_elseif_unterminated_string_error() { ); } +// ── ElseifBranch.offset and IfBlock.else_offset ────────────────────────────── + +/// ElseifBranch.offset is captured at the @elseif directive position. +/// +/// Source layout (all ASCII bytes): +/// "@if a:\nA\n@elseif b:\nB\n@end\n" +/// ^0 ^7 ^9 +/// '@elseif b:' begins at byte offset 9. +#[test] +fn elseif_branch_offset_captured() { + let src = "@if a:\nA\n@elseif b:\nB\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let Node::If(block) = &module.body[0] else { + panic!("expected If node"); + }; + assert_eq!(block.elseif_branches.len(), 1); + let branch = &block.elseif_branches[0]; + assert_eq!( + branch.offset, 9, + "ElseifBranch.offset must point at the @elseif directive byte (9); got {}", + branch.offset + ); +} + +/// IfBlock.else_offset is Some when @else is present, pointing at the @else directive. +/// +/// Source layout: +/// "@if a:\nA\n@else:\nB\n@end\n" +/// ^0 ^7 ^9 +/// '@else:' begins at byte offset 9. +#[test] +fn else_offset_captured_when_present() { + let src = "@if a:\nA\n@else:\nB\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let Node::If(block) = &module.body[0] else { + panic!("expected If node"); + }; + assert_eq!( + block.else_offset, + Some(9), + "IfBlock.else_offset must be Some(9) when @else is at byte 9; got {:?}", + block.else_offset + ); +} + +/// IfBlock.else_offset is None when there is no @else clause. +#[test] +fn else_offset_absent_when_no_else() { + let src = "@if a:\nA\n@end\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let module = parse_with_ctx(&tokens, "test.mds", src).unwrap(); + let Node::If(block) = &module.body[0] else { + panic!("expected If node"); + }; + assert_eq!( + block.else_offset, None, + "IfBlock.else_offset must be None when no @else present" + ); +} + +// ── Unclosed-block error spans ──────────────────────────────────────────────── + +/// Unclosed @if block produces a syntax error anchored at the @if opener. +/// +/// Before consume_end received opener_offset, unclosed-block errors were +/// anchored at EOF (offset 0 / no span). After the change the error must +/// carry a span at the @if opener so users see the unclosed line. +#[test] +fn unclosed_if_error_anchored_at_opener() { + let src = "@if x:\nhello\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let err = parse_with_ctx(&tokens, "test.mds", src) + .unwrap_err() + .serialize(); + assert!( + err.message.contains("unclosed") || err.message.contains("@end"), + "error message should mention unclosed block or @end; got: {}", + err.message + ); + let span = err.span.expect("unclosed @if error must carry a span"); + assert_eq!( + span.offset, 0, + "unclosed @if error span must be at the @if opener (byte 0); got offset {}", + span.offset + ); +} + +/// Unclosed @for block produces a syntax error anchored at the @for opener. +#[test] +fn unclosed_for_error_anchored_at_opener() { + let src = "@for x in items:\nhello\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let err = parse_with_ctx(&tokens, "test.mds", src) + .unwrap_err() + .serialize(); + let span = err.span.expect("unclosed @for error must carry a span"); + assert_eq!( + span.offset, 0, + "unclosed @for error span must be at the @for opener (byte 0); got offset {}", + span.offset + ); +} + +/// Unclosed @define block produces a syntax error anchored at the @define opener. +#[test] +fn unclosed_define_error_anchored_at_opener() { + let src = "@define greet(name):\nhello {name}\n"; + let tokens = tokenize(src, "test.mds").unwrap(); + let err = parse_with_ctx(&tokens, "test.mds", src) + .unwrap_err() + .serialize(); + let span = err.span.expect("unclosed @define error must carry a span"); + assert_eq!( + span.offset, 0, + "unclosed @define error span must be at the @define opener (byte 0); got offset {}", + span.offset + ); +} + // ── @for unterminated string error ─────────────────────────────────────────── #[test] diff --git a/crates/mds-core/src/resolver.rs b/crates/mds-core/src/resolver.rs index 082e0055..96fe96ae 100644 --- a/crates/mds-core/src/resolver.rs +++ b/crates/mds-core/src/resolver.rs @@ -198,6 +198,15 @@ const MAX_IMPORT_DEPTH: usize = 64; /// The value `""` surfaces in miette diagnostic output (e.g. `:3:1`). const SOURCE_LABEL: &str = ""; +/// Warning emitted when `source_map: true` is used with a messages-mode template. +/// +/// Source maps operate on a flat text stream; messages-mode boundaries +/// (`@message` blocks) are not representable in the SMv3 segment model. +/// The warning is surface-neutral (no mention of specific option names or APIs). +const MSG_MODE_SOURCE_MAP_WARNING: &str = + "source maps are not supported for messages-mode templates (@message blocks); \ + no source map will be generated"; + /// Module cache to avoid re-resolving the same file or virtual key. /// /// Supports multiple filesystem backends via the [`FileSystem`] trait. @@ -679,6 +688,8 @@ impl ModuleCache { } let region_output = if let Some(builder) = current_map.take() { + // builder.current_src was set to origin's source index above; + // evaluate_with_map_seeded derives file/source from it (issue #58). let (region_out, returned_builder, iters, bytes) = evaluate_with_map_seeded( nodes, scope, @@ -692,7 +703,13 @@ impl ModuleCache { current_map = Some(returned_builder); region_out } else { - evaluate(nodes, scope, warnings)? + evaluate( + nodes, + scope, + warnings, + origin.file.as_ref(), + origin.source.as_ref(), + )? }; // PF-004: cumulative size guard — same limit as the per-node check. @@ -790,15 +807,11 @@ impl ModuleCache { } = components; if has_message_block(&final_body) { - // AC-FUNC-07: source_map=true is incompatible with messages-mode output. + // AC-FUNC-07: source_map=true is incompatible with messages-mode templates. // The evaluator only has text-stream semantics; messages boundaries don't // have stable byte offsets relative to the source. Degrade gracefully. if opts.source_map { - warnings.push( - "source_map: true is not supported for messages-mode templates \ - (@message blocks); source_map will be None" - .to_string(), - ); + warnings.push(MSG_MODE_SOURCE_MAP_WARNING.to_string()); } let messages = evaluate_messages_intrinsic( &final_body, @@ -833,7 +846,14 @@ impl ModuleCache { None => (raw, None), } } else { - (evaluate(&final_body, &mut scope, warnings)?, None) + // `final_body` is spliced from base-skeleton nodes (base-relative + // offsets) and child block overrides (child-relative offsets), so no + // single source can attribute every node's offset. Pass empty file/source + // so `build_type_mismatch` degrades a `type_mismatch` to spanless rather + // than anchoring a base-relative offset against the child source (ADR-005 + // "degrade rather than mis-attribute"). The source-map branch above keeps + // spans correct by evaluating per-region with each region's own origin. + (evaluate(&final_body, &mut scope, warnings, "", "")?, None) }; let body_clean = crate::clean_output(&body_raw); @@ -842,6 +862,26 @@ impl ModuleCache { let fm_prefix_len = final_str.len() - body_clean_len; let source_map = map_out.map(|b| b.finalize(&body_raw, &final_str, fm_prefix_len, None)); + // Step 5 — single choke-point (PF-005 / PF-004 / ADR-005): + // relativize ALL sources[] entries so no absolute path can leak into + // the published map. Unconditional — never opt-in, never debug_assert. + // + // Defense-in-depth: establish root from base_dir if it was not set by + // the entry-point normalize() / set_root() call (guards against a future + // alternate code path that bypasses root establishment — PF-004 shape). + // No-op for VirtualFs: its source_root() always returns None regardless. + if self.fs.source_root().is_none() && !ctx.base_dir.is_empty() { + let _ = self.fs.set_root(ctx.base_dir); + } + let source_map = source_map.map(|mut sm| { + let root_str = self.fs.source_root(); + let root = root_str.as_deref().map(std::path::Path::new); + let base = opts.source_map_base.as_deref(); + for src in &mut sm.sources { + *src = crate::source_path::relativize_source(src, base, root); + } + sm + }); return Ok((crate::CompiledOutput::Markdown(final_str), source_map)); } @@ -862,13 +902,9 @@ impl ModuleCache { validator::validate(&module.body, &mut scope, ctx.file_str, ctx.source)?; if has_message_block(&module.body) { - // AC-FUNC-07: source_map=true is incompatible with messages-mode output. + // AC-FUNC-07: source_map=true is incompatible with messages-mode templates. if opts.source_map { - warnings.push( - "source_map: true is not supported for messages-mode templates \ - (@message blocks); source_map will be None" - .to_string(), - ); + warnings.push(MSG_MODE_SOURCE_MAP_WARNING.to_string()); } let messages = evaluate_messages_intrinsic( &module.body, @@ -886,13 +922,18 @@ impl ModuleCache { } let (body_raw, map_out) = if opts.source_map { + // Builder seeds current_src=0 pointing to ctx.file_str/ctx.source; + // evaluate_with_map derives file/source from builder (issue #58). let builder = crate::sourcemap::MapBuilder::new(ctx.file_str.to_string(), ctx.source.to_string()); let (raw, returned) = evaluate_with_map(&module.body, &mut scope, warnings, builder)?; // AC-PERF-03 + AC-SEC-04: degrade if cap hit or sourcesContent too large. apply_map_degradation(raw, returned, opts, warnings) } else { - (evaluate(&module.body, &mut scope, warnings)?, None) + ( + evaluate(&module.body, &mut scope, warnings, ctx.file_str, ctx.source)?, + None, + ) }; let body_clean = crate::clean_output(&body_raw); @@ -900,6 +941,26 @@ impl ModuleCache { let final_str = crate::prepend_frontmatter(raw_frontmatter.as_deref(), body_clean); let fm_prefix_len = final_str.len() - body_clean_len; let source_map = map_out.map(|b| b.finalize(&body_raw, &final_str, fm_prefix_len, None)); + // Step 5 — single choke-point (PF-005 / PF-004 / ADR-005): + // relativize ALL sources[] entries so no absolute path can leak into + // the published map. Unconditional — never opt-in, never debug_assert. + // + // Defense-in-depth: establish root from base_dir if it was not set by + // the entry-point normalize() / set_root() call (guards against a future + // alternate code path that bypasses root establishment — PF-004 shape). + // No-op for VirtualFs: its source_root() always returns None regardless. + if self.fs.source_root().is_none() && !ctx.base_dir.is_empty() { + let _ = self.fs.set_root(ctx.base_dir); + } + let source_map = source_map.map(|mut sm| { + let root_str = self.fs.source_root(); + let root = root_str.as_deref().map(std::path::Path::new); + let base = opts.source_map_base.as_deref(); + for src in &mut sm.sources { + *src = crate::source_path::relativize_source(src, base, root); + } + sm + }); Ok((crate::CompiledOutput::Markdown(final_str), source_map)) } @@ -991,6 +1052,7 @@ impl ModuleCache { let (prompt_body, prompt_map) = if self.source_map_mode && prompt_exported { let builder = crate::sourcemap::MapBuilder::new(ctx.file_str.to_string(), ctx.source.to_string()); + // evaluate_with_map derives file/source from builder.current_src (issue #58). let (body_raw, returned) = evaluate_with_map(&module.body, &mut scope, warnings, builder)?; let body = (!body_raw.trim().is_empty()).then_some(body_raw); @@ -1002,7 +1064,7 @@ impl ModuleCache { let fmap = if returned.segments_dropped { warnings.push(format!( "source map segment cap ({} segments) exceeded in imported module '{}'; \ - source_map will be None for this compilation", + no source map will be generated", crate::limits::MAX_SOURCEMAP_SEGMENTS, ctx.file_str, )); @@ -1024,7 +1086,7 @@ impl ModuleCache { }; (body, fmap) } else { - let body_raw = evaluate(&module.body, &mut scope, warnings)?; + let body_raw = evaluate(&module.body, &mut scope, warnings, ctx.file_str, ctx.source)?; let body = (!body_raw.trim().is_empty()).then_some(body_raw); (body, None) }; @@ -1316,7 +1378,13 @@ impl ModuleCache { merged_frontmatter, } = components; - let prompt_body = evaluate(&final_body, &mut scope, warnings)?; + // `final_body` splices base-skeleton nodes (base-relative offsets) with child + // block overrides (child-relative offsets); a single `ctx.source` cannot attribute + // both. Pass empty file/source so a `type_mismatch` in an inherited condition + // degrades to spanless instead of mis-attributing a base-relative offset onto the + // child source (ADR-005 "degrade rather than mis-attribute"). Per-region source + // attribution for `@extends` is deferred to S8; spanless is the safe interim. + let prompt_body = evaluate(&final_body, &mut scope, warnings, "", "")?; let prompt_body = (!prompt_body.trim().is_empty()).then_some(prompt_body); Ok(ResolvedModule { @@ -1635,7 +1703,8 @@ impl ModuleCache { defs.explicit_exports.insert(name.clone()); } ExportDirective::Wildcard { - path: import_path, .. + path: import_path, + offset, } => { // Re-export all exports from the target module. These are // available to importers but NOT in the current file's scope. @@ -1647,9 +1716,16 @@ impl ModuleCache { ctx.runtime_vars, warnings, )?; + let line_len = line_len_at(ctx.source, *offset); for (name, func) in source_module.get_all_exports() { if defs.functions.contains_key(&name) { - return Err(MdsError::name_collision(name)); + return Err(MdsError::name_collision_at( + &name, + ctx.file_str, + ctx.source, + *offset, + line_len, + )); } defs.functions.insert(name.clone(), func); defs.explicit_exports.insert(name); @@ -1669,7 +1745,13 @@ impl ModuleCache { warnings: &mut Vec, ) -> Result<(), MdsError> { if scope.get_namespace(alias).is_some() { - return Err(MdsError::name_collision(alias.to_string())); + return Err(MdsError::name_collision_at( + alias, + ctx.file_str, + ctx.source, + offset, + alias.len(), + )); } let resolved = self .resolve_import_from(ctx.base_dir, path, ctx.runtime_vars, warnings) @@ -1757,9 +1839,16 @@ impl ModuleCache { .map_err(|e| attach_import_span(e, path, ctx.file_str, ctx.source, offset))?; // Per spec: only functions and the prompt body are imported via merge. // Frontmatter variables from the imported module are NOT brought into scope. + let line_len = line_len_at(ctx.source, offset); for (name, func) in resolved.get_all_exports() { if scope.get_function(&name).is_some() { - return Err(MdsError::name_collision(name)); + return Err(MdsError::name_collision_at( + &name, + ctx.file_str, + ctx.source, + offset, + line_len, + )); } scope.set_function(&name, func); } @@ -1781,9 +1870,7 @@ impl ModuleCache { let resolved = self .resolve_import_from(ctx.base_dir, path, ctx.runtime_vars, warnings) .map_err(|e| attach_import_span(e, path, ctx.file_str, ctx.source, offset))?; - let line_len = ctx.source[offset..] - .find('\n') - .unwrap_or(ctx.source[offset..].len()); + let line_len = line_len_at(ctx.source, offset); let not_exported = |name: &str| { MdsError::import_error_at( format!("'{name}' is not exported from '{path}'"), @@ -1988,7 +2075,7 @@ fn apply_map_degradation( if builder.segments_dropped { warnings.push(format!( "source map segment cap ({} segments) exceeded; \ - source_map will be None for this compilation", + no source map will be generated", crate::limits::MAX_SOURCEMAP_SEGMENTS, )); return (raw, None); @@ -2020,7 +2107,7 @@ fn has_message_block(nodes: &[Node]) -> bool { || block .elseif_branches .iter() - .any(|(_, body)| has_message_block(body)) + .any(|branch| has_message_block(&branch.body)) || block .else_body .as_deref() @@ -2442,6 +2529,22 @@ fn parse_frontmatter_mapping( } } +/// Returns the byte count from `offset` to just before the next `\n` (or +/// to the end of the string when there is no newline). Returns `0` when +/// `offset` is out of bounds or falls on a non-UTF-8 char boundary so callers +/// degrade gracefully rather than panic (ADR-005 — degrade rather than +/// mis-attribute; consistent with the `is_char_boundary` guard in +/// `build_type_mismatch` in evaluator.rs). +fn line_len_at(source: &str, offset: usize) -> usize { + if source.is_char_boundary(offset) { + source[offset..] + .find('\n') + .unwrap_or(source[offset..].len()) + } else { + 0 + } +} + #[cfg(test)] #[path = "resolver_tests.rs"] mod tests; diff --git a/crates/mds-core/src/resolver_tests.rs b/crates/mds-core/src/resolver_tests.rs index 30c4f8da..5864347d 100644 --- a/crates/mds-core/src/resolver_tests.rs +++ b/crates/mds-core/src/resolver_tests.rs @@ -3174,3 +3174,76 @@ fn parent_dir_drives_nested_file_import_resolution() { "nested import must produce expected output (parent_dir resolved sub/ correctly): {output}" ); } + +// ── line_len_at — UTF-8 char-boundary safety (issue #29) ───────────────────── +// +// These tests verify `line_len_at` degrades to 0 rather than panicking on +// non-boundary or out-of-bounds offsets, matching the guard in +// `build_type_mismatch` (evaluator.rs) per ADR-005. + +#[test] +fn line_len_at_out_of_bounds_returns_zero() { + // offset past the end of source must return 0, never panic. + assert_eq!( + line_len_at("hello", 10), + 0, + "out-of-bounds offset must return 0" + ); +} + +#[test] +fn line_len_at_non_boundary_multibyte_returns_zero() { + // "é" is a 2-byte UTF-8 sequence (0xC3 0xA9). + // offset 1 is the second byte — not a char boundary. + let s = "é\nmore"; + assert_eq!( + line_len_at(s, 1), + 0, + "offset inside a multi-byte char must return 0, not panic" + ); +} + +#[test] +fn line_len_at_valid_boundary_returns_line_length() { + // "héllo\nworld": h=1, é=2, l=1, l=1, o=1 → 6 bytes before '\n'. + let s = "héllo\nworld"; + assert_eq!( + line_len_at(s, 0), + 6, + "offset 0 must return bytes to the first newline" + ); +} + +#[test] +fn line_len_at_mid_string_boundary() { + // offset 3 (after "hé" = 3 bytes) is on a char boundary. + // remaining: "llo\nworld" → 3 bytes to '\n'. + let s = "héllo\nworld"; + assert_eq!( + line_len_at(s, 3), + 3, + "offset at a mid-string char boundary must return bytes to the next newline" + ); +} + +#[test] +fn line_len_at_no_newline_returns_remaining_len() { + let s = "hello"; + assert_eq!( + line_len_at(s, 0), + 5, + "when no newline exists, must return the remaining length" + ); +} + +#[test] +fn line_len_at_offset_at_end_returns_zero() { + // offset == source.len() is a valid char boundary (end of string). + // The slice [len..] is empty, so find('\n') is None and .len() is 0. + let s = "hello"; + assert_eq!( + line_len_at(s, 5), + 0, + "offset == source.len() must return 0 (empty trailing slice)" + ); +} diff --git a/crates/mds-core/src/source_path.rs b/crates/mds-core/src/source_path.rs new file mode 100644 index 00000000..18fa3e1f --- /dev/null +++ b/crates/mds-core/src/source_path.rs @@ -0,0 +1,933 @@ +//! Source-path relativization for Source Map v3 `sources[]` entries. +//! +//! Provides a single choke-point function [`relativize_source`] that converts +//! an absolute or relative source path into a safe, map-relative or root- +//! relative form for embedding in a Source Map v3 document. +//! +//! # Security invariant (PF-005 / ADR-005) +//! +//! The guard is **unconditional and runtime-enforced** — no path that escapes +//! the project root, carries an absolute prefix, or embeds filesystem layout +//! information can survive into `sources[]`. The invariant holds in release +//! builds (never `debug_assert!`). +//! +//! # Single choke-point (PF-004) +//! +//! **All** `sources[]` relativization MUST flow through this function. There +//! is exactly one enforcement point so no alternate code path can silently +//! bypass the guard. + +use std::path::Path; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Relativize a single `sources[]` entry for a Source Map v3 document. +/// +/// # Parameters +/// +/// - `source` — raw source key from [`crate::sourcemap::MapBuilder`] +/// (`sources[i]`), typically an absolute canonical path on native or a +/// virtual key string on WASM. +/// - `base` — directory that the source map file will be written to (the +/// "map directory"). When `None`, paths are relativized against `root` +/// instead (binding surfaces — napi / Python — that do not write a `.map` +/// file). +/// - `root` — established project root (from +/// [`crate::fs::FileSystem::source_root`]). `None` for virtual / in-memory +/// filesystems ([`crate::fs::VirtualFs`] / WASM) where there is no +/// containment concept; separator unification plus the escape / absolute / +/// drive-qualified guards are applied in that case. **This MUST preserve +/// today's WASM `sources[]` output byte-for-byte** (ADR-005) — it does, +/// because virtual keys are relative by construction, so no guard fires. +/// +/// # Invariants (PF-005 / ADR-005) +/// +/// - The returned string is **never** an absolute path. +/// - The returned string is **never** drive-qualified. +/// - For non-fallback, non-sentinel outputs with `root = Some(_)`: +/// `normalize(b.join(result))` equals `normalize(source)` and is contained +/// within `root` (round-trip re-check in step 9 of the guard algorithm). +/// +/// # Sentinels +/// +/// Strings delimited by `<` and `>` (e.g. ``) pass through verbatim — +/// they are diagnostic labels, not filesystem paths. +/// +/// # Guard algorithm (order matters — each step closes a specific hole) +/// +/// 1. Sentinel: source starts `<` and ends `>` → return verbatim. +/// 2. Unify separators FIRST: `s = source.replace('\\', "/")` — closes the +/// backslash-on-Unix bypass (`..\..\` not caught as `../..` otherwise). +/// 3. Strip verbatim prefixes on unified string: `//?/UNC/` then `//?/`. +/// 4. Classify absolute: leading `/`, or drive-qualified (`C:\`, `C:/`, `C:`). +/// 5. Lexically normalize into components (resolve `.`, `..`) — closes +/// `./../../` and interior-`..` bypasses. +/// 6. If `root = None`: absolute / drive-qualified / lexical-escape checks all +/// degrade to basename; otherwise return the unified path. +/// 7. If not absolute: resolve against `base` (or `root`) before containment. +/// 8. Containment: component-wise descendant of `root`? If not → basename. +/// 9. Emit relative to `b` (where `b = base` if `base` is inside `root`, else +/// `b = root`). Round-trip re-check before returning; on failure → basename. +/// 10. Basename fallback: last non-`..` component of the NORMALIZED components +/// (never the raw string — see note in `basename_fallback`). Safety +/// re-check; if that fails → `"source"`. +pub fn relativize_source(source: &str, base: Option<&Path>, root: Option<&Path>) -> String { + // Step 1: sentinel — display labels are not filesystem paths. + if source.starts_with('<') && source.ends_with('>') { + return source.to_string(); + } + + // Step 2: unify separators FIRST — closes the backslash-on-Unix bypass. + // `..\..\secret.mds` becomes `../../secret.mds` before any other check, + // so the leading-`..` detector and apply_relative both see the real structure. + let unified = source.replace('\\', "/"); + + // Step 3: strip Windows verbatim-prefix variants (PF-003 / AC-SEC-01). + let stripped = unified + .strip_prefix("//?/UNC/") + .or_else(|| unified.strip_prefix("//?/")) + .unwrap_or(&unified); + + if stripped.is_empty() { + return "source".to_string(); + } + + // Step 4: classify absolute. + let is_abs = stripped.starts_with('/') || is_drive_qualified(stripped); + + // Step 5: lexically normalize into components, resolving `.` and `..`. + // This closes the `./../../` (leading-dot-slash) and interior-dot-dot bypasses. + let norm_comps: Vec = if is_abs { + normalize_abs(stripped) + } else { + normalize_rel(stripped) + }; + + // Step 6: root = None branch — VirtualFs / WASM. + // No containment concept, so containment (step 8) and map-relative emission + // (step 9) do not apply; only separator unification and the escape/absolute + // guards run. Preserves today's WASM `sources[]` output byte-for-byte for + // every legitimate virtual key (ADR-005) — virtual keys are relative by + // construction (`buildModulesMap` emits project-root-relative slash paths), + // so neither guard below can fire on the shipped WASM path. + let Some(root) = root else { + // An absolute or drive-qualified key still encodes filesystem layout + // even though there is no root to contain it against. Degrade to the + // basename so the "never absolute / never drive-qualified" invariant + // documented above holds on THIS branch too (PF-005: a guarantee that + // is real only where a root happens to be established is not a + // guarantee — `FileSystem::source_root` is a defaulted method returning + // `None`, so any impl that does not override it lands here). + if is_abs { + return basename_fallback(&norm_comps); + } + // A relative path whose first component is `..` escapes the virtual root. + if norm_comps.first().map(String::as_str) == Some("..") { + return basename_fallback(&norm_comps); + } + let joined = norm_comps.join("/"); + return if joined.is_empty() { + "source".to_string() + } else { + joined + }; + }; + + // Normalize root components (absolute component list). + let root_unified = path_to_unified(root); + let root_comps = normalize_abs(&root_unified); + + // Step 7: if not absolute, resolve against base (or root) before containment. + let abs_comps: Vec = if is_abs { + norm_comps.clone() + } else { + let anchor = match base { + Some(b) => normalize_abs(&path_to_unified(b)), + None => root_comps.clone(), + }; + match apply_relative(anchor, &norm_comps) { + Some(comps) => comps, + // Path escapes above anchor root → basename fallback. + None => return basename_fallback(&norm_comps), + } + }; + + // Step 8: containment — source must be a component-wise descendant of root. + // (Case-folded on Windows via `starts_with_comps`.) + if !starts_with_comps(&abs_comps, &root_comps) { + return basename_fallback(&abs_comps); + } + + // Step 9: emit. + // + // `b = base` if `base` is also inside root, else `b = root`. + // This handles the "source under root but output outside it" case: emit + // root-relative rather than degrading to a basename. It also makes CLI + // and binding surfaces run the **same algorithm** — bindings are permanently + // in the base-absent case and always receive root-relative paths. + let b_comps = match base { + Some(b) => { + let bc = normalize_abs(&path_to_unified(b)); + if starts_with_comps(&bc, &root_comps) { + bc + } else { + root_comps.clone() + } + } + None => root_comps.clone(), + }; + + let result = component_diff(&b_comps, &abs_comps); + + // Round-trip re-check (step 9 guard, PF-005): + // normalize(b.join(result)) == normalize(source) + // AND result is not absolute + // AND result is not drive-qualified + // + // `apply_relative` receives `b_comps` (consumed here, no longer needed). + let result_parts: Vec = result.split('/').map(str::to_string).collect(); + let round_trip = apply_relative(b_comps, &result_parts); + if round_trip.as_deref() != Some(abs_comps.as_slice()) + || result.starts_with('/') + || is_drive_qualified(&result) + { + return basename_fallback(&abs_comps); + } + + result +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// True if `s` looks like a Windows drive-qualified path in any of the three +/// forms that can survive separator unification. +/// +/// Ported verbatim from `crates/mds-cli/src/build.rs` (SEC-2), including all +/// three disjuncts: +/// - `:\\` — classic backslash-qualified drive path. +/// - `:/` — forward-slash-normalised drive path (SEC-2 gap). +/// - leading `:` — bare drive designator without a separator. +fn is_drive_qualified(s: &str) -> bool { + s.contains(":\\") + || s.contains(":/") + || (s.len() >= 2 + && (s.as_bytes()[0] as char).is_ascii_alphabetic() + && s.as_bytes()[1] == b':') +} + +/// Convert a `Path` to a `/`-unified string. +fn path_to_unified(p: &Path) -> String { + let s = p.to_str().unwrap_or("").replace('\\', "/"); + // Strip Windows verbatim-prefix variants (same normalization that + // `relativize_source` applies to source strings in step 3), so that the + // root component list is comparable to the stripped source component list. + // Without this, a canonicalize()-produced root `\\?\C:\proj` becomes + // `//?/C:/proj` after backslash unification and `normalize_abs` yields + // ["?", "C:", "proj"], which never matches source components ["C:", "proj", ...] + // → containment always fails → every source degrades to its basename. + let stripped = s + .strip_prefix("//?/UNC/") + .or_else(|| s.strip_prefix("//?/")) + .unwrap_or(&s); + stripped.to_string() +} + +/// Normalize an **absolute** path string into a component list. +/// +/// Strips the leading `/` (Unix absolute) or keeps the drive prefix as the +/// first component (Windows absolute). Resolves `.` (skip) and `..` (pop; +/// no-op when already at root). +fn normalize_abs(s: &str) -> Vec { + let rest = s.strip_prefix('/').unwrap_or(s); + let mut comps: Vec = Vec::new(); + for part in rest.split('/') { + match part { + "" | "." => {} + ".." => { + comps.pop(); // no-op when empty (already at root) + } + p => comps.push(p.to_string()), + } + } + comps +} + +/// Normalize a **relative** path string into a component list. +/// +/// Preserves leading `..` segments — they are meaningful relative to an +/// anchor directory and are consumed by [`apply_relative`]. Resolves +/// interior `..` against preceding non-`..` segments where possible. +fn normalize_rel(s: &str) -> Vec { + let mut comps: Vec = Vec::new(); + for part in s.split('/') { + match part { + "" | "." => {} + ".." => { + // If the stack is empty or already ends in `..`, preserve escape. + if comps.last().map(String::as_str) == Some("..") || comps.is_empty() { + comps.push("..".to_string()); + } else { + comps.pop(); + } + } + p => comps.push(p.to_string()), + } + } + comps +} + +/// Apply normalized relative components (`rel_parts`) on top of an anchor +/// (an already-normalized absolute component list). +/// +/// Returns `None` if the relative path escapes above the anchor root +/// (i.e. has more `..` segments than the anchor has components). The caller +/// treats `None` as a "path escapes" condition and falls back to the basename. +fn apply_relative(mut anchor: Vec, rel_parts: &[String]) -> Option> { + for c in rel_parts { + match c.as_str() { + "" | "." => {} // Skip empty segments and current-directory markers. + ".." => { + if anchor.is_empty() { + return None; // Escapes above root. + } + anchor.pop(); + } + p => anchor.push(p.to_string()), + } + } + Some(anchor) +} + +/// True when `path` component-wise starts with `prefix`. +/// +/// On Windows, component comparison is ASCII-case-insensitive. +fn starts_with_comps(path: &[String], prefix: &[String]) -> bool { + if path.len() < prefix.len() { + return false; + } + #[cfg(windows)] + return path[..prefix.len()] + .iter() + .zip(prefix.iter()) + .all(|(a, b)| a.eq_ignore_ascii_case(b)); + #[cfg(not(windows))] + path[..prefix.len()] + .iter() + .zip(prefix.iter()) + .all(|(a, b)| a == b) +} + +/// Compute the `/`-separated relative path from `from` to `to`. +/// +/// Both inputs are already-normalized absolute component lists sharing the +/// same root (the caller has verified containment via [`starts_with_comps`]). +/// +/// Ported from `crates/mds-cli/src/build.rs::relative_path` but the +/// absolute-path failure fallback is replaced with the basename fallback to +/// close that leak path (build.rs:978 bug class). +fn component_diff(from: &[String], to: &[String]) -> String { + #[cfg(windows)] + let common = from + .iter() + .zip(to.iter()) + .take_while(|(a, b)| a.eq_ignore_ascii_case(b)) + .count(); + #[cfg(not(windows))] + let common = from + .iter() + .zip(to.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let ups = from.len() - common; + let downs = &to[common..]; + let mut parts: Vec<&str> = Vec::with_capacity(ups + downs.len()); + parts.extend(std::iter::repeat_n("..", ups)); + for c in downs { + parts.push(c.as_str()); + } + if parts.is_empty() { + ".".to_string() + } else { + parts.join("/") + } +} + +/// Basename fallback (algorithm step 10, PF-005). +/// +/// Returns the last non-`..` component of `comps`, or `"source"` if the +/// component fails the safety re-check (empty, contains `/`, is `.` / `..`, +/// or is drive-qualified). +/// +/// **NEVER derived from the raw source string** — using the raw string's +/// `file_name()` is the exact bug class that `build.rs:978` had: on Unix, +/// `..\..\Users\alice\secret.mds` has no `/`-based `file_name()` component, +/// so `Path::new(raw).file_name()` returns the whole string verbatim, leaking +/// the path instead of extracting just `"secret.mds"`. Using the NORMALIZED +/// component list avoids this. +fn basename_fallback(comps: &[String]) -> String { + let last = comps + .iter() + .rev() + .find(|s| s.as_str() != "..") + .map(String::as_str) + .unwrap_or("source"); + // Safety re-check: non-empty, no embedded '/', not "." or "..", not drive-qualified. + if last.is_empty() + || last.contains('/') + || last == "." + || last == ".." + || is_drive_qualified(last) + { + "source".to_string() + } else { + last.to_string() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn p(s: &'static str) -> &'static Path { + Path::new(s) + } + + /// Assert that `out` satisfies the basic output invariants for non-sentinel results. + fn check_output_invariants(out: &str, source: &str) { + let is_sentinel = out.starts_with('<') && out.ends_with('>'); + if !is_sentinel { + assert!( + !out.starts_with('/'), + "output must not be absolute (source={source:?}): got {out:?}", + ); + assert!( + !is_drive_qualified(out), + "output must not be drive-qualified (source={source:?}): got {out:?}", + ); + } + } + + // ── Three security bypasses — must be RED before implementation is complete ── + + /// Backslash-on-Unix bypass: `..\..\` looks like a filename on Unix without + /// prior separator unification, leaking the raw string through the old guard. + #[test] + fn bypass_backslash_on_unix() { + let out = relativize_source( + r"..\..\Users\alice\secret.mds", + Some(p("/proj")), + Some(p("/proj")), + ); + assert_eq!( + out, "secret.mds", + "backslash-on-Unix must degrade to basename" + ); + check_output_invariants(&out, r"..\..\Users\alice\secret.mds"); + } + + /// Leading dot-slash bypass: `./../../` resolves to an upward escape. + #[test] + fn bypass_leading_dot_slash() { + let out = relativize_source("./../../etc/passwd", Some(p("/proj")), Some(p("/proj"))); + assert_eq!( + out, "passwd", + "leading-dot-slash escape must degrade to basename" + ); + check_output_invariants(&out, "./../../etc/passwd"); + } + + /// Interior dot-dot bypass: `/proj/a/../../etc/passwd` normalizes to `/etc/passwd` + /// which is outside root `/proj`. + #[test] + fn bypass_interior_dot_dot() { + let out = relativize_source( + "/proj/a/../../etc/passwd", + Some(p("/proj/build")), + Some(p("/proj")), + ); + assert_eq!( + out, "passwd", + "interior-dot-dot escape must degrade to basename" + ); + check_output_invariants(&out, "/proj/a/../../etc/passwd"); + } + + // ── Core rule pair — both rows MUST hold simultaneously ── + + /// Source in /proj/src, map in /proj/build, root /proj → map-relative `../src/a.mds`. + #[test] + fn core_rule_map_relative() { + let out = relativize_source("/proj/src/a.mds", Some(p("/proj/build")), Some(p("/proj"))); + assert_eq!(out, "../src/a.mds"); + check_output_invariants(&out, "/proj/src/a.mds"); + } + + /// Source outside root → basename fallback. + /// (root = /proj/build, source = /proj/src/a.mds — source not under root) + #[test] + fn core_rule_source_outside_root() { + let out = relativize_source( + "/proj/src/a.mds", + Some(p("/proj/build")), + Some(p("/proj/build")), + ); + assert_eq!(out, "a.mds"); + check_output_invariants(&out, "/proj/src/a.mds"); + } + + // ── Additional matrix rows ── + + #[test] + fn sentinel_angle_bracket_passes_through_verbatim() { + let out = relativize_source("", Some(p("/proj")), Some(p("/proj"))); + assert_eq!(out, ""); + } + + #[test] + fn sentinel_source_label_no_root() { + let out = relativize_source("", None, None); + assert_eq!(out, ""); + } + + /// Binding (napi / Python) surfaces pass `base = None` → root-relative output. + #[test] + fn binding_no_base_emits_root_relative() { + let out = relativize_source("/proj/src/a.mds", None, Some(p("/proj"))); + assert_eq!(out, "src/a.mds"); + check_output_invariants(&out, "/proj/src/a.mds"); + } + + /// Base directory is outside root → fall back to root as anchor (not basename). + #[test] + fn base_outside_root_uses_root_as_anchor() { + let out = relativize_source("/proj/src/a.mds", Some(p("/other")), Some(p("/proj"))); + assert_eq!(out, "src/a.mds"); + check_output_invariants(&out, "/proj/src/a.mds"); + } + + /// Windows `\\?\` verbatim prefix is stripped before all other processing. + #[test] + fn verbatim_prefix_stripped() { + // After unification: `//?/C:/secret/foo.mds` → strip `//?/` → `C:/secret/foo.mds` + // Drive-qualified → abs_comps = ["C:", "secret", "foo.mds"] + // Root = /proj → comps ["proj"] → containment fails → basename. + let out = relativize_source(r"\\?\C:\secret\foo.mds", Some(p("/proj")), Some(p("/proj"))); + assert_eq!(out, "foo.mds"); + check_output_invariants(&out, r"\\?\C:\secret\foo.mds"); + } + + /// Forward-slash-normalised Windows drive path must degrade to basename (SEC-2). + #[test] + fn forward_slash_drive_path_degrades_to_filename() { + let out = relativize_source("C:/secret/foo.mds", Some(p("/proj")), Some(p("/proj"))); + assert_eq!(out, "foo.mds"); + check_output_invariants(&out, "C:/secret/foo.mds"); + } + + /// Cross-drive path: source on D:, root on C: → containment fails → basename. + #[test] + fn cross_drive_degrades_to_filename() { + let out = relativize_source("D:/other/file.mds", Some(p("C:/proj")), Some(p("C:/proj"))); + assert_eq!(out, "file.mds"); + check_output_invariants(&out, "D:/other/file.mds"); + } + + /// Windows verbatim UNC root (`\\?\C:\proj`) must relativize a co-located source + /// correctly. On Windows, `std::fs::canonicalize()` returns verbatim paths and + /// `NativeFs::source_root()` forwards that via `display()`. Without stripping + /// the `\\?\` prefix in `path_to_unified`, `normalize_abs` yields + /// `["?", "C:", "proj", ...]` which never matches the stripped source components + /// `["C:", "proj", ...]` → containment always fails → every entry degrades to + /// its basename (CF-SM2 failure on windows-latest). + #[test] + fn verbatim_root_relativizes_source_correctly() { + // Source also has verbatim prefix — step 3 strips it; root stripping is the fix. + let out = relativize_source( + r"\\?\C:\proj\src\entry.mds", + None, + Some(Path::new(r"\\?\C:\proj")), + ); + assert_eq!(out, "src/entry.mds"); + check_output_invariants(&out, r"\\?\C:\proj\src\entry.mds"); + } + + /// Same scenario with a nested import — two sources under a verbatim root. + #[test] + fn verbatim_root_nested_import_relativizes() { + let out = relativize_source( + r"\\?\C:\proj\partials\greeting.mds", + None, + Some(Path::new(r"\\?\C:\proj")), + ); + assert_eq!(out, "partials/greeting.mds"); + check_output_invariants(&out, r"\\?\C:\proj\partials\greeting.mds"); + } + + /// VirtualFs / WASM: `root = None` → pass-through with separator unification. + #[test] + fn root_none_pass_through_virtual_path() { + let out = relativize_source("src/templates/foo.mds", None, None); + assert_eq!(out, "src/templates/foo.mds"); + } + + /// VirtualFs / WASM: relative path that lexically escapes → basename. + #[test] + fn root_none_escape_degrades_to_basename() { + let out = relativize_source("../../escape/secret.mds", None, None); + assert_eq!(out, "secret.mds"); + check_output_invariants(&out, "../../escape/secret.mds"); + } + + /// `root = None` must NOT be an escape hatch around the never-absolute + /// invariant. `FileSystem::source_root` is a *defaulted* trait method + /// returning `None`, so an impl that forgets to override it lands on this + /// branch — it must still refuse to emit filesystem layout (PF-005). + #[test] + fn root_none_absolute_source_degrades_to_basename() { + let out = relativize_source("/Users/alice/proj/src/a.mds", None, None); + assert_eq!( + out, "a.mds", + "an absolute source with no root must degrade to its basename, \ + never echo the directory chain" + ); + check_output_invariants(&out, "/Users/alice/proj/src/a.mds"); + } + + /// Same as above for a drive-qualified key: without the guard the unified + /// string `C:/secret/foo.mds` was returned verbatim, which is exactly the + /// "never drive-qualified" invariant this module documents. + #[test] + fn root_none_drive_qualified_source_degrades_to_basename() { + let out = relativize_source(r"C:\secret\foo.mds", None, None); + assert_eq!(out, "foo.mds"); + check_output_invariants(&out, r"C:\secret\foo.mds"); + } + + /// The guards above must be inert for every *legitimate* virtual key. + /// `buildModulesMap` emits project-root-relative slash paths, so WASM + /// `sources[]` output stays byte-identical (ADR-005 byte-parity clause). + #[test] + fn root_none_relative_keys_are_unchanged_by_the_absolute_guard() { + for key in [ + "a.mds", + "src/a.mds", + "src/templates/deep/nested.mds", + "./src/a.mds", + ] { + let out = relativize_source(key, None, None); + let expected = key.strip_prefix("./").unwrap_or(key); + assert_eq!( + out, expected, + "legitimate virtual key {key:?} must pass through unchanged" + ); + } + } + + /// Degenerate: source = "/" → empty components → containment fails → "source". + #[test] + fn degenerate_slash_only() { + let out = relativize_source("/", Some(p("/proj")), Some(p("/proj"))); + assert_eq!(out, "source"); + check_output_invariants(&out, "/"); + } + + /// Degenerate: source = ".." → escapes anchor → basename of components → "source". + #[test] + fn degenerate_dot_dot() { + let out = relativize_source("..", Some(p("/proj")), Some(p("/proj"))); + // norm_comps = [".."], apply_relative(["proj"], [".."]) = Some([]) + // starts_with_comps([], ["proj"]) → false → basename_fallback([]) + // comps.last() = None → "source" + assert_eq!(out, "source"); + check_output_invariants(&out, ".."); + } + + /// Degenerate: empty source → "source". + #[test] + fn degenerate_empty_source() { + let out = relativize_source("", Some(p("/proj")), Some(p("/proj"))); + assert_eq!(out, "source"); + } + + // ── Source inside root at the same level as the map directory ── + + #[test] + fn source_in_same_dir_as_map() { + // Source and map both in /proj/build → relative = "out.mds" + let out = relativize_source( + "/proj/build/out.mds", + Some(p("/proj/build")), + Some(p("/proj")), + ); + assert_eq!(out, "out.mds"); + check_output_invariants(&out, "/proj/build/out.mds"); + } + + // ── Tests migrated from mds-cli/src/build.rs (AC-SEC-01) ────────────────── + // + // These tests were moved here when the CLI's `relativize_source_path` helper + // was deleted and its behaviour subsumed by this single choke-point (PF-004). + // Signatures are adapted to `relativize_source(source, base, root)`. + + /// Both source and map directory share a common ancestor → clean map-relative path. + /// This was the discriminating assertion that `a7ef84f` accidentally regressed. + #[test] + fn relativize_absolute_source_with_absolute_mapdir_is_clean_relative() { + let got = relativize_source("/proj/src/a.mds", Some(p("/proj/build")), Some(p("/proj"))); + assert_eq!( + got, "../src/a.mds", + "same-root absolute paths must relativize to a clean map-relative path" + ); + check_output_invariants(&got, "/proj/src/a.mds"); + } + + /// Source outside the project root with any base → basename fallback, never absolute. + /// (Adapted from the `relative_mapdir` variant: the new guard catches it regardless + /// of whether the old map_dir was relative or absolute.) + #[test] + fn relativize_absolute_source_with_relative_mapdir_never_leaks_absolute() { + // Source `/tmp/deep/nested/abs_input.mds` is not under root `/proj` → + // containment fails → basename fallback. + let got = relativize_source( + "/tmp/deep/nested/abs_input.mds", + Some(p("/proj/build")), + Some(p("/proj")), + ); + assert!(!got.starts_with('/'), "must not be absolute: {got}"); + assert!( + got.ends_with("abs_input.mds"), + "must still resolve to the source filename: {got}" + ); + check_output_invariants(&got, "/tmp/deep/nested/abs_input.mds"); + } + + /// Source outside root with no base (root-relative / binding mode) → + /// basename fallback, never absolute. + /// (Adapted from the `empty_mapdir` variant: `base = None` is the + /// canonical "no explicit map directory" encoding.) + #[test] + fn relativize_absolute_source_with_empty_mapdir_never_leaks_absolute() { + let got = relativize_source("/tmp/deep/abs_input.mds", None, Some(p("/proj"))); + assert!(!got.starts_with('/'), "must not be absolute: {got}"); + assert!( + got.ends_with("abs_input.mds"), + "must still resolve to the source filename: {got}" + ); + check_output_invariants(&got, "/tmp/deep/abs_input.mds"); + } + + /// Forward-slash-normalised Windows drive path must degrade to basename (SEC-2). + /// `C:/secret/foo.mds` is not seen as absolute by `Path::is_absolute()` on + /// Unix, so a separator-only check would pass it through unchanged. + #[test] + fn relativize_forward_slashed_drive_path_degrades_to_filename() { + let got = relativize_source( + "C:/secret/foo.mds", + Some(p("/proj/build")), + Some(p("/proj")), + ); + assert_eq!( + got, "foo.mds", + "forward-slashed drive path must degrade to filename" + ); + check_output_invariants(&got, "C:/secret/foo.mds"); + } + + /// A cross-drive path suffix (`../../D:/x.mds`) containing `:/` must be + /// caught and degrade to the bare filename (SEC-2 — the `:/` gap). + #[test] + fn relativize_cross_drive_relative_path_degrades_to_filename() { + let got = relativize_source("../../D:/x.mds", Some(p("/proj/build")), Some(p("/proj"))); + assert_eq!(got, "x.mds", "cross-drive :/ path must degrade to filename"); + check_output_invariants(&got, "../../D:/x.mds"); + } + + /// Lexically normalize `base/relative` without hitting the filesystem. + /// + /// Applies each `/`-delimited component of `relative` to `base`, resolving + /// `..` by popping and ignoring `.` / empty components. Used in the + /// round-trip property assertion below. + fn lexical_join(base: &Path, relative: &str) -> PathBuf { + let mut result = base.to_path_buf(); + for comp in relative.split('/') { + match comp { + "" | "." => {} + ".." => { + let _ = result.pop(); + } + c => result.push(c), + } + } + result + } + + /// For every test case, the output must never be absolute and never + /// drive-qualified, and for non-sentinel outputs the round-trip + /// `normalize(effective_b.join(out))` must stay inside `root`. + /// + /// The round-trip invariant is enforced in production at + /// `source_path.rs:191-197`; this matrix catches regressions there. + #[test] + fn property_outputs_never_absolute_or_drive_qualified() { + struct Case { + source: &'static str, + base: Option<&'static str>, + root: Option<&'static str>, + } + let cases: &[Case] = &[ + // Three security bypasses + Case { + source: r"..\..\Users\alice\secret.mds", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "./../../etc/passwd", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "/proj/a/../../etc/passwd", + base: Some("/proj/build"), + root: Some("/proj"), + }, + // Core rule pair + Case { + source: "/proj/src/a.mds", + base: Some("/proj/build"), + root: Some("/proj"), + }, + Case { + source: "/proj/src/a.mds", + base: Some("/proj/build"), + root: Some("/proj/build"), + }, + // Sentinels + Case { + source: "", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "", + base: None, + root: None, + }, + // Binding / base-outside-root + Case { + source: "/proj/src/a.mds", + base: None, + root: Some("/proj"), + }, + Case { + source: "/proj/src/a.mds", + base: Some("/other"), + root: Some("/proj"), + }, + // Verbatim prefix and drive paths + Case { + source: r"\\?\C:\secret\foo.mds", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "C:/secret/foo.mds", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "D:/other/file.mds", + base: Some("C:/proj"), + root: Some("C:/proj"), + }, + // VirtualFs pass-through and escape + Case { + source: "src/templates/foo.mds", + base: None, + root: None, + }, + Case { + source: "../../escape/secret.mds", + base: None, + root: None, + }, + // Degenerate inputs + Case { + source: "/", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "..", + base: Some("/proj"), + root: Some("/proj"), + }, + Case { + source: "", + base: Some("/proj"), + root: Some("/proj"), + }, + // Same-dir source + Case { + source: "/proj/build/out.mds", + base: Some("/proj/build"), + root: Some("/proj"), + }, + ]; + + for c in cases { + let base = c.base.map(Path::new); + let root = c.root.map(Path::new); + let out = relativize_source(c.source, base, root); + + let is_sentinel = out.starts_with('<') && out.ends_with('>'); + if !is_sentinel { + assert!( + !out.starts_with('/'), + "output must not be absolute (source={:?}): got {out:?}", + c.source, + ); + assert!( + !is_drive_qualified(&out), + "output must not be drive-qualified (source={:?}): got {out:?}", + c.source, + ); + assert!( + !out.is_empty(), + "output must not be empty (source={:?})", + c.source, + ); + + // Round-trip: normalize(effective_b.join(out)) must be inside root. + // + // `effective_b` mirrors the production logic (source_path.rs:170-180): + // use `base` when base is inside root, else fall back to root. + // This catches a regression in the round-trip guard at lines 191-197. + if let Some(r) = root { + let effective_b: &Path = match base { + Some(b) if b.starts_with(r) => b, + _ => r, + }; + let round_trip = lexical_join(effective_b, &out); + assert!( + round_trip.starts_with(r), + "round-trip: normalize(effective_b.join(out)) must be inside root \ + (source={:?} out={out:?} effective_b={effective_b:?} root={r:?}): \ + got {round_trip:?}", + c.source, + ); + } + } + } + } +} diff --git a/crates/mds-core/src/sourcemap.rs b/crates/mds-core/src/sourcemap.rs index fb635910..8b43a753 100644 --- a/crates/mds-core/src/sourcemap.rs +++ b/crates/mds-core/src/sourcemap.rs @@ -61,6 +61,48 @@ impl std::fmt::Debug for Origin { } } +// --------------------------------------------------------------------------- +// String-source canonical map label +// --------------------------------------------------------------------------- + +/// Canonical `sources[]` label for in-memory (string-source) compilations. +/// +/// All paths that produce a [`MapBuilder`] for string-source input converge +/// on [`MapBuilder::new`] or [`MapBuilder::source_index`]. Both choke-points +/// apply [`map_source_label`] so the diagnostic sentinel `""` can +/// never appear in `sources[]`. +/// +/// All binding surfaces (WASM, napi, Python, CLI) that handle string-source +/// compiles must import this constant rather than redeclaring the literal, so +/// cross-surface `sources[0]` parity (PF-007 / AC-API-06) is a compile-time +/// fact rather than a comment-coordinated manual sync. +pub const STRING_SOURCE_MAP_LABEL: &str = "input.mds"; + +/// Map a raw source file label to its canonical source-map label. +/// +/// The diagnostic/cycle-detection sentinel `""` is remapped to +/// [`STRING_SOURCE_MAP_LABEL`] so that the `sources[]` array in produced +/// source maps is identical across native, WASM, napi, and Python surfaces +/// (fixes the PF-007 cross-surface divergence). +/// +/// Applied at BOTH choke-points where new labels enter a [`MapBuilder`]: +/// - [`MapBuilder::new`] (the seed label at index 0), and +/// - [`MapBuilder::source_index`] (before the dedup compare, so `""` +/// and `"input.mds"` can never coexist as two distinct `sources[]` entries +/// even if S8 or spliced-region paths pass the sentinel separately). +/// +/// The literal `""` is used here rather than `SOURCE_LABEL` from +/// `resolver.rs` to avoid a cross-module dependency. If the sentinel ever +/// changes, update this function first. +#[inline] +pub(crate) fn map_source_label(name: &str) -> &str { + if name == "" { + STRING_SOURCE_MAP_LABEL + } else { + name + } +} + // --------------------------------------------------------------------------- // Public type // --------------------------------------------------------------------------- @@ -391,6 +433,16 @@ pub struct CompileOptions { /// space for callers that do not need embedded sources (CLI default unless /// `--embed-sources` is passed). pub include_sources_content: bool, + /// Directory that the source map file will be written to. + /// + /// Source Map v3 specifies that `sources[]` paths are relative to the map + /// file's location. When `Some`, [`crate::source_path::relativize_source`] + /// emits paths relative to this directory; when `None` (the default), paths + /// are emitted relative to the project root (root-relative form). + /// + /// CLI sets this to the output file's parent directory (Coder B). + /// Binding surfaces (napi, Python, WASM) leave it `None`. + pub source_map_base: Option, } /// Error returned by [`CompileOptions::validate`] when the field combination is invalid. @@ -503,12 +555,15 @@ impl MapBuilder { /// [`source_index`] registers additional sources (e.g. for `@extends` /// base templates in CP3+). pub(crate) fn new(source_name: String, source_content: String) -> Self { + // Canonicalize the label at the choke-point: "" (the diagnostic + // sentinel for string-source compiles) becomes STRING_SOURCE_MAP_LABEL. + let canonical = map_source_label(&source_name).to_string(); Self { segments: Vec::new(), cursor: 0, suppress: 0, current_src: 0, - sources: vec![source_name], + sources: vec![canonical], sources_content: vec![source_content], segments_dropped: false, no_sources_content: false, @@ -520,11 +575,16 @@ impl MapBuilder { /// Scans linearly (sources vecs are small — typically 1-3 entries per /// single-file compilation). pub(crate) fn source_index(&mut self, file: &str, content: &str) -> u32 { - if let Some(pos) = self.sources.iter().position(|s| s == file) { + // Apply the canonical label BEFORE the dedup compare so that "" + // and "input.mds" can never coexist as two distinct entries (e.g. when + // S8 function-body attribution passes the sentinel after the seed is + // already "input.mds"). + let canonical = map_source_label(file); + if let Some(pos) = self.sources.iter().position(|s| s == canonical) { return pos as u32; } let idx = self.sources.len() as u32; - self.sources.push(file.to_string()); + self.sources.push(canonical.to_string()); self.sources_content.push(content.to_string()); idx } @@ -931,6 +991,7 @@ mod tests { let opts = CompileOptions { source_map: true, include_sources_content: false, + ..Default::default() }; assert!( opts.validate().is_ok(), @@ -943,6 +1004,7 @@ mod tests { let opts = CompileOptions { source_map: true, include_sources_content: true, + ..Default::default() }; assert!( opts.validate().is_ok(), @@ -955,6 +1017,7 @@ mod tests { let opts = CompileOptions { source_map: false, include_sources_content: true, + ..Default::default() }; assert!( opts.validate().is_err(), diff --git a/crates/mds-core/src/validator.rs b/crates/mds-core/src/validator.rs index 5ba76f48..bb671340 100644 --- a/crates/mds-core/src/validator.rs +++ b/crates/mds-core/src/validator.rs @@ -101,9 +101,9 @@ fn validate_if_node( validate_condition(&block.condition, scope, file, source, block.offset)?; validate(&block.then_body, scope, file, source)?; // Validate all @elseif branches - for (elseif_cond, elseif_body) in &block.elseif_branches { - validate_condition(elseif_cond, scope, file, source, block.offset)?; - validate(elseif_body, scope, file, source)?; + for elseif in &block.elseif_branches { + validate_condition(&elseif.condition, scope, file, source, elseif.offset)?; + validate(&elseif.body, scope, file, source)?; } if let Some(else_body) = &block.else_body { validate(else_body, scope, file, source)?; diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 46262c85..2e5c986b 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -11,8 +11,10 @@ use mds::{ fn public_functions_exist() { let _: fn(&str) -> Result = mds::format_str; let _: fn(&str, Option<&Path>) -> Result = mds::format_str_with; + let _: fn(&str, Option<&Path>, &str) -> Result = mds::format_str_named; let _ = mds::format_str("Hello!\n"); let _ = mds::format_str_with("Hello!\n", None); + let _ = mds::format_str_named("Hello!\n", None, ""); let _ = mds::compile_str("---\nname: World\n---\nHello {name}!\n"); let _ = mds::compile_str_with("Hello!\n", None, None); let _ = mds::compile_str_collecting_warnings("Hello!\n", None, None); @@ -1323,6 +1325,7 @@ fn compile_options_has_source_map_and_include_sources_content() { let off = mds::CompileOptions { source_map: false, include_sources_content: false, + ..Default::default() }; assert!(!off.source_map); assert!(!off.include_sources_content); @@ -1330,6 +1333,7 @@ fn compile_options_has_source_map_and_include_sources_content() { let on = mds::CompileOptions { source_map: true, include_sources_content: true, + ..Default::default() }; assert!(on.source_map); assert!(on.include_sources_content); @@ -1355,6 +1359,7 @@ fn include_sources_content_false_omits_sources_content() { mds::CompileOptions { source_map: true, include_sources_content: false, + ..Default::default() }, ) .expect("should compile"); @@ -1380,6 +1385,7 @@ fn include_sources_content_true_includes_sources_content() { mds::CompileOptions { source_map: true, include_sources_content: true, + ..Default::default() }, ) .expect("should compile"); @@ -1390,3 +1396,84 @@ fn include_sources_content_true_includes_sources_content() { "sourcesContent must be Some when include_sources_content=true" ); } + +// ── Fix API surface pin (F-API-1) ───────────────────────────────────────────── + +/// F-API-1: apply_fixes_incremental and associated types exist on the public API surface. +/// +/// Pins: +/// - `mds::fix::apply_fixes_incremental` is callable with `F: Fn(&str) -> Result` +/// - `mds::fix::FixOutcome::PartiallyFixed` variant is exhaustively matchable +/// - `mds::fix::RejectedEdit` struct has the expected `edit: ByteEdit` and `reason: String` fields +#[test] +fn fix_api_incremental_exists() { + use mds::fix::{apply_fixes_incremental, plan_fixes, ByteEdit, FixOutcome, RejectedEdit}; + + // FixOutcome is #[non_exhaustive]: external matches need a wildcard arm. + // We still enumerate all known variants to pin their shapes at compile time. + let outcome: FixOutcome = FixOutcome::NothingToFix; + #[allow(clippy::match_single_binding)] + #[allow(unreachable_patterns)] + match outcome { + FixOutcome::Fixed { .. } + | FixOutcome::PartiallyFixed { .. } + | FixOutcome::Rejected { .. } + | FixOutcome::NothingToFix => {} + _ => {} // required: FixOutcome is #[non_exhaustive] + } + + // RejectedEdit struct has `edit` and `reason` fields. + let edit = ByteEdit { + start: 0, + end: 5, + rule: "duplicate-import".to_string(), + }; + let rejected = RejectedEdit { + edit, + reason: "simulated reverify failure".to_string(), + }; + assert_eq!(rejected.reason, "simulated reverify failure"); + assert_eq!(rejected.edit.rule, "duplicate-import"); + + // apply_fixes_incremental is callable with F: Fn — compile-time and runtime check. + let source = "Hello!\n"; + let original = LintResult { + diagnostics: vec![], + truncated: false, + is_standalone: false, + }; + let plan = plan_fixes(&original, source); + let outcome = apply_fixes_incremental( + source, + plan, + &original, + |_s| -> Result { + Ok(LintResult { + diagnostics: vec![], + truncated: false, + is_standalone: false, + }) + }, + ); + // Empty source with no diagnostics → NothingToFix (no reverify called). + assert!( + matches!(outcome, FixOutcome::NothingToFix), + "trivial source with no diagnostics must return NothingToFix; got: {outcome:?}" + ); +} + +/// Regression gate (issue #9): `STRING_SOURCE_MAP_LABEL` must be reachable from +/// the public `mds` API so every surface can import it rather than redeclaring +/// the literal (avoids PF-007 per-surface re-declaration defeating cross-surface +/// byte-parity; applies ADR-005). +/// +/// This test fails to COMPILE if the constant reverts to `pub(crate)`. +#[test] +fn string_source_map_label_is_in_public_api() { + let label: &str = mds::STRING_SOURCE_MAP_LABEL; + assert_eq!( + label, "input.mds", + "STRING_SOURCE_MAP_LABEL must equal \"input.mds\"; changing it requires \ + updating every surface that uses it" + ); +} diff --git a/crates/mds-core/tests/fmt.rs b/crates/mds-core/tests/fmt.rs index df105d1c..16dd28d2 100644 --- a/crates/mds-core/tests/fmt.rs +++ b/crates/mds-core/tests/fmt.rs @@ -10,7 +10,7 @@ //! per the plan's own instruction to verify claims against live code. See the //! `r3_*` and `r4_*` tests and their comments for the specific behavior locked in. -use mds::{format_str, format_str_with, MdsError}; +use mds::{format_str, format_str_named, format_str_with, MdsError}; // ── Small corpus of representative, syntactically-valid MDS snippets ───────── // @@ -343,6 +343,63 @@ fn syntax_error_unclosed_message_with_trailing_ws_is_syntax_not_formatter_invari // ── T1-gate-fallback: undefined-var source still formats; import source uses full gate ── +// RELEASE BLOCKER 3: non-compiling source with trailing blank line after final +// directive must NOT raise FormatterInvariant. R2 trims the trailing blank line, +// producing a token count mismatch in structural_equivalent that was spuriously +// reported as a formatter bug (ADR-001 gate false positive). +#[test] +fn gate_fallback_structural_equivalent_no_false_positive_on_trailing_blank_line() { + // Exact repro: undefined_var → structural_equivalent path; trailing \n after @end + // causes R2 to delete a Text("\n") token, creating a count mismatch before the fix. + let src = "@if undefined_var:\nx\n@end\n\n"; + assert!( + mds::compile_str(src).is_err(), + "sanity: source must not compile standalone (undefined variable)" + ); + let out = format_str(src).expect( + "format_str must NOT raise FormatterInvariant for trailing blank line after final directive" + ); + assert_eq!( + out, "@if undefined_var:\nx\n@end\n", + "R2 should trim the trailing blank" + ); + // Idempotence: formatting the result again must produce the same output. + let out2 = format_str(&out).expect("second format pass must succeed"); + assert_eq!(out, out2, "format_str must be idempotent"); +} + +#[test] +fn gate_fallback_no_false_positive_multiple_trailing_blank_lines() { + // Variant: multiple trailing blank lines — all are insignificant and should be stripped. + let src = "@for item in undefined_list:\n- {item}\n@end\n\n\n"; + assert!( + mds::compile_str(src).is_err(), + "sanity: source must not compile standalone" + ); + let out = + format_str(src).expect("multiple trailing blank lines must not produce FormatterInvariant"); + assert_eq!(out, "@for item in undefined_list:\n- {item}\n@end\n"); + let out2 = format_str(&out).expect("second pass must succeed"); + assert_eq!(out, out2, "idempotent"); +} + +#[test] +fn gate_fallback_no_false_positive_crlf_trailing_blank_line() { + // CRLF variant: \r\n trailing blank line — clean_output strips \r, still empty. + let src = "@if undefined_var:\nx\n@end\r\n\r\n"; + assert!( + mds::compile_str(src).is_err(), + "sanity: source must not compile standalone" + ); + let out = format_str(src).expect("CRLF trailing blank must not produce FormatterInvariant"); + assert_eq!( + out, "@if undefined_var:\nx\n@end\n", + "R1 + R2 should normalise CRLF + trailing blank" + ); + let out2 = format_str(&out).expect("second pass must succeed"); + assert_eq!(out, out2, "idempotent"); +} + #[test] fn gate_fallback_undefined_var_source_still_formats() { // `mds::compile_str` fails (undefined variable), so format_str_with must @@ -735,3 +792,49 @@ fn format_str_with_none_base_dir_matches_format_str() { } } } + +// ── format_str_named: file name threads through errors ──────────────────────── + +#[test] +fn format_str_named_happy_path_matches_format_str_with() { + // format_str_named("") must produce the same result as format_str_with. + let src = "Hello!\r\n\r\n\r\nBye.\r\n"; + let via_with = format_str_with(src, None).unwrap(); + let via_named = format_str_named(src, None, "").unwrap(); + assert_eq!( + via_with, via_named, + "format_str_named must agree with format_str_with" + ); +} + +#[test] +fn format_str_named_lexer_syntax_error_src_shows_file_name() { + // Unclosed interpolation -> lexer-level Syntax error. + // format_str_named must thread file_name into tokenize so the NamedSource carries it. + let err = format_str_named("Hello {name\n", None, "my_template.mds").unwrap_err(); + assert!( + matches!(err, MdsError::Syntax { .. }), + "unclosed interpolation must be a Syntax error, got: {err:?}" + ); + let debug = format!("{err:?}"); + assert!( + debug.contains("my_template.mds"), + "NamedSource must carry the file name passed to format_str_named; debug repr: {debug}" + ); +} + +#[test] +fn format_str_named_parser_syntax_error_src_shows_file_name() { + // Unclosed @if -> tokenizes OK but fails at compile time with Syntax. + // assert_equivalent must rebuild the Syntax error src with the provided file_name. + let err = format_str_named("@if cond:\nHello\n", None, "partials/_block.mds").unwrap_err(); + assert!( + matches!(err, MdsError::Syntax { .. }), + "unclosed @if must be a Syntax error, got: {err:?}" + ); + let debug = format!("{err:?}"); + assert!( + debug.contains("partials/_block.mds"), + "NamedSource in parse-level Syntax error must carry the file name; debug repr: {debug}" + ); +} diff --git a/crates/mds-core/tests/source_map_vfs.rs b/crates/mds-core/tests/source_map_vfs.rs index 7e5e7764..6d3fa983 100644 --- a/crates/mds-core/tests/source_map_vfs.rs +++ b/crates/mds-core/tests/source_map_vfs.rs @@ -50,6 +50,7 @@ fn vfs_with_map(modules: HashMap, entry: &str) -> CompileResult CompileOptions { source_map: true, include_sources_content: true, + ..Default::default() }, ) } @@ -884,17 +885,30 @@ fn source_map_messages_mode_degrades_to_none() { ); // A warning must be emitted explaining the degradation (AC-FUNC-07). - let has_warning = result.warnings.iter().any(|w| { - w.contains("messages-mode") - || w.contains("@message") - || w.contains("source_map will be None") - }); + // The warning uses MSG_MODE_SOURCE_MAP_WARNING (surface-neutral wording). + let matching_warnings: Vec<&String> = result + .warnings + .iter() + .filter(|w| w.contains("messages-mode") && w.contains("no source map will be generated")) + .collect(); assert!( - has_warning, + !matching_warnings.is_empty(), "AC-FUNC-07: must emit a warning for messages-mode + source_map=true; \ got warnings: {:?}", result.warnings ); + // Deduplicated: the warning must appear EXACTLY ONCE per compilation. + // (Previously the same literal string was present in two code paths; MSG_MODE_SOURCE_MAP_WARNING + // const was introduced to enforce a single canonical string — this test guards against regression + // where both paths fire for the same input.) + assert_eq!( + matching_warnings.len(), + 1, + "AC-FUNC-07: messages-mode degradation warning must appear exactly once; \ + got {} occurrences in warnings: {:?}", + matching_warnings.len(), + result.warnings + ); } /// AC-PERF-03: when the segment cap (`MAX_SOURCEMAP_SEGMENTS`) is exceeded, @@ -950,9 +964,7 @@ fn source_map_segment_cap_degrades_to_none() { ); // A warning must be emitted. - let has_warning = warnings - .iter() - .any(|w| w.contains("segment cap") || w.contains("source_map will be None")); + let has_warning = warnings.iter().any(|w| w.contains("segment cap")); assert!( has_warning, "AC-PERF-03: must emit a warning when segment cap is exceeded; \ @@ -1138,3 +1150,122 @@ fn for_max_total_iterations_across_extends_regions_source_map() { "REL-1: expected total-iterations resource_limit error, got: {err}" ); } + +// ── D1: STRING_SOURCE_MAP_LABEL cross-surface parity (PF-007) ──────────────── +// +// These tests verify that the choke-point fix in MapBuilder::new and +// source_index ensures the "" diagnostic sentinel never appears in +// sources[] for any code path. + +/// D1-CORE-1: string-source compile with sourceMap → sources[0] == "input.mds". +/// +/// Verifies the MapBuilder::new choke-point: map_source_label("") → +/// STRING_SOURCE_MAP_LABEL so the default string-source label matches WASM. +#[test] +fn d1_string_source_sources_label_is_input_mds() { + let result = mds::compile_str_with_deps_opts( + "Hello World!\n", + None, + None, + CompileOptions { + source_map: true, + include_sources_content: false, + ..Default::default() + }, + ) + .expect("should compile"); + let sm = result.source_map.expect("source_map must be present"); + assert_eq!( + sm.sources, + vec!["input.mds"], + "string-source sources[0] must be \"input.mds\" after map_source_label fix; got: {:?}", + sm.sources + ); +} + +/// D1-CORE-2: S8 function-body attribution — locally-defined function in a +/// string-source template must not add a second "" entry to sources[]. +/// +/// Without the source_index choke-point fix: +/// - MapBuilder::new("") was stored as "" at index 0. +/// - S8 path called source_index("", ...) → found it → OK. +/// - After the MapBuilder::new fix alone: +/// - MapBuilder::new("") → stores "input.mds" at index 0. +/// - S8 path called source_index("", ...) → NOT found → added +/// as a NEW entry "input.mds"... but with old code it would have been +/// "" at index 1. +/// - With both choke-points fixed: source_index("") → +/// map_source_label → "input.mds" → found at index 0 → no new entry. +#[test] +fn d1_s8_locally_defined_function_no_source_sentinel() { + // Define a function in the entry (string-source) template and call it. + // In S8 path: source_index(func.origin.file) is called with "". + // After the fix both MapBuilder::new and source_index canonicalize it to + // "input.mds", so sources must be exactly ["input.mds"] — no duplicates. + let result = mds::compile_str_with_deps_opts( + "@define greet():\nHello!\n@end\n{greet()}\n", + None, + None, + CompileOptions { + source_map: true, + include_sources_content: false, + ..Default::default() + }, + ) + .expect("should compile"); + let sm = result.source_map.expect("source_map must be present"); + assert_eq!( + sm.sources, + vec!["input.mds"], + "S8 path must not add a second \"\" entry; got: {:?}", + sm.sources + ); +} + +/// D1-CORE-3: @extends child that is a string-source → no "" sentinel +/// in sources[]. +/// +/// The child's origin (file="") flows into override_origin for any +/// blocks it overrides. When evaluate_with_map_seeded processes spliced regions +/// it calls source_index(origin.file, ...) for each region. After the fix, +/// origin.file="" → map_source_label → "input.mds" at index 0 (the +/// same entry already seeded by MapBuilder::new with skeleton_origin.file). +#[test] +fn d1_extends_from_string_no_source_sentinel() { + let dir = tempfile::tempdir().unwrap(); + // Write the base template to disk so @extends can resolve it. + std::fs::write( + dir.path().join("base.mds"), + "@block content:\ndefault content\n@end\n", + ) + .unwrap(); + + let child = "@extends \"./base.mds\"\n@block content:\noverridden\n@end\n"; + let result = mds::compile_str_with_deps_opts( + child, + Some(dir.path()), + None, + CompileOptions { + source_map: true, + include_sources_content: false, + ..Default::default() + }, + ) + .expect("should compile"); + let sm = result.source_map.expect("source_map must be present"); + // "" must not appear anywhere in sources[]. + for src in &sm.sources { + assert_ne!( + src.as_str(), + "", + "\"\" must not appear in sources[] after map_source_label fix; got: {:?}", + sm.sources + ); + } + // The child's blocks should be attributed to "input.mds" (not ""). + assert!( + sm.sources.contains(&"input.mds".to_string()), + "child source must be labeled \"input.mds\"; got: {:?}", + sm.sources + ); +} diff --git a/crates/mds-core/tests/virtual_fs.rs b/crates/mds-core/tests/virtual_fs.rs index ac27b995..166e3b5a 100644 --- a/crates/mds-core/tests/virtual_fs.rs +++ b/crates/mds-core/tests/virtual_fs.rs @@ -1360,6 +1360,123 @@ fn issue_149_indented_fence_inside_list_item_with_braces() { ); } +// ── D2: type_mismatch_at — span threaded from @if/@elseif directive ────────────── +// +// These tests verify that a cross-type comparison (e.g. string == number) in an +// @if or @elseif condition now carries a source span pointing to the directive line, +// and that the cross-source @extends case degrades gracefully to spanless. + +#[test] +fn d2_type_mismatch_at_if_carries_span() { + // A type mismatch in the primary @if condition must carry a span whose offset + // falls within the source and whose line/column are computed (non-None). + // Source: "@if x == \"3\":\nyes\n@end\n" where x=3 (number from frontmatter). + let src = "---\nx: 3\n---\n@if x == \"3\":\nyes\n@end\n"; + let err = + mds::compile_str(src).expect_err("D2: cross-type == in @if must be a TypeMismatch error"); + let serialized = err.serialize(); + assert_eq!( + serialized.code, "mds::type_mismatch", + "D2: error must be mds::type_mismatch, got: {}", + serialized.code + ); + let span = serialized + .span + .expect("D2: @if type_mismatch must carry a span"); + // Offset should point to the start of "@if x == \"3\":" — after the 14-byte frontmatter prefix. + // The exact offset doesn't matter here, but line/column must be populated. + assert!( + span.line.is_some(), + "D2: type_mismatch span must have a line number, got: {span:?}" + ); + assert!( + span.column.is_some(), + "D2: type_mismatch span must have a column, got: {span:?}" + ); + assert!( + span.length > 0, + "D2: type_mismatch span length must be > 0, got: {span:?}" + ); +} + +#[test] +fn d2_type_mismatch_at_elseif_span_points_to_elseif_not_if() { + // A type mismatch in an @elseif branch must carry a span anchored to the + // @elseif directive line, NOT to the @if line. + // Layout: + // line 4 = "@if x == 5:" — valid but always false (x=3 ≠ 5, same type → no mismatch here) + // line 5 = "no" + // line 6 = "@elseif x == \"3\":" — this is where the type_mismatch fires (int vs string) + // Note: bare `@if false:` is rejected by the parser; use a variable comparison instead. + let src = "---\nx: 3\n---\n@if x == 5:\nno\n@elseif x == \"3\":\nyes\n@end\n"; + let err = mds::compile_str(src) + .expect_err("D2: cross-type == in @elseif must be a TypeMismatch error"); + let serialized = err.serialize(); + assert_eq!( + serialized.code, "mds::type_mismatch", + "D2: error must be mds::type_mismatch, got: {}", + serialized.code + ); + let span = serialized + .span + .expect("D2: @elseif type_mismatch must carry a span"); + // The span must point to "@elseif x == \"3\":" (line 6 in this 8-line source). + // We verify: line >= 5 (at least past the @if line at line 4). + let line = span.line.expect("D2: @elseif span must have a line number"); + assert!( + line >= 5, + "D2: type_mismatch span from @elseif must point past the @if line; got line={line}" + ); + // Also verify the @if line (4) is NOT the anchor: line must be >= 5 for the @elseif. + // (It's line 6 in this 8-line source; we just check >= 5 to avoid hard-coding offset arithmetic.) +} + +#[test] +fn extends_base_skeleton_type_mismatch_span_not_misattributed_to_child() { + // A type mismatch in a BASE skeleton `@if` condition (offset relative to the base + // template) must NOT be attributed to the CHILD source. The flat + // `evaluate(&final_body, …, ctx.source)` path in the `@extends` pipeline evaluates a + // 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 offset that happens to land within the child source at a char + // boundary would otherwise anchor the `type_mismatch` span onto the child's + // `@extends` line — mis-attribution to a foreign source. The contract + // (`build_type_mismatch` doc / ADR-005) is to degrade to spanless rather than + // mis-attribute, exactly as the flat non-`@extends` path never faces this because its + // offsets and `ctx.source` share one origin. + let mut modules = HashMap::new(); + modules.insert( + "base.mds".to_string(), + // `n` is a string; `@if n == 5` is a string-vs-number mismatch that fires at eval + // time (the validator cannot type-check it). The `@if` lives in the base SKELETON + // (not inside a `@block`), so its offset (14) is base-relative. The `@block` + // placeholder lets `child.mds` be a valid extender. + "---\nn: hi\n---\n@if n == 5:\nbase-if-branch\n@end\n@block body:\nbase default\n@end\n" + .to_string(), + ); + modules.insert( + "child.mds".to_string(), + // Long enough that the base `@if` offset (14) falls inside the child source at a + // valid char boundary — the exact condition that triggered mis-attribution. + "@extends \"./base.mds\"\n@block body:\nThis override body is intentionally long so the base skeleton offset lands inside the child source.\n@end\n" + .to_string(), + ); + let err = compile_vfs(modules, "child.mds") + .expect_err("cross-type mismatch in an inherited @if must error"); + let serialized = err.serialize(); + assert_eq!( + serialized.code, "mds::type_mismatch", + "must surface as mds::type_mismatch, got: {}", + serialized.code + ); + assert!( + serialized.span.is_none(), + "cross-source @extends type_mismatch must degrade to spanless (never mis-attributed \ + to the child source); got span: {:?}", + serialized.span + ); +} + // ── Integration repro: #153 — invalid interpolation hint says \{ not \{{ ─────── #[test] @@ -1379,3 +1496,104 @@ fn issue_153_invalid_interpolation_hint_text() { "#153: error hint must NOT say \\{{{{ (double brace); got: {msg}" ); } + +// ── Issue #58: evaluate_with_map file/source single source of truth ─────────── +// +// Regression guard for the c5a4d65 bug class: `file` and `source` previously +// traveled as BOTH explicit parameters AND EvalContext fields in the source-map +// path. The fix (#58) derives them from `builder.current_src` — the single +// source of truth — so a caller cannot accidentally pass mismatched values. +// +// These tests compile with source_map=true and verify that type_mismatch spans +// are attributed to the correct source, never to the wrong module. + +#[test] +fn source_map_extends_type_mismatch_span_not_misattributed_to_child() { + // With source_map=true, the @extends pipeline uses evaluate_regions_with_map, + // which sets builder.current_src to the region's origin before each call. + // After issue #58, evaluate_with_map_seeded derives ctx.file/ctx.source from + // that builder entry rather than from explicit params — so the region's own + // source is always in scope for span attribution. + // + // A type_mismatch in the BASE skeleton's @if fires with ctx.source = base content. + // Any resulting span must fall within the base source, not the child source. + let mut modules = HashMap::new(); + let base_source = + "---\nn: hi\n---\n@if n == 5:\nbase-if-branch\n@end\n@block body:\nbase default\n@end\n"; + modules.insert("base.mds".to_string(), base_source.to_string()); + modules.insert( + "child.mds".to_string(), + // Long enough that the base @if offset (14) lands inside the child source at + // a valid char boundary — the exact condition that triggered mis-attribution + // before c5a4d65 and that #58 structurally prevents. + "@extends \"./base.mds\"\n@block body:\nThis override body is intentionally long enough that the base skeleton @if offset falls inside the child source.\n@end\n" + .to_string(), + ); + let err = mds::compile_virtual_with_deps_opts( + modules, + "child.mds", + None, + mds::CompileOptions { + source_map: true, + include_sources_content: false, + ..Default::default() + }, + ) + .expect_err("cross-type mismatch in an inherited @if must error"); + let serialized = err.serialize(); + assert_eq!( + serialized.code, "mds::type_mismatch", + "must surface as mds::type_mismatch, got: {}", + serialized.code + ); + // In the source_map path, evaluate_regions_with_map evaluates the skeleton + // with the base's origin, so the span (if present) must fall within the base + // source. If it degrades to spanless (ADR-005), that is also correct. + // What is NOT correct: a span with offset >= base_source.len() pointing into + // child territory. + if let Some(span) = &serialized.span { + assert!( + span.offset < base_source.len(), + "type_mismatch span (offset={}) must fall within base source (len={}); \ + a larger offset would indicate mis-attribution to the child source", + span.offset, + base_source.len() + ); + } +} + +#[test] +fn source_map_standalone_type_mismatch_carries_span() { + // Standalone (non-@extends) compile with source_map=true: the builder is seeded + // with the module's own file/source at current_src=0. After issue #58, + // evaluate_with_map derives ctx.file and ctx.source from that entry. + // A type_mismatch in the @if must carry a span whose offset falls within the source. + let src = "---\nx: 3\n---\n@if x == \"3\":\nyes\n@end\n"; + let err = mds::compile_str_with_deps_opts( + src, + None, + None, + mds::CompileOptions { + source_map: true, + include_sources_content: false, + ..Default::default() + }, + ) + .expect_err("cross-type == in @if must fail"); + let serialized = err.serialize(); + assert_eq!( + serialized.code, "mds::type_mismatch", + "must surface as mds::type_mismatch, got: {}", + serialized.code + ); + let span = serialized + .span + .expect("standalone source_map type_mismatch must carry a span"); + assert!( + span.offset < src.len(), + "span offset ({}) must fall within the source (len={})", + span.offset, + src.len() + ); + assert!(span.length > 0, "span length must be > 0, got: {span:?}"); +} diff --git a/crates/mds-napi/README.md b/crates/mds-napi/README.md index bac1d032..d9b4eaf0 100644 --- a/crates/mds-napi/README.md +++ b/crates/mds-napi/README.md @@ -31,9 +31,43 @@ declared as `optionalDependencies`, filtered by `os`/`cpu`/`libc`: ## API ```js -const { compile, check, compileFile, checkFile } = require('@mdscript/mds-napi'); +const { compile, compileFile, check, checkFile, lint, lintFile, lintVirtual } = require('@mdscript/mds-napi'); ``` +### `compile(source, opts?)` + +Compile an MDS source string. Returns a discriminated-union result object: + +- Markdown: `{ kind: "markdown", output: string, warnings: string[], dependencies: string[], sourceMap?: object }` +- Messages: `{ kind: "messages", messages: [{role,content},...], warnings: string[], dependencies: string[] }` + +Options: +- `basePath` (string) — base directory for `@import` resolution; defaults to cwd. +- `vars` (object) — runtime variable overrides. +- `sourceMap` (boolean) — generate a Source Map v3 document; result gains `sourceMap`. + For string-source compiles `sources[0]` is `"input.mds"`. +- `sourcesContent` (boolean) — embed original source text in the map (requires `sourceMap`). + ⚠ Privacy: embeds the full template source. + +### `compileFile(path, opts?)` + +Same result shape as `compile`. Options: `vars`, `sourceMap`, `sourcesContent`. +`basePath` is not accepted — the base directory is derived from the file path. + +### `check(source, opts?)` / `checkFile(path, opts?)` + +Validate without rendering. Returns `{ warnings: string[] }`. +Options: `basePath`, `vars` (check only; checkFile: `vars` only). +Source-map options are **not accepted** — check does not generate output. + +### `lint(source, opts?)` / `lintFile(path, opts?)` / `lintVirtual(modules, entry, opts?)` + +Static analysis. Returns the canonical lint JSON: +`{ version: 1, files: [{file, diagnostics: [{rule, severity, message, help, fixable, span?},...]},...], truncated: bool }` + +Options: `basePath` (lint only — lintFile derives the base from the file path; lintVirtual resolves against the module map), `vars`, `rules` (`Record`). +Unknown rule names in `rules` are silently accepted (a typo has no effect); unknown severity values throw `mds::invalid_options`. + See `index.d.ts` for the full typed surface. ## License diff --git a/crates/mds-napi/__test__/index.spec.mjs b/crates/mds-napi/__test__/index.spec.mjs index 461a1bee..a9d62a36 100644 --- a/crates/mds-napi/__test__/index.spec.mjs +++ b/crates/mds-napi/__test__/index.spec.mjs @@ -162,6 +162,36 @@ describe('compileFile', () => { assert.ok(result.output.includes('Hello Alice!'), `got: ${result.output}`); } }); + + test('F-CF7: bare filename (no separator, no ./ prefix) resolves from cwd (avoids PF-006)', () => { + // PF-006: Path::parent() returns Some("") for a bare name like "simple.mds". + // "".canonicalize() fails with file_not_found unless effective_parent maps it + // to ".". This test exercises exactly that path — a bare filename with NO + // separator character and NO "./" prefix — by chdir-ing into the fixtures + // directory so the bare name is resolvable. + // F-CF6 uses path.relative() which always produces a SEPARATOR-CONTAINING + // relative path (e.g. "../../fixtures/simple.mds"), bypassing the bug entirely. + // This test is the genuine bare-filename gate. + const originalCwd = process.cwd(); + try { + process.chdir(FIXTURES); + const result = compileFile('simple.mds'); // no separator, no './' prefix + assert.equal(result.kind, 'markdown', 'F-CF7: kind must be markdown'); + assert.ok( + result.output.includes('Hello Alice!'), + `F-CF7: bare-filename compileFile must produce compiled content; got: ${result.output}`, + ); + // Dependencies must be absolute paths even when the entry was a bare filename. + for (const dep of result.dependencies) { + assert.ok( + path.isAbsolute(dep), + `F-CF7: dependency must be absolute; got: ${dep}`, + ); + } + } finally { + process.chdir(originalCwd); + } + }); }); // ── Check tests ─────────────────────────────────────────────────────────────── @@ -342,6 +372,23 @@ describe('error shape', () => { }, ); }); + + // D2: type_mismatch_at — cross-type @if comparison now carries a source span + test('D2: type_mismatch from @if cross-type comparison carries a non-null span', () => { + const src = '---\nx: 3\n---\n@if x == "3":\nyes\n@end\n'; + assert.throws( + () => compile(src), + (err) => { + assert.equal(err.code, 'mds::type_mismatch', `D2: expected type_mismatch, got: ${err.code}`); + assert.ok(err.span !== undefined && err.span !== null, 'D2: type_mismatch must carry a span'); + assert.ok(typeof err.span.offset === 'number', 'D2: span.offset must be a number'); + assert.ok(err.span.length > 0, 'D2: span.length must be > 0'); + assert.ok(typeof err.span.line === 'number', `D2: span.line must be a number, got: ${typeof err.span.line}`); + assert.ok(typeof err.span.column === 'number', `D2: span.column must be a number, got: ${typeof err.span.column}`); + return true; + }, + ); + }); }); // ── Options validation tests ────────────────────────────────────────────────── @@ -1020,6 +1067,9 @@ describe('source maps (F-SM)', () => { assert.equal(sm.version, 3, 'sourceMap.version must be 3'); assert.ok(Array.isArray(sm.sources), 'sourceMap.sources must be an array'); assert.ok(sm.sources.length > 0, 'sourceMap.sources must be non-empty'); + // String-source uses "input.mds" (STRING_SOURCE_MAP_LABEL) — unified with WASM. + assert.equal(sm.sources[0], 'input.mds', + `sources[0] must be "input.mds" after map_source_label fix; got: ${JSON.stringify(sm.sources[0])}`); assert.ok(Array.isArray(sm.names), 'sourceMap.names must be an array'); assert.equal(sm.names.length, 0, 'sourceMap.names must be empty'); assert.equal(typeof sm.mappings, 'string', 'sourceMap.mappings must be a string'); @@ -1105,7 +1155,14 @@ describe('source maps (F-SM)', () => { }); // F-SM7: structural validity of sourceMap from compileFile - test('F-SM7: compileFile produces structurally valid sourceMap', () => { + // + // After the ADR-005 Phase A choke-point fix (source_path.rs::relativize_source), + // compileFile emits ROOT-RELATIVE paths in sources[] — NOT absolute filesystem + // paths. The value is slash-separated relative to the project root (found via + // .mdsroot / .git walk-up). The assertion below checks the path ends with the + // fixture basename; for a stronger cross-surface parity assertion see CF-SM1 in + // packages/mds/__test__/source-map.spec.mjs. + test('F-SM7: compileFile produces structurally valid sourceMap with root-relative sources[]', () => { const result = compileFile(SIMPLE_MDS, { sourceMap: true }); assert.equal(result.kind, 'markdown'); const sm = result.sourceMap; @@ -1116,6 +1173,15 @@ describe('source maps (F-SM)', () => { assert.ok(sm.mappings.length > 0, 'mappings must be non-empty'); // file is absent (bindings do not set file) assert.ok(!('file' in sm), 'file must be absent (bindings do not set file)'); + // sources[0] must be root-relative (not an absolute path). + assert.ok( + !sm.sources[0].startsWith('/') && !sm.sources[0].match(/^[A-Za-z]:\\/), + `sources[0] must be root-relative, not an absolute path; got: ${JSON.stringify(sm.sources[0])}`, + ); + assert.ok( + sm.sources[0].endsWith('.mds'), + `sources[0] must end with .mds extension; got: ${JSON.stringify(sm.sources[0])}`, + ); // Validate mappings contains only valid Base64-VLQ chars assert.ok( /^[A-Za-z0-9+/,;]*$/.test(sm.mappings), diff --git a/crates/mds-napi/package.json b/crates/mds-napi/package.json index 5c59b352..bf1b558a 100644 --- a/crates/mds-napi/package.json +++ b/crates/mds-napi/package.json @@ -5,6 +5,10 @@ "main": "index.js", "types": "index.d.ts", "license": "MIT", + "scripts": { + "build:native": "napi build --release --no-js", + "test": "node --test __test__/index.spec.mjs" + }, "repository": { "type": "git", "url": "git+https://github.com/dean0x/mdscript.git", diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 473d68f0..9f70d3d4 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -511,6 +511,7 @@ fn extract_compile_options_direct(env: &Env, obj: &Object) -> napi::Result serde_json::Value { /// Compile an MDS template source string and return a structured result. /// +/// For string-source compiles the `sources[0]` field in any generated source map is +/// `"input.mds"`. +/// /// ## Arguments /// /// - `source`: MDS template source text. @@ -638,11 +642,17 @@ fn build_canonical_result(result: mds::CompileResult) -> serde_json::Value { /// - `basePath` (string): base directory for resolving `@import` paths. /// Defaults to the current working directory. /// - `vars` (`Record`): runtime variable overrides. +/// - `sourceMap` (boolean, default `false`): generate a Source Map v3 document. +/// The result gains a `sourceMap` key when this is `true`. Silently ignored for +/// messages-mode templates (source maps are not supported for them). +/// - `sourcesContent` (boolean, default `false`): embed the original source text +/// in `sourcesContent[]`. Requires `sourceMap: true`; raises `mds::invalid_options` +/// otherwise. ⚠ Privacy: embeds the full template source in the map. /// /// ## Returns /// /// On success: -/// - Markdown: `{ kind: "markdown", output: string, warnings: string[], dependencies: string[] }` +/// - Markdown: `{ kind: "markdown", output: string, warnings: string[], dependencies: string[], sourceMap?: object }` /// - Messages: `{ kind: "messages", messages: [{role:string,content:string},...], warnings: string[], dependencies: string[] }` /// /// The inactive payload field is absent from the returned object. @@ -674,6 +684,9 @@ pub fn compile(env: Env, source: String, opts: Option) -> napi::Result`): runtime variable overrides. +/// - `sourceMap` (boolean, default `false`): generate a Source Map v3 document. +/// - `sourcesContent` (boolean, default `false`): embed original source text in the +/// map (requires `sourceMap: true`). /// /// `basePath` is not accepted — the base directory is derived from the file's /// own directory. @@ -703,7 +716,13 @@ pub fn compile_file( /// ## Arguments /// /// - `source`: MDS template source text. -/// - `opts`: optional configuration object (same fields as `compile`). +/// - `opts`: optional configuration object: +/// - `basePath` (string): base directory for resolving `@import` paths. +/// - `vars` (`Record`): runtime variable overrides. +/// +/// Source-map options (`sourceMap`, `sourcesContent`) are **not** accepted — `check` +/// does not generate output so maps are irrelevant and passing them is a hard error +/// (`mds::invalid_options`). /// /// ## Returns /// @@ -734,6 +753,9 @@ pub fn check(env: Env, source: String, opts: Option) -> napi::Result`): runtime variable overrides. /// +/// `basePath` is not accepted (base directory is derived from the file path). +/// Source-map options are not accepted — see `check`. +/// /// ## Returns /// /// Same shape as `check`. diff --git a/crates/mds-python/README.md b/crates/mds-python/README.md index 19f3b989..cc67530e 100644 --- a/crates/mds-python/README.md +++ b/crates/mds-python/README.md @@ -48,19 +48,30 @@ keyword-only; `scan_imports` takes its argument positionally. | Function | Signature | |----------|-----------| -| `compile` | `compile(source, *, vars=None, base_path=None) -> CompileResult` | -| `compile_file` | `compile_file(path, *, vars=None) -> CompileResult` | -| `compile_virtual` | `compile_virtual(modules, entry, *, vars=None) -> CompileResult` | +| `compile` | `compile(source, *, vars=None, base_path=None, source_map=False, sources_content=False) -> CompileResult` | +| `compile_file` | `compile_file(path, *, vars=None, source_map=False, sources_content=False) -> CompileResult` | +| `compile_virtual` | `compile_virtual(modules, entry, *, vars=None, source_map=False, sources_content=False) -> CompileResult` | | `check` | `check(source, *, vars=None, base_path=None) -> CheckResult` | | `check_file` | `check_file(path, *, vars=None) -> CheckResult` | | `check_virtual` | `check_virtual(modules, entry, *, vars=None) -> CheckResult` | | `scan_imports` | `scan_imports(source, /) -> list[str]` | +| `lint` | `lint(source, *, vars=None, base_path=None, rules=None) -> LintResult` | +| `lint_file` | `lint_file(path, *, vars=None, rules=None) -> LintResult` | +| `lint_virtual` | `lint_virtual(modules, entry, *, vars=None, rules=None) -> LintResult` | - `path` / `base_path` accept `str` or `os.PathLike`. - `vars` is a mapping of string keys to JSON-compatible values; a non-mapping raises `MdsError(code="mds::invalid_options")`. -- `compile_virtual` / `check_virtual` resolve imports against an in-memory map; - `entry` must be a key in `modules` (no source injection occurs). +- `compile_virtual` / `check_virtual` / `lint_virtual` resolve imports against an in-memory + map; `entry` must be a key in `modules`. +- `source_map=True` generates a Source Map v3 document; `result.source_map` is a `dict`. + For string-source compiles `sources[0]` is `"input.mds"`. `sources_content=True` embeds + the original source text in `sourcesContent[]` (requires `source_map=True`). + ⚠ Privacy: `sources_content=True` embeds the full template source in the map. +- `rules` is a mapping of rule name → severity string (`"off"`, `"info"`, `"warn"`, `"error"`). + Unknown severity values raise `MdsError(code="mds::invalid_options")`; unknown rule names + are silently accepted (the name simply has no effect — a typo will not configure the rule). + `LintResult` exposes `.version`, `.truncated`, `.files`, `.to_dict()`, `.to_json()`. ### Result objects diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 85582a05..22ccfb9a 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -838,6 +838,7 @@ fn extract_compile_options( let opts = mds::CompileOptions { source_map, include_sources_content: sources_content, + ..Default::default() }; opts.validate().map_err(|_| { options_error( diff --git a/crates/mds-python/tests/conftest.py b/crates/mds-python/tests/conftest.py index b3478d41..160b3f7a 100644 --- a/crates/mds-python/tests/conftest.py +++ b/crates/mds-python/tests/conftest.py @@ -22,15 +22,36 @@ def fixtures() -> Path: def _find_cli() -> Path | None: - """Locate a built `mds` CLI binary (the independent parity producer).""" + """Locate a built `mds` CLI binary (the independent parity producer). + + Priority: + 1. ``MDS_CLI_BIN`` environment variable — if set, the path must exist as a + file; a non-existent or non-file path raises :class:`FileNotFoundError` + immediately rather than silently falling through to other candidates. + 2. The *freshest* of ``target/release/mds`` and ``target/debug/mds`` by + mtime — prefers the binary that was compiled most recently so a fresh + debug build is not shadowed by a stale release artifact. + 3. ``mds`` found anywhere on ``$PATH`` via :func:`shutil.which`. + """ env = os.environ.get("MDS_CLI_BIN") - if env and Path(env).is_file(): - return Path(env) + if env: + p = Path(env) + if not p.is_file(): + raise FileNotFoundError( + f"MDS_CLI_BIN={env!r} is set but points to a non-existent or " + "non-file path; remove it or correct the path" + ) + return p exe = "mds.exe" if os.name == "nt" else "mds" - for profile in ("release", "debug"): - cand = REPO_ROOT / "target" / profile / exe - if cand.is_file(): - return cand + candidates = [ + REPO_ROOT / "target" / profile / exe for profile in ("release", "debug") + ] + existing = [c for c in candidates if c.is_file()] + if existing: + # Pick whichever binary was modified most recently; ties resolve to + # release (candidates[0]) because Python's max() returns the first + # maximum encountered when keys are equal. + return max(existing, key=lambda p: p.stat().st_mtime) found = shutil.which("mds") return Path(found) if found else None diff --git a/crates/mds-python/tests/test_errors.py b/crates/mds-python/tests/test_errors.py index 30a9bb82..edd5e00b 100644 --- a/crates/mds-python/tests/test_errors.py +++ b/crates/mds-python/tests/test_errors.py @@ -147,6 +147,8 @@ def test_e5_span_offset_and_line_column_single_line() -> None: # 1-indexed character column (ASCII → char offset == byte offset) assert e.span.column == e.span.offset + 1 assert isinstance(e.span.offset, int) # Python int — no truncation + else: + pytest.fail("expected MdsError") def test_e5_span_line_increments_on_multiline() -> None: @@ -157,6 +159,8 @@ def test_e5_span_line_increments_on_multiline() -> None: assert e.span is not None assert e.span.line == 2 assert e.span.column and e.span.column > 1 + else: + pytest.fail("expected MdsError") def test_e5_span_none_when_core_reports_none() -> None: @@ -165,3 +169,27 @@ def test_e5_span_none_when_core_reports_none() -> None: m.compile("x" * (10 * 1024 * 1024 + 1)) except m.MdsError as e: assert e.span is None + else: + pytest.fail("expected MdsError") + + +# ── D2: type_mismatch_at — span present on @if cross-type comparison ───────────── + + +def test_d2_type_mismatch_span_is_not_none() -> None: + """D2: cross-type == in @if now carries a source span via type_mismatch_at.""" + src = "---\nx: 3\n---\n@if x == \"3\":\nyes\n@end\n" + try: + m.compile(src) + except m.MdsError as e: + assert e.code == "mds::type_mismatch", f"expected type_mismatch, got: {e.code}" + assert e.span is not None, ( + "D2: type_mismatch from @if must carry a span (type_mismatch_at)" + ) + assert isinstance(e.span.offset, int), "span.offset must be an int" + assert e.span.length > 0, "span.length must be > 0" + # line/column must be populated (the error points at the @if line). + assert e.span.line is not None, "span.line must be present for @if type_mismatch" + assert e.span.column is not None, "span.column must be present for @if type_mismatch" + else: + pytest.fail("expected MdsError") diff --git a/crates/mds-python/tests/test_functional.py b/crates/mds-python/tests/test_functional.py index 86b323f3..ecc1c8b5 100644 --- a/crates/mds-python/tests/test_functional.py +++ b/crates/mds-python/tests/test_functional.py @@ -83,6 +83,28 @@ def test_f5_compile_file_deps_absolute_entry_excluded(fixtures: pathlib.Path) -> assert any(d.endswith("import_provider.mds") for d in r.dependencies) +def test_f5_compile_file_bare_name_from_cwd( + fixtures: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """PF-006 regression: bare relative filename (no ./ prefix) resolves from cwd. + + Path::parent() returns Some("") for a bare name — "".canonicalize() would fail + with file_not_found on every binding surface if not for effective_parent. + This test passes "simple.mds" with NO separator so it exercises the bare-name + path that shipped broken in v0.1.0–v0.3.0. + """ + monkeypatch.chdir(fixtures) + r = m.compile_file("simple.mds") # bare name — no "./" prefix + assert r.kind == "markdown" + assert "Hello Alice!" in (r.output or ""), ( + f"expected 'Hello Alice!' in output, got: {r.output!r}" + ) + # Dependencies must be absolute even when the entry was a bare filename. + assert all(pathlib.Path(d).is_absolute() for d in r.dependencies), ( + f"all dependencies must be absolute paths, got: {r.dependencies}" + ) + + # ── check / check_file (F6, F7) ───────────────────────────────────────────────── diff --git a/crates/mds-python/tests/test_source_map.py b/crates/mds-python/tests/test_source_map.py index 97d0324e..9e33c954 100644 --- a/crates/mds-python/tests/test_source_map.py +++ b/crates/mds-python/tests/test_source_map.py @@ -64,8 +64,9 @@ def test_sm_py1_compile_produces_source_map() -> None: sm = result.source_map assert sm is not None, "source_map getter should be non-None" _check_sm_structure(sm) - # String-source compilation uses "" as the entry label. - assert sm["sources"] == [""] + # String-source compilation uses "input.mds" (STRING_SOURCE_MAP_LABEL) as + # the entry label — unified with the WASM backend via the choke-point fix. + assert sm["sources"] == ["input.mds"] def test_sm_py1_compile_virtual_produces_source_map() -> None: @@ -122,10 +123,13 @@ def test_sm_py4_messages_mode_degrades() -> None: # Messages-mode templates have no renderable output → no source map. assert result.source_map is None assert result.kind == "messages" - # The binding should emit a warning about source map being unavailable. + # The binding must emit MSG_MODE_SOURCE_MAP_WARNING (shared core constant). + # The assertion is anchored to a distinctive phrase from that constant so + # that any drift of the constant text is caught here (avoids PF-007). warnings = result.warnings - assert any("source_map" in w or "messages-mode" in w for w in warnings), ( - f"expected a source_map/messages-mode warning, got: {warnings}" + assert any("messages-mode templates" in w for w in warnings), ( + f"expected MSG_MODE_SOURCE_MAP_WARNING (contains 'messages-mode templates'), " + f"got: {warnings}" ) @@ -290,9 +294,17 @@ def test_sm_py10_compile_file_source_map() -> None: sm = result.source_map assert sm is not None _check_sm_structure(sm) - # compile_file uses the absolute path as the source label. + # compile_file now emits root-relative paths in sources[] (ADR-005 Phase A + # choke-point fix in source_path.rs::relativize_source). The value is a + # slash-separated path relative to the project root (located via .mdsroot / + # .git walk-up), NOT the absolute filesystem path. Since the fixtures dir + # is the project root for this test, sources[0] ends with "simple.mds". assert len(sm["sources"]) == 1 assert sm["sources"][0].endswith("simple.mds") + # Must be root-relative, NOT an absolute filesystem path (ADR-005 security fix). + assert not sm["sources"][0].startswith("/"), ( + f"sources[0] must not be an absolute path; got: {sm['sources'][0]!r}" + ) # No file key in binding output. assert "file" not in sm diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index d49d6a64..f4cf39a7 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -62,7 +62,11 @@ const MAX_MODULES_AGGREGATE_SIZE: usize = MAX_SOURCE_SIZE; // ── Defaults ───────────────────────────────────────────────────────────────── /// Default filename used when the caller does not supply `options.filename`. -const DEFAULT_FILENAME: &str = "input.mds"; +/// +/// This is the canonical label for all string-source compilations across every +/// surface — imported from `mds_core` so cross-surface `sources[0]` parity +/// (PF-007 / AC-API-06) is enforced by the compiler rather than by comment. +const DEFAULT_FILENAME: &str = mds::STRING_SOURCE_MAP_LABEL; // ── JS interop primitives ───────────────────────────────────────────────────── @@ -390,6 +394,7 @@ fn extract_compile_options_wasm(obj: &js_sys::Object) -> Result Result { let compile_opts = mds::CompileOptions { source_map: opts.source_map, include_sources_content: opts.include_sources_content, + ..Default::default() }; let modules = build_modules(source, &opts.filename, opts.extra_modules)?; let result = diff --git a/examples/README.md b/examples/README.md index 3ae28276..8e119a0e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,24 @@ any `.mds` file with the CLI: mds build examples/ai-agent/system-prompt.mds -o - ``` +## New in v0.4.0 + +Four capabilities shipped with v0.4.0 — each has a dedicated example: + +```bash +# Safety-gated formatter — rewrites directive lines only, never body text +mds fmt --check examples/ + +# Static analysis — 9 rules, human and JSON output, --fix --diff preview +mds lint examples/linting/ + +# Source Map v3 — sidecar .map file, --inline data-URI, or --embed-sources +mds build examples/source-maps/annotated-prompt.mds --source-map -o /tmp/out.md + +# String variables without type coercion (e.g. preserve leading zeros) +mds build template.mds --set-string zip_code=02134 +``` + ## Templates | Directory | What it shows | @@ -16,8 +34,10 @@ mds build examples/ai-agent/system-prompt.mds -o - | [`blog-generator/`](blog-generator/) | A blog post template driven by frontmatter variables | | [`prompt-library/`](prompt-library/) | A reusable prompt library using `@export`/`@import` (personas, formatting, guardrails) | | [`inheritance/`](inheritance/) | Template inheritance with `@extends`/`@block` — one base agent skeleton specialized into a data analyst and a code reviewer | -| [`edge-cases/`](edge-cases/) | Numbered walkthrough of language features — loops, conditionals, imports, escaping, re-exports, runtime vars, built-in functions, default args, logical operators, expression directives, frontmatter imports | +| [`edge-cases/`](edge-cases/) | Numbered walkthrough of language features — loops, conditionals, imports, escaping, re-exports, runtime vars, built-in functions, default args, logical operators, expression directives, frontmatter imports; v0.4.0 adds interior blank-line preservation, typed comparisons, and `@extends` frontmatter merge | | [`stress-test/`](stress-test/) | A large, deeply-composed template tree exercising the resolver and evaluator | +| [`linting/`](linting/) | A deliberately-messy template that trips four lint rules; shows `mds lint` human and JSON output, `--fix --diff` preview, and exit-code semantics | +| [`source-maps/`](source-maps/) | Source Map v3 generation via `mds build --source-map` — sidecar map, `--inline` data-URI embed, and `--embed-sources` self-contained variant | Some examples take runtime variables — pass the accompanying `vars.json`: @@ -50,6 +70,8 @@ mds build examples/ --out-dir dist/ mds check examples/ ``` +Note: `examples/stress-test/errors/` contains five intentionally-failing fixtures (`bad-arity`, `bad-circular-a/b`, `bad-type`, `bad-undefined`), so `mds build examples/ --out-dir dist/` and `mds check examples/` exit non-zero by design. Likewise, `mds lint examples/` exits 2 by design because `examples/linting/demo.mds` deliberately triggers error-level findings. + `@message` detection is **static**: a `@message` block anywhere in the template (even inside `@if false:`) makes it a messages template. **Mixed content** — loose top-level prose or interpolations alongside `@message` blocks — is a hard diff --git a/examples/edge-cases/27_interior_blank_lines.mds b/examples/edge-cases/27_interior_blank_lines.mds new file mode 100644 index 00000000..247deaa1 --- /dev/null +++ b/examples/edge-cases/27_interior_blank_lines.mds @@ -0,0 +1,40 @@ +--- +title: Interior Blank Lines +--- + +## {title} — whitespace is verbatim as of v0.4.0 + +Interior blank-line runs are preserved byte-for-byte everywhere. Only the +trailing edge of the whole output normalizes to exactly one final newline. + +### Ordinary body + +BEFORE-RUN + + + +AFTER-RUN — exactly three blank lines above, none collapsed. + +### Inside a @define body + +@define spaced(first, second): +{first} + + +{second} — exactly two blank lines above, carried from the macro body. +@end + +{spaced("alpha", "omega")} + +### Inside a @block body + +@block spacer: +block-start + + +block-end — the two blank lines above are verbatim. +@end + +Edge rule: `@define` and `@message` bodies edge-trim (leading and trailing +blank lines of the body are stripped), but interior runs are never collapsed. +`@block` bodies and ordinary text keep even their edge blank lines. diff --git a/examples/edge-cases/28_typed_comparisons.mds b/examples/edge-cases/28_typed_comparisons.mds new file mode 100644 index 00000000..339e958d --- /dev/null +++ b/examples/edge-cases/28_typed_comparisons.mds @@ -0,0 +1,32 @@ +--- +count: 3 +zip_code: "02134" +strict: true +--- + +## Typed comparisons — equality is strict as of v0.4.0 + +Comparing values of different runtime types with `==` or `!=` raises +`mds::type_mismatch` instead of silently returning false. Compare same +types, or convert explicitly with `string()` / `number()`. + +@if count == 3: +- number == number: count is 3 +@end +@if string(count) == "3": +- string(count) bridges number to string: "3" +@end +@if zip_code == "02134": +- string == string: zip_code stays "02134" — the leading zero matters +@end +@if number(zip_code) == 2134: +- number(zip_code) bridges string to number: 2134 — the leading zero is lost +@end +@if strict != false: +- boolean != boolean compares fine +@end + +CLI note: `--set zip_code=02134` coerces the value to the number 2134; +use `--set-string zip_code=02134` to keep the string "02134" byte-for-byte. +Supplying the same key via both `--set` and `--set-string` in one +invocation is a hard error. diff --git a/examples/edge-cases/29_extends_frontmatter_merge.mds b/examples/edge-cases/29_extends_frontmatter_merge.mds new file mode 100644 index 00000000..9b07d212 --- /dev/null +++ b/examples/edge-cases/29_extends_frontmatter_merge.mds @@ -0,0 +1,22 @@ +--- +role: release auditor +focus: + - breaking changes + - frontmatter merge +style: + tone: terse +--- +@extends "../inheritance/base-agent.mds" +@block persona: +You are a {role} verifying the deep-merged frontmatter contract of v0.4.0. +@end +@block capabilities: +The compiled output above starts with the MERGED frontmatter: base keys +plus child keys, child winning on collisions. `audience` comes only from +the base template; `role` is overridden here; `focus` and `style` exist +only in this child. Arrays replace wholesale; nested maps merge per key. +Focus areas: +@for item in focus: +- {item} +@end +@end diff --git a/examples/linting/README.md b/examples/linting/README.md new file mode 100644 index 00000000..897badac --- /dev/null +++ b/examples/linting/README.md @@ -0,0 +1,102 @@ +# Linting demo + +`demo.mds` compiles cleanly (`mds build examples/linting/demo.mds`) but deliberately +trips four lint rules: **duplicate-import** (error, auto-fixable), **unused-import**, +**unused-variable**, and **redundant-else** (warnings). `_shared.mds` is the sibling +module it imports twice. + +All commands below are run from the repository root. + +## Human output + +```console +$ mds lint examples/linting/demo.mds +mds::lint::redundant-else + + ⚠ [redundant-else] The @else body is identical to the @if body — the + │ conditional produces the same output regardless of the condition. + ╭─[demo.mds:16:1] + 15 │ + 16 │ @if audience == "developers": + · ─┬─ + · ╰── The @else body is identical to the @if body — the conditional produces the same output regardless of the condition. + 17 │ Thanks for reading, {audience}. + ╰──── + help: Remove the @else branch or make its content different from the @if + body. + +mds::lint::duplicate-import + + × [duplicate-import] Duplicate import: './_shared.mds' is imported more than + │ once. + ╭─[demo.mds:6:1] + 5 │ @import "./_shared.mds" as shared + 6 │ @import "./_shared.mds" as extra + · ───┬─── + · ╰── Duplicate import: './_shared.mds' is imported more than once. + 7 │ + ╰──── + help: Remove the duplicate import. If different forms are needed (alias vs + merge), consolidate into one import directive. + +mds::lint::unused-variable + + ⚠ [unused-variable] Variable 'retries' is defined in frontmatter but never + │ referenced in the body. + ╭─[demo.mds:3:1] + 2 │ audience: developers + 3 │ retries: 3 + · ───┬─── + · ╰── Variable 'retries' is defined in frontmatter but never referenced in the body. + 4 │ --- + ╰──── + help: Remove the frontmatter key or reference it in the template body. + +mds::lint::unused-import + + ⚠ [unused-import] Import alias 'extra' from './_shared.mds' is never used. + ╭─[demo.mds:6:1] + 5 │ @import "./_shared.mds" as shared + 6 │ @import "./_shared.mds" as extra + · ───┬─── + · ╰── Import alias 'extra' from './_shared.mds' is never used. + 7 │ + ╰──── + help: Remove the @import or use the alias with @include or as a qualified + call (`alias.func(...)`). +``` + +## JSON output + +```console +$ mds lint --format json examples/linting/demo.mds +``` + +```json +{"files":[{"diagnostics":[{"fixable":false,"help":"Remove the @else branch or make its content different from the @if body.","message":"The @else body is identical to the @if body — the conditional produces the same output regardless of the condition.","rule":"redundant-else","severity":"warn","span":{"length":3,"offset":463}},{"fixable":true,"help":"Remove the duplicate import. If different forms are needed (alias vs merge), consolidate into one import directive.","message":"Duplicate import: './_shared.mds' is imported more than once.","rule":"duplicate-import","severity":"error","span":{"length":7,"offset":74}},{"fixable":false,"help":"Remove the frontmatter key or reference it in the template body.","message":"Variable 'retries' is defined in frontmatter but never referenced in the body.","rule":"unused-variable","severity":"warn","span":{"length":7,"offset":25}},{"fixable":false,"help":"Remove the @import or use the alias with @include or as a qualified call (`alias.func(...)`).","message":"Import alias 'extra' from './_shared.mds' is never used.","rule":"unused-import","severity":"warn","span":{"length":7,"offset":74}}],"file":"demo.mds"}],"truncated":false,"version":1} +``` + +## Preview the auto-fix + +```console +$ mds lint --fix --diff examples/linting/demo.mds +--- examples/linting/demo.mds ++++ examples/linting/demo.mds +@@ -3,7 +3,6 @@ + retries: 3 + --- + @import "./_shared.mds" as shared +-@import "./_shared.mds" as extra + + # Lint demo + +``` + +`--fix --diff` (and `--fix --check`) never write. Running plain `--fix` removes the +duplicate import line (which also clears the unused-import warning) and leaves the +two remaining warnings for you to resolve by hand. + +## Exit codes + +`0` clean · `1` warnings only · `2` any error-severity finding or analysis failure +(this demo exits `2`) · `3` resource limit exceeded. diff --git a/examples/linting/_shared.mds b/examples/linting/_shared.mds new file mode 100644 index 00000000..cd98a13c --- /dev/null +++ b/examples/linting/_shared.mds @@ -0,0 +1,4 @@ +@define greet(name): +Hello, {name}! +@end +@export greet diff --git a/examples/linting/demo.mds b/examples/linting/demo.mds new file mode 100644 index 00000000..4677ff50 --- /dev/null +++ b/examples/linting/demo.mds @@ -0,0 +1,20 @@ +--- +audience: developers +retries: 3 +--- +@import "./_shared.mds" as shared +@import "./_shared.mds" as extra + +# Lint demo + +{shared.greet("linter")} This template compiles cleanly, but `mds lint` +flags four issues: a duplicate import of `_shared.mds` (error, auto-fixable), +the unused `extra` alias it creates (warning), the `retries` frontmatter key +that the body never references (warning), and a redundant @else branch whose +body matches the @if body (warning). + +@if audience == "developers": +Thanks for reading, {audience}. +@else: +Thanks for reading, {audience}. +@end diff --git a/examples/node-api-test.mjs b/examples/node-api-test.mjs index 355c311e..ba38d8cd 100644 --- a/examples/node-api-test.mjs +++ b/examples/node-api-test.mjs @@ -627,6 +627,91 @@ test('kind: messages template with zero messages emits empty array', () => { assert(result.messages.length === 0, `expected 0 messages, got ${result.messages.length}`); }); +// ─── Tests: lint API (v0.4.0) ──────────────────────────────────── + +test('lint: canonical shape + unused-variable finding', () => { + const result = mds.lint('---\nused: yes\nnever_used: 1\n---\n# Doc\n\nValue: {used}\n'); + assert(result.version === 1, `lint result version must be 1, got ${result.version}`); + assert(Array.isArray(result.files), 'lint result must have files array'); + assert(result.truncated === false, 'lint result truncated must be false'); + assert(result.files.length === 1, `expected 1 file with findings, got ${result.files.length}`); + assert(result.files[0].file === 'input.mds', `string-source lint file key must be input.mds, got ${result.files[0].file}`); + const diag = result.files[0].diagnostics.find((d) => d.rule === 'unused-variable'); + assert(diag, 'should report unused-variable for never_used'); + assert(diag.severity === 'warn', `unused-variable severity must be warn, got ${diag.severity}`); + assert(diag.message.includes('never_used'), 'diagnostic message should name the variable'); + assert(typeof diag.fixable === 'boolean', 'diagnostic must have boolean fixable'); + assert(diag.span && typeof diag.span.offset === 'number', 'diagnostic should carry a span'); +}); + +test('lintVirtual: duplicate-import finding across a 2-module map', () => { + const result = mds.lintVirtual( + { + 'main.mds': '@import "./lib.mds"\n@import "./lib.mds"\n\n# Main\n', + 'lib.mds': '## Lib\n', + }, + 'main.mds', + ); + assert(result.version === 1 && result.truncated === false, 'canonical lint envelope'); + assert(result.files[0].file === 'main.mds', `file key must be the caller entry name, got ${result.files[0].file}`); + const diag = result.files[0].diagnostics.find((d) => d.rule === 'duplicate-import'); + assert(diag, 'should report duplicate-import'); + assert(diag.severity === 'error', `duplicate-import severity must be error, got ${diag.severity}`); + assert(diag.fixable === true, 'duplicate-import must be auto-fixable'); +}); + +test('lintFile: canonical shape on a real template', async () => { + const result = await mds.lintFile( + resolve(__dirname, 'prompt-library/personas.mds'), + ); + assert(result.version === 1, `lint result version must be 1, got ${result.version}`); + assert(Array.isArray(result.files), 'lintFile result must have files array'); + assert(result.truncated === false, 'lintFile result truncated must be false'); + for (const f of result.files) { + assert(typeof f.file === 'string' && Array.isArray(f.diagnostics), 'each file entry has file + diagnostics'); + } +}); + +// ─── Tests: source maps (v0.4.0) ───────────────────────────────── + +test('compile with sourceMap: version 3 + mappings', () => { + const source = '---\nname: Mapper\n---\n# Hello {name}\n\nLine two.\n'; + const result = mds.compile(source, { sourceMap: true }); + assert(result.kind === 'markdown', 'sourceMap test template is markdown-kind'); + assert(result.sourceMap, 'result.sourceMap must be present when sourceMap: true'); + assert(result.sourceMap.version === 3, `sourceMap version must be 3, got ${result.sourceMap.version}`); + assert(typeof result.sourceMap.mappings === 'string' && result.sourceMap.mappings.length > 0, 'mappings must be a non-empty string'); + assert(Array.isArray(result.sourceMap.sources) && result.sourceMap.sources.length === 1, 'sources must list the single string-source entry'); + assert(Array.isArray(result.sourceMap.names), 'names must be an array'); + assert(!('sourcesContent' in result.sourceMap), 'sourcesContent must be absent unless requested'); +}); + +test('compile with sourceMap + sourcesContent embeds the source', () => { + const source = '---\nname: Mapper\n---\n# Hello {name}\n'; + const result = mds.compile(source, { sourceMap: true, sourcesContent: true }); + assert(Array.isArray(result.sourceMap.sourcesContent), 'sourcesContent must be present when requested'); + assert(result.sourceMap.sourcesContent[0] === source, 'sourcesContent[0] must be the exact original source'); +}); + +test('sourcesContent without sourceMap throws mds::invalid_options', () => { + try { + mds.compile('# Hi\n', { sourcesContent: true }); + assert(false, 'should have thrown mds::invalid_options'); + } catch (err) { + assert(mds.isMdsError(err), `expected MDS error, got ${err}`); + assert(err.code === 'mds::invalid_options', `expected mds::invalid_options, got ${err.code}`); + } +}); + +test('messages template: sourceMap degrades to a warning', () => { + const source = '@message user:\nHello!\n@end\n'; + const result = mds.compile(source, { sourceMap: true }); + assert(result.kind === 'messages', `expected kind==='messages', got ${result.kind}`); + assert(!('sourceMap' in result), 'messages result must not carry a sourceMap'); + assert(result.warnings.length > 0, 'requesting a sourceMap on a messages template must surface a warning'); + assert(result.warnings.some((w) => w.includes('not supported')), 'warning should explain sourceMap is unsupported for messages templates'); +}); + // ─── Run all tests ─────────────────────────────────────────────── console.log(`\nRunning ${tests.length} tests...\n`); diff --git a/examples/source-maps/README.md b/examples/source-maps/README.md new file mode 100644 index 00000000..f27324e4 --- /dev/null +++ b/examples/source-maps/README.md @@ -0,0 +1,69 @@ +# Source maps + +`annotated-prompt.mds` imports helpers from the `_style.mds` partial, so its +source map traces compiled lines back to **two** source files. + +## Build with a source map + +From the repository root: + +```bash +mds build examples/source-maps/annotated-prompt.mds --source-map +``` + +This writes two files next to the template: + +- `annotated-prompt.md` — the compiled output (byte-identical to a build + without `--source-map`) +- `annotated-prompt.md.map` — the sidecar map, named `.map` (the + `.map` extension is appended to the full output filename) + +## How to read the map + +The sidecar is standard [Source Map v3](https://tc39.es/ecma426/) JSON: + +```json +{ + "version": 3, + "file": "annotated-prompt.md", + "sources": ["annotated-prompt.mds", "_style.mds"], + "names": [], + "mappings": ";;;;;;;;AAQA;..." +} +``` + +- `sources` lists every file that contributed output, as paths **relative to + the map file** — the entry template plus each `@import`/`@extends` module. +- `mappings` is Base64-VLQ data: one `;`-separated group per generated line, + each segment mapping a generated column to `(source index, line, column)`. + Any standard source-map consumer (`source-map` on npm, `sourcemap` on PyPI) + can decode it. Content produced by an imported module (for example the + `## Focus areas` heading from `_style.mds`) maps back to the *module* file, + not the entry template. + +## Inline variant + +```bash +mds build examples/source-maps/annotated-prompt.mds --source-map --inline +``` + +No sidecar is written; instead the map is appended to the output as a final +HTML comment: + +``` + +``` + +## Embedding source text + +```bash +mds build examples/source-maps/annotated-prompt.mds --source-map --embed-sources +``` + +This fills `sourcesContent` with the full text of each source file, making the +map self-contained (no access to the `.mds` files needed to inspect sources). + +> **Privacy caveat:** `--embed-sources` ships your complete template text — +> including any comments and internal prompt engineering — inside the map. +> Do not distribute such maps with output you consider the templates +> confidential to. The default (no `--embed-sources`) omits `sourcesContent`. diff --git a/examples/source-maps/_style.mds b/examples/source-maps/_style.mds new file mode 100644 index 00000000..99be1749 --- /dev/null +++ b/examples/source-maps/_style.mds @@ -0,0 +1,11 @@ +@define section(title): +## {title} +@end + +@define rule(text): +- {text} +@end + +@define separator(): +--- +@end diff --git a/examples/source-maps/annotated-prompt.mds b/examples/source-maps/annotated-prompt.mds new file mode 100644 index 00000000..9cb9810e --- /dev/null +++ b/examples/source-maps/annotated-prompt.mds @@ -0,0 +1,23 @@ +--- +agent: release reviewer +focus_areas: + - changelog accuracy + - version consistency + - breaking-change callouts +--- + +@import "./_style.mds" as style + +# System prompt: {agent} + +You are a meticulous {agent} for a software project. + +{style.section("Focus areas")} + +@for area in focus_areas: +{style.rule(area)} +@end + +{style.separator()} + +Review the release notes below and flag anything that violates a focus area. diff --git a/packages/mds-wasm/README.md b/packages/mds-wasm/README.md index cb4d1932..91c33ce9 100644 --- a/packages/mds-wasm/README.md +++ b/packages/mds-wasm/README.md @@ -18,8 +18,45 @@ Two builds, selected by package `exports` conditions: | `node` | `dist/node/mds_wasm.js` | CommonJS (`wasm-pack --target nodejs`) | none | | `browser` / `default` | `dist/web/mds_wasm.js` | ESM (`wasm-pack --target web`) | call `default()` with the `.wasm` URL | -Each build exposes `compile(source, options)`, `check(source, options)`, and -`scanImports(source)`. +Each build exposes `compile(source, options)`, `check(source, options)`, +`lint(source, options)`, `lintVirtual(modules, entry, options)`, and `scanImports(source)`. + +### Options + +```js +// compile(source, options) +// options.filename — string (default "input.mds"): key used for this source in the +// virtual FS and as sources[0] in the generated source map. Override when you want +// a meaningful name to appear in source maps or import paths. +// options.modules — { [key: string]: string }: additional virtual modules for +// @import resolution. The entry source is inserted under options.filename. +// options.vars — { [key: string]: any }: runtime variable overrides. +// options.sourceMap — boolean: generate a Source Map v3 document; result gains .sourceMap. +// sources[0] is options.filename (default "input.mds"). +// options.sourcesContent — boolean: embed original source text in sourcesContent[] +// (requires sourceMap: true). ⚠ Privacy: embeds the full template source. +const result = compile(source, { sourceMap: true, vars: { name: 'World' } }); +// result.sourceMap is a Source Map v3 object when sourceMap: true + +// check(source, options) +// Accepted keys: filename, modules, vars. (sourceMap/sourcesContent are parsed but +// not applied — check does not generate output. Use compile for source maps.) +const checked = check(source, { vars: { name: 'World' } }); +// returns { warnings: string[] } + +// lint(source, options) +// Accepted keys: filename, modules, vars, rules. +// options.rules — { [ruleName: string]: 'off' | 'info' | 'warn' | 'error' } +// Unknown rule names are silently accepted; unknown severity values throw. +const lintResult = lint(source, { rules: { 'shadow-variable': 'warn' } }); +// lintResult: { version: 1, files: [...], truncated: boolean } + +// lintVirtual(modules, entry, options) +// modules: { [key: string]: string } — the full virtual module map. +// entry: string — key of the entry module within modules. +// Accepted option keys: vars, rules. (filename and modules are top-level args, not options.) +const vResult = lintVirtual({ 'main.mds': source }, 'main.mds', { rules: {} }); +``` ## Build diff --git a/packages/mds/README.md b/packages/mds/README.md index aa39a6ac..45996cd5 100644 --- a/packages/mds/README.md +++ b/packages/mds/README.md @@ -77,7 +77,7 @@ try { compile(source); } catch (err) { if (isMdsError(err)) { - console.error(err.code); // e.g. "mds::undefined_variable" + console.error(err.code); // e.g. "mds::undefined_var" console.error(err.message); console.error(err.help); // optional guidance string console.error(err.span); // optional { offset, length, line, column } @@ -91,10 +91,13 @@ try { | Function | Description | |----------|-------------| -| `compile(source, options?)` | Compile MDS source string to Markdown | +| `compile(source, options?)` | Compile MDS source string to Markdown or messages | | `check(source, options?)` | Validate MDS source without rendering | | `compileFile(path, options?)` | Compile an MDS file, resolving imports | | `checkFile(path, options?)` | Validate an MDS file, resolving imports | +| `lint(source, options?)` | Static analysis on a source string | +| `lintFile(path, options?)` | Static analysis on a file | +| `lintVirtual(modules, entry, options?)` | Static analysis on an in-memory module map | | `getBackend()` | Returns the active backend: `'native'` or `'wasm'` | | `init(options?)` | Initialize the WASM backend (browser/explicit WASM only) | | `isMdsError(err)` | Type guard for MDS compiler errors (requires `code` starting with `"mds::"`) | @@ -102,9 +105,55 @@ try { ### Options ```ts -// CompileOptions / FileOptions -{ vars?: Record } +// CompileOptions — accepted by compile() and compileFile() +interface CompileOptions { + vars?: Record; + sourceMap?: boolean; // generate Source Map v3; result gains a `sourceMap` field + sourcesContent?: boolean; // embed source text in map (requires sourceMap: true) + // ⚠ Privacy: embeds the full template source +} + +// CheckOptions — accepted by check() and checkFile() only +// Source-map options are NOT accepted; passing them throws mds::invalid_options +interface CheckOptions { + vars?: Record; +} + +// LintOptions — accepted by lint() (string-source) +interface LintOptions { + vars?: Record; + rules?: Record; + basePath?: string; // base directory for @import resolution; required when the source + // contains @import or @extends. Ignored by the WASM backend. +} + +// LintFileOptions — accepted by lintFile() and lintVirtual() +// basePath is NOT accepted: lintFile derives the base directory from the file path; +// lintVirtual resolves imports against the caller-supplied module map, not the filesystem. +interface LintFileOptions { + vars?: Record; + rules?: Record; +} // InitOptions -{ wasmUrl?: string | URL | Response | BufferSource } +interface InitOptions { + wasmUrl?: string | URL | Response | BufferSource; +} +``` + +**Strict unknown-option rejection:** passing any key not listed above throws +`Error { code: 'mds::invalid_options' }` immediately, before calling the backend. +This applies to `compile`, `compileFile`, `check`, `checkFile`, `lint`, `lintFile`, +and `lintVirtual`. + +**Source maps:** for string-source compiles (`compile`) `sources[0]` in the generated +map is `"input.mds"`. For stdin builds via the CLI it is `""`. + +**Lint `rules` map:** unknown rule names are silently accepted (a typo has no effect); +unknown severity values throw `mds::invalid_options`. + +**Lint result shape:** +```ts +{ version: 1, files: [{ file: string, diagnostics: LintDiagnostic[] }], truncated: boolean } +// LintDiagnostic: { rule, severity, message, help?, fixable, span? } ``` diff --git a/packages/mds/__test__/options-validation.spec.mjs b/packages/mds/__test__/options-validation.spec.mjs new file mode 100644 index 00000000..ad3741ff --- /dev/null +++ b/packages/mds/__test__/options-validation.spec.mjs @@ -0,0 +1,401 @@ +/** + * Options-validation tests — assertKnownKeys wrapper-level enforcement. + * Tests: U-OV-1 through U-OV-20 + * + * Verifies that the universal @mdscript/mds wrapper rejects unknown option keys + * with code === 'mds::invalid_options' before dispatching to any backend. + * Since validation runs inside the wrapper (before backend dispatch) it is + * backend-agnostic: the same rejection fires on native and WASM paths. + * + * U-OV-14 performs a byte-identical message parity check across all seven + * methods against the native napi backend. That test hard-fails when the + * native addon is absent — a silently-passing skip is how parity regressions + * survive undetected (avoids PF-007). + */ +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { + compile, + check, + compileFile, + checkFile, + lint, + lintFile, + lintVirtual, + isMdsError, + init, +} from '../dist/node.js'; +import { assertKnownKeys } from '../dist/util/options.js'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const require = createRequire(import.meta.url); + +describe('options-validation', () => { + before(() => init()); + + // ── compile: typo'd key ──────────────────────────────────────────────────── + + test('U-OV-1: compile rejects unknown key "sourceMaps"', () => { + assert.throws( + () => compile('Hello\n', { sourceMaps: true }), + (err) => { + assert.ok(isMdsError(err), `expected isMdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"sourceMaps"'), `key name in message: ${err.message}`); + assert.ok(err.message.includes('recognised keys are:'), `format check: ${err.message}`); + return true; + }, + ); + }); + + test('U-OV-2: compile rejects unknown key "varsJson"', () => { + assert.throws( + () => compile('Hello\n', { varsJson: '{}' }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + return true; + }, + ); + }); + + test('U-OV-3: compile rejects snake_case alias "source_map"', () => { + assert.throws( + () => compile('Hello\n', { source_map: true }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + return true; + }, + ); + }); + + // ── check: typo'd key ───────────────────────────────────────────────────── + + test('U-OV-4: check rejects unknown key "base_path" (snake_case)', () => { + assert.throws( + () => check('Hello\n', { base_path: '.' }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"base_path"'), `key name in message: ${err.message}`); + return true; + }, + ); + }); + + test('U-OV-5: check rejects sourceMap (not valid for check)', () => { + assert.throws( + () => check('Hello\n', { sourceMap: true }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + return true; + }, + ); + }); + + // ── lint: typo'd key ────────────────────────────────────────────────────── + + test('U-OV-6: lint rejects unknown key "base_path" (snake_case)', () => { + assert.throws( + () => lint('Hello\n', { base_path: '.' }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"base_path"'), `key name: ${err.message}`); + return true; + }, + ); + }); + + // ── lintVirtual: basePath not allowed there ──────────────────────────────── + + test('U-OV-7: lintVirtual rejects basePath (not in LintFileOptions)', () => { + const modules = { 'a.mds': 'Hello\n' }; + assert.throws( + () => lintVirtual(modules, 'a.mds', { basePath: '.' }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"basePath"'), `key name: ${err.message}`); + return true; + }, + ); + }); + + // ── valid options pass through ───────────────────────────────────────────── + + test('U-OV-8: compile accepts all valid keys without error', () => { + assert.doesNotThrow(() => compile('Hello\n', { vars: {}, sourceMap: false, sourcesContent: false })); + // basePath is now also accepted (reconciled with napi parse_compile_opts — issue #180) + assert.doesNotThrow(() => compile('Hello\n', { basePath: '.' })); + }); + + test('U-OV-9: check accepts vars and basePath without error', () => { + assert.doesNotThrow(() => check('Hello\n', { vars: {} })); + // basePath is now also accepted (reconciled with napi parse_check_opts — issue #180) + assert.doesNotThrow(() => check('Hello\n', { basePath: '.' })); + }); + + test('U-OV-10: no options does not throw', () => { + assert.doesNotThrow(() => compile('Hello\n')); + assert.doesNotThrow(() => compile('Hello\n', undefined)); + assert.doesNotThrow(() => check('Hello\n')); + assert.doesNotThrow(() => check('Hello\n', undefined)); + }); + + // ── multiple unknown keys ────────────────────────────────────────────────── + + test('U-OV-11: compile rejects multiple unknown keys, lists them all', () => { + assert.throws( + () => compile('Hello\n', { sourceMaps: true, varsJson: '{}' }), + (err) => { + assert.ok(isMdsError(err)); + assert.equal(err.code, 'mds::invalid_options'); + // Plural form: "unknown option keys:" + assert.ok(err.message.startsWith('unknown option keys:'), `plural form: ${err.message}`); + assert.ok(err.message.includes('"sourceMaps"') && err.message.includes('"varsJson"')); + return true; + }, + ); + }); + + // ── async file-ops throw unknown-key errors synchronously ──────────────── + // + // checkFile and lintFile are NOT async functions: assertKnownKeys() fires + // before any I/O and throws synchronously. The tests are intentionally + // non-async and use assert.throws (not assert.rejects) so that a regression + // to async would be caught: Node.js assert.rejects() with a validator + // function does NOT intercept synchronous throws in v22, so the error would + // escape the validator and the test would fail with "testCodeFailure" rather + // than "Missing expected exception", masking the regression. + + test('U-OV-12: checkFile throws synchronously for unknown key (sourceMap not valid for check)', () => { + // assertKnownKeys fires before any I/O, so no real file is needed. + assert.throws( + () => checkFile('/any.mds', { sourceMap: true }), + (err) => { + assert.ok(isMdsError(err), `expected isMdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options'); + return true; + }, + ); + }); + + test('U-OV-13: lintFile throws synchronously for unknown key "basePath"', () => { + // assertKnownKeys fires before any I/O, so no real file is needed. + assert.throws( + () => lintFile('/any.mds', { basePath: '.' }), + (err) => { + assert.ok(isMdsError(err), `expected isMdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options'); + return true; + }, + ); + }); + + // ── message-parity: wrapper format matches backend format (all 7 methods) ── + + test('U-OV-14: wrapper error message is byte-identical to napi for all seven methods (avoids PF-007)', async () => { + // Hard-fail if native addon not available — a silently-passing skip is exactly + // how parity regressions survive undetected (PF-007: per-surface goldens each + // lock in their own value, defeating cross-surface parity). + const addon = require('@mdscript/mds-napi'); + + // A key not recognised by any method: triggers the standard + // "unknown option key" format from both wrapper and napi. + // 'sourceMaps' is a common typo for 'sourceMap'; not in any method's list. + const BAD_OPT = { sourceMaps: true }; + + // Virtual modules for lintVirtual: modules must be valid before options are checked. + const VIRTUAL_MODS = { 'a.mds': '' }; + const VIRTUAL_ENTRY = 'a.mds'; + + // Call fn(), awaiting if it returns a Promise. Returns the error message if fn + // throws (sync or async), or an empty string if it does not throw. + async function captureMsg(fn) { + try { + const result = fn(); + if (result != null && typeof result === 'object' && typeof result.then === 'function') { + await result; + } + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + return ''; + } + + const cases = [ + { + name: 'compile', + wrapperFn: () => compile('', BAD_OPT), + addonFn: () => addon.compile('', BAD_OPT), + }, + { + name: 'check', + wrapperFn: () => check('', BAD_OPT), + addonFn: () => addon.check('', BAD_OPT), + }, + { + // Options are validated before file I/O in both the wrapper and napi. + name: 'compileFile', + wrapperFn: () => compileFile('/nonexistent.mds', BAD_OPT), + addonFn: () => addon.compileFile('/nonexistent.mds', BAD_OPT), + }, + { + name: 'checkFile', + wrapperFn: () => checkFile('/nonexistent.mds', BAD_OPT), + addonFn: () => addon.checkFile('/nonexistent.mds', BAD_OPT), + }, + { + name: 'lint', + wrapperFn: () => lint('', BAD_OPT), + addonFn: () => addon.lint('', BAD_OPT), + }, + { + // Options are validated before file I/O in napi. + name: 'lintFile', + wrapperFn: () => lintFile('/nonexistent.mds', BAD_OPT), + addonFn: () => addon.lintFile('/nonexistent.mds', BAD_OPT), + }, + { + name: 'lintVirtual', + wrapperFn: () => lintVirtual(VIRTUAL_MODS, VIRTUAL_ENTRY, BAD_OPT), + addonFn: () => addon.lintVirtual(VIRTUAL_MODS, VIRTUAL_ENTRY, BAD_OPT), + }, + ]; + + for (const { name, wrapperFn, addonFn } of cases) { + const wrapperMsg = await captureMsg(wrapperFn); + const addonMsg = await captureMsg(addonFn); + + assert.ok(wrapperMsg.length > 0, `wrapper should have thrown for ${name}`); + assert.ok(addonMsg.length > 0, `napi backend should have thrown for ${name}`); + assert.strictEqual( + wrapperMsg, + addonMsg, + `byte-identical messages required for ${name} — wrapper: "${wrapperMsg}" | napi: "${addonMsg}"`, + ); + } + }); + + // ── basePath reconciliation: compile and check (issue #72 / user decision) ─ + + test('U-OV-15: compile now accepts basePath (reconciled with napi parse_compile_opts — issue #180)', () => { + // napi's parse_compile_opts has always accepted basePath; the wrapper was wrong to + // reject it. After the fix, the wrapper no longer intercepts it. + assert.doesNotThrow( + () => compile('Hello\n', { basePath: '.' }), + 'compile must not throw invalid_options for basePath after wrapper reconciliation', + ); + }); + + test('U-OV-16: check now accepts basePath (reconciled with napi parse_check_opts — issue #180)', () => { + // napi's parse_check_opts has always accepted basePath; the wrapper was wrong to + // reject it. After the fix, the wrapper no longer intercepts it. + assert.doesNotThrow( + () => check('Hello\n', { basePath: '.' }), + 'check must not throw invalid_options for basePath after wrapper reconciliation', + ); + }); + + // ── basePath passthrough on file methods (issue #74) ────────────────────── + + test('U-OV-17: compileFile does not intercept basePath with a generic unknown-key message (issue #74)', async () => { + // Before the fix, the wrapper emitted "unknown option key 'basePath'; recognised + // keys are: vars, sourceMap, sourcesContent", masking the backend's purpose-built + // "not valid for compileFile/checkFile" message. After the fix, basePath is passed + // through without wrapper interception. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mds-test-')); + const file = path.join(tmp, 'ok.mds'); + fs.writeFileSync(file, 'Hello\n', 'utf8'); + try { + let errorMsg = ''; + try { + await compileFile(file, { basePath: '.' }); + } catch (e) { + errorMsg = e instanceof Error ? e.message : String(e); + } + assert.ok( + !errorMsg.startsWith('unknown option key "basePath"'), + `wrapper must not intercept basePath for compileFile with a generic message; got: "${errorMsg}"`, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('U-OV-18: checkFile does not intercept basePath with a generic unknown-key message (issue #74)', async () => { + // Same as U-OV-17 but for checkFile. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mds-test-')); + const file = path.join(tmp, 'ok.mds'); + fs.writeFileSync(file, 'Hello\n', 'utf8'); + try { + let errorMsg = ''; + try { + await checkFile(file, { basePath: '.' }); + } catch (e) { + errorMsg = e instanceof Error ? e.message : String(e); + } + assert.ok( + !errorMsg.startsWith('unknown option key "basePath"'), + `wrapper must not intercept basePath for checkFile with a generic message; got: "${errorMsg}"`, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + // ── prototype-chain safety (issue #18 regression prevention) ───────────── + + test('U-OV-19: prototype-chain method names are handled without TypeError (issue #18)', () => { + // The MethodName literal union catches these at compile time. The hasOwnProperty + // guard in assertKnownKeys prevents a TypeError at runtime when the union is bypassed + // via a cast. Verified by calling assertKnownKeys directly in JavaScript (no TS + // type enforcement). + for (const badMethod of ['toString', 'constructor', '__proto__', 'valueOf', 'hasOwnProperty']) { + assert.doesNotThrow( + () => assertKnownKeys({}, badMethod), + `assertKnownKeys({}, '${badMethod}') must not throw TypeError`, + ); + } + }); + + test('U-OV-20: prototype-chain option keys are handled correctly (issue #18)', () => { + // 'toString' and 'constructor' ARE own enumerable properties when set via object + // literal syntax — Object.keys returns them, and they are correctly rejected as + // unknown keys (mds::invalid_options), not TypeError. + assert.throws( + () => compile('Hello\n', { toString: 'x' }), + (err) => { + assert.ok(isMdsError(err), `expected isMdsError for "toString" key, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"toString"'), `key name in message: ${err.message}`); + return true; + }, + '"toString" as option key must be rejected with invalid_options, not TypeError', + ); + + assert.throws( + () => compile('Hello\n', { constructor: 'x' }), + (err) => { + assert.ok(isMdsError(err), `expected isMdsError for "constructor" key, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options'); + assert.ok(err.message.includes('"constructor"'), `key name in message: ${err.message}`); + return true; + }, + '"constructor" as option key must be rejected with invalid_options, not TypeError', + ); + + // '__proto__' in an object literal sets the prototype, not an own enumerable property, + // so Object.keys returns [] and no unknown-key error fires. No crash. + assert.doesNotThrow( + () => compile('Hello\n', { __proto__: 'x' }), + '__proto__ in object literal is not an own enumerable key; no invalid_options error should fire', + ); + }); +}); diff --git a/packages/mds/__test__/source-map.spec.mjs b/packages/mds/__test__/source-map.spec.mjs index c00199a6..8443dde2 100644 --- a/packages/mds/__test__/source-map.spec.mjs +++ b/packages/mds/__test__/source-map.spec.mjs @@ -17,9 +17,67 @@ */ import { test, describe, before } from 'node:test'; import assert from 'node:assert/strict'; -import { compile, compileFile, isMdsError, init } from '../dist/node.js'; +import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { existsSync, statSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { compile, compileFile, checkFile, lintFile, isMdsError, init } from '../dist/node.js'; import { SIMPLE_MDS } from './helpers.mjs'; import { initWasmNode, createWasmBackend } from '../dist/backend/wasm.js'; +import { buildModulesMap } from '../dist/util/module-scanner.js'; + +// --------------------------------------------------------------------------- +// CF-SM helpers — locate CLI binary and Python interpreter for 4-surface +// differential tests. Both searches follow the same priority as conftest.py: +// explicit env var > freshest build artifact > system PATH. +// --------------------------------------------------------------------------- + +/** Absolute path to the repo root (three levels above this test file). */ +const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); + +/** + * Return the path to the `mds` CLI binary, or null if none can be found. + * Prefers the most recently modified of target/{release,debug}/mds. + */ +function findMdsCli() { + const envBin = process.env.MDS_CLI_BIN; + if (envBin) { + if (!existsSync(envBin)) throw new Error(`MDS_CLI_BIN=${envBin} does not exist`); + return envBin; + } + const exe = process.platform === 'win32' ? 'mds.exe' : 'mds'; + const candidates = ['release', 'debug'] + .map((p) => join(REPO_ROOT, 'target', p, exe)) + .filter(existsSync); + if (!candidates.length) return null; + return candidates.reduce((a, b) => statSync(a).mtimeMs >= statSync(b).mtimeMs ? a : b); +} + +/** + * Return the path to a Python interpreter that can import `mdscript`, or null. + * + * Resolution order: + * 1. MDS_PYTHON_BIN env var (set by CI to the pip-managed interpreter). + * 2. Repo-local venv at .venv/bin/python3 (set up by `maturin develop`). + * 3. System `python3` on PATH (best-effort fallback for local dev). + * + * Returns null only when none of the above is found. In CI (process.env.CI) + * the caller must treat null as a hard failure — see PF-007. + */ +function findPythonForMdscript() { + const envBin = process.env.MDS_PYTHON_BIN; + if (envBin) { + if (!existsSync(envBin)) throw new Error(`MDS_PYTHON_BIN=${envBin} does not exist`); + return envBin; + } + const venvPy = join(REPO_ROOT, '.venv', 'bin', 'python3'); + if (existsSync(venvPy)) return venvPy; + const res = spawnSync('which', ['python3'], { encoding: 'utf-8' }); + if (res.status === 0 && res.stdout.trim()) return res.stdout.trim(); + return null; +} // --------------------------------------------------------------------------- // Hand-rolled Base64-VLQ decoder (no external dependency) @@ -134,8 +192,10 @@ describe('source maps (U-SM)', () => { const result = compile('Hello World!\n', { sourceMap: true }); assert.ok('sourceMap' in result, 'sourceMap key must be present'); assertSmStructure(result.sourceMap); - // String-source compilation uses "" as the entry label. - assert.deepEqual(result.sourceMap.sources, ['']); + // String-source compilation uses "input.mds" as the entry label (unified + // with the WASM backend via the STRING_SOURCE_MAP_LABEL choke-point fix). + assert.deepEqual(result.sourceMap.sources, ['input.mds'], + `string-source sources[0] must be "input.mds" after map_source_label fix; got: ${JSON.stringify(result.sourceMap.sources)}`); assert.ok(result.sourceMap.mappings.length > 0, 'mappings must be non-empty for non-trivial content'); }); @@ -374,19 +434,20 @@ describe('VLQ decoder self-test (VLQ-SELF)', () => { // W-SM1: WASM compile() emits a valid SMv3 sourceMap for simple input // W-SM2: cross-field guard — sourcesContent:true without sourceMap:true // throws mds::invalid_options on the WASM backend -// W-SM3: WASM and native backends produce byte-identical mappings for the -// same input (AC-API-04 / ADR-002: shared core serializer) +// W-SM3: WASM and native backends produce IDENTICAL sourceMaps for the +// same input (AC-API-04 / ADR-002 / PF-007 cross-surface parity) // -// The WASM default filename is "input.mds" (vs "" for the native -// backend), so sources[] will differ by convention; mappings are compared -// without the filename entry. +// After the STRING_SOURCE_MAP_LABEL choke-point fix both backends emit +// "input.mds" in sources[0], so the full sourceMap (including sources[]) +// is now comparable. W-SM3 is now a true differential test (PF-007). // --------------------------------------------------------------------------- describe('source maps — WASM backend (W-SM)', () => { + let wasmMod; let wasmBackend; before(async () => { - const wasmMod = await initWasmNode(); + wasmMod = await initWasmNode(); wasmBackend = createWasmBackend(wasmMod); }); @@ -431,14 +492,16 @@ describe('source maps — WASM backend (W-SM)', () => { ); }); - // ── W-SM3: WASM vs native byte-identical parity (AC-API-04) ─────────── + // ── W-SM3: WASM vs native full sourceMap parity (PF-007 differential) ─ // - // Both backends delegate to the same mds-core serializer, so mappings, - // version, and names must be byte-identical. sources[] is intentionally - // excluded from this check because the WASM backend uses a different - // default filename convention ("input.mds" vs ""). - - test('W-SM3: WASM and native backends produce byte-identical mappings for same input', () => { + // Both backends delegate to the same mds-core serializer. After the + // STRING_SOURCE_MAP_LABEL choke-point fix (map_source_label in + // MapBuilder::new / source_index) both backends emit "input.mds" in + // sources[0], so the ENTIRE sourceMap object is now identical. + // This is a true differential test (PF-007): compare backends to each + // other rather than to per-surface constants. + + test('W-SM3: WASM and native backends produce identical full sourceMap for same input', () => { const src = 'Hello World!\n'; const nativeResult = compile(src, { sourceMap: true }); const wasmResult = wasmBackend.compile(src, { sourceMap: true }); @@ -446,22 +509,390 @@ describe('source maps — WASM backend (W-SM)', () => { assert.ok(nativeResult.sourceMap != null, 'native must produce sourceMap'); assert.ok(wasmResult.sourceMap != null, 'wasm must produce sourceMap'); + assertSmStructure(nativeResult.sourceMap); assertSmStructure(wasmResult.sourceMap); - assert.equal( - nativeResult.sourceMap.version, - wasmResult.sourceMap.version, - 'version must match across backends', + // Full object comparison — avoids PF-007: per-field assertions cannot catch + // new fields being added or fields that differ unexpectedly. The ENTIRE + // sourceMap object must be byte-identical across backends (ADR-002: shared + // core serializer; PF-007: differential test rather than per-surface golden). + assert.deepEqual( + nativeResult.sourceMap, + wasmResult.sourceMap, + `full sourceMap must be identical across backends (PF-007); ` + + `native=${JSON.stringify(nativeResult.sourceMap)}, wasm=${JSON.stringify(wasmResult.sourceMap)}`, ); + }); + + // ── W-SM3b: cross-backend parity without sourcesContent ───────────────── + // + // Same input, sourcesContent:true — verify that both backends embed + // identical source content and the labeling is consistent (PF-007). + + test('W-SM3b: WASM and native produce identical sourceMap with sourcesContent:true', () => { + const src = 'Hello World!\n'; + const nativeResult = compile(src, { sourceMap: true, sourcesContent: true }); + const wasmResult = wasmBackend.compile(src, { sourceMap: true, sourcesContent: true }); + + assert.ok(nativeResult.sourceMap != null, 'native must produce sourceMap'); + assert.ok(wasmResult.sourceMap != null, 'wasm must produce sourceMap'); + + assert.deepEqual( + nativeResult.sourceMap.sources, + wasmResult.sourceMap.sources, + `sources[] must match across backends with sourcesContent; native=${JSON.stringify(nativeResult.sourceMap.sources)}`, + ); + assert.deepEqual( + nativeResult.sourceMap.sourcesContent, + wasmResult.sourceMap.sourcesContent, + 'sourcesContent must be identical across backends', + ); + }); + + // ── V-SM1: virtual module map path — WASM compile with filename option ───── + // + // PF-007: per-surface goldens cannot catch cross-surface divergence. This + // differential test verifies that when the WASM backend is given an explicit + // `filename` (as buildModulesMap produces for the compileFile path), the + // resulting sources[] is byte-identical to what the native compile produces + // for the same source. + // + // The modules:{} (empty) arg exercises the modules-map code path without + // requiring any @import directives. The key assertion is deepEqual on + // sources[], not per-surface goldens (catches future divergence between + // STRING_SOURCE_MAP_LABEL and the WASM filename passthrough). + + test('V-SM1: WASM compile() with explicit filename + empty modules produces same sources[] as native', () => { + const src = 'Hello World!\n'; + + // Native compile: sources[0] = STRING_SOURCE_MAP_LABEL = "input.mds". + const nativeResult = compile(src, { sourceMap: true }); + + // WASM compile with the same filename the modules-map path would produce + // (the buildModulesMap default when the project root is the entry's directory). + const wasmResult = wasmBackend.compile(src, { + filename: 'input.mds', + modules: {}, + sourceMap: true, + }); + + assert.ok(nativeResult.sourceMap != null, 'native must produce sourceMap'); + assert.ok(wasmResult.sourceMap != null, 'wasm must produce sourceMap'); + + // deepEqual comparison — avoids PF-007: per-field assertions cannot detect + // future divergence in how native vs WASM label the virtual entry source. assert.deepEqual( - nativeResult.sourceMap.names, - wasmResult.sourceMap.names, - 'names must match across backends', + nativeResult.sourceMap.sources, + wasmResult.sourceMap.sources, + `sources[] must be identical across backends on the modules-map path (PF-007); ` + + `native=${JSON.stringify(nativeResult.sourceMap.sources)}, ` + + `wasm-modules=${JSON.stringify(wasmResult.sourceMap.sources)}`, ); - assert.equal( - nativeResult.sourceMap.mappings, - wasmResult.sourceMap.mappings, - 'mappings must be byte-identical across backends (ADR-002: shared core serializer)', + }); +}); + +// --------------------------------------------------------------------------- +// CF-SM1: compileFile differential — native napi vs WASM-via-buildModulesMap +// +// Catches the live PF-007 instance: the universal @mdscript/mds compileFile +// returned different sources[] depending on which backend init() loaded. +// WASM produced root-relative keys (buildModulesMap) while native produced +// absolute paths before the Phase A relativize_source choke-point fix. +// +// The test manually replicates the WASM compileFile code path (buildModulesMap +// + wasmModule.compile) and compares its sources[] against the native compileFile. +// After the fix both must produce identical root-relative sources[]. +// +// A temp dir with .mdsroot is used so buildModulesMap can locate the project +// root and produce the correct root-relative entry filename. +// --------------------------------------------------------------------------- + +describe('source maps — compileFile differential (CF-SM)', () => { + let wasmMod; + + before(async () => { + // init() loads the native backend; wasmMod gives us the WASM path. + await init(); + wasmMod = await initWasmNode(); + }); + + test('CF-SM1: native compileFile and WASM-via-buildModulesMap produce identical sources[]', async () => { + // Create a temp dir with .mdsroot so buildModulesMap identifies it as the + // project root, making the entry filename root-relative. + const dir = await mkdtemp(join(tmpdir(), 'cf-sm1-')); + try { + await writeFile(join(dir, '.mdsroot'), ''); + await writeFile(join(dir, 'hello.mds'), 'Hello World!\n'); + const filePath = join(dir, 'hello.mds'); + + // Native compileFile: uses NativeFs which now emits root-relative paths + // (source_path.rs relativize_source choke-point, ADR-005 Phase A fix). + const nativeResult = await compileFile(filePath, { sourceMap: true }); + + // WASM compileFile simulation: the exact code path wrapWithFileOps uses. + // buildModulesMap returns entryFilename as a root-relative slash path + // (e.g. "hello.mds") and populates modules with the file source. + const { entryFilename, modules } = await buildModulesMap( + filePath, + (src) => wasmMod.scanImports(src), + ); + const entrySource = modules[entryFilename]; + // Remove entry from modules — WASM inserts it separately under filename. + delete modules[entryFilename]; + const wasmResult = createWasmBackend(wasmMod).compile(entrySource, { + filename: entryFilename, + modules, + sourceMap: true, + }); + + assert.ok(nativeResult.sourceMap != null, 'native compileFile must produce sourceMap'); + assert.ok(wasmResult.sourceMap != null, 'wasm compileFile path must produce sourceMap'); + + // Full deepEqual on sources[] — the PF-007 failure mode was that native + // produced absolute path while WASM produced root-relative key; now both + // must produce the same root-relative value ("hello.mds"). + assert.deepEqual( + nativeResult.sourceMap.sources, + wasmResult.sourceMap.sources, + `compileFile sources[] must be identical across backends (PF-007); ` + + `native=${JSON.stringify(nativeResult.sourceMap.sources)}, ` + + `wasm=${JSON.stringify(wasmResult.sourceMap.sources)}`, + ); + + // Extra guard: sources[0] must be root-relative (just the filename, no + // absolute path prefix) — ensures the choke-point fix is actually active. + const src0 = nativeResult.sourceMap.sources[0]; + assert.ok( + !src0.includes('/') || src0.startsWith('./') || src0 === src0.replace(/^\//, ''), + `sources[0] must not be an absolute path; got: ${src0}`, + ); + assert.ok( + src0.endsWith('hello.mds'), + `sources[0] must end with "hello.mds"; got: ${src0}`, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + // ------------------------------------------------------------------------- + // CF-SM2: all four surfaces produce identical sources[] for a nested fixture + // + // Closes the remaining PF-007 gap: CF-SM1 only compared napi ↔ WASM; the + // CLI and Python surfaces each had per-surface goldens that could diverge + // without detection. This test extends the differential to all four surfaces + // using a fixture that exercises cross-directory @import (not just a single + // root-level file), so path relativization from a non-root source is tested. + // + // Fixture layout: + // /.mdsroot ← project root marker + // /src/entry.mds ← entry: @import "../partials/greeting.mds" as g + // /partials/greeting.mds ← @define greet(): Hello! @end @export greet + // + // CLI is invoked with -o /out.md so the sidecar map lands at the project + // root level, making sources[] root-relative — identical to the binding output + // (QA-verified byte-identical when map anchor equals project root). + // + // Python is invoked via the repo-local venv when available; the test is skipped + // for the Python surface only when no usable interpreter is found, so it never + // silently passes against a missing surface — the other three still run. + // ------------------------------------------------------------------------- + test('CF-SM2: napi, WASM, CLI, and Python produce identical sources[] for nested @import fixture', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cf-sm2-')); + try { + // -- Setup fixture ------------------------------------------------------- + await writeFile(join(dir, '.mdsroot'), ''); + await mkdir(join(dir, 'src'), { recursive: true }); + await mkdir(join(dir, 'partials'), { recursive: true }); + await writeFile( + join(dir, 'partials', 'greeting.mds'), + '@define greet():\nHello!\n@end\n@export greet\n', + ); + await writeFile( + join(dir, 'src', 'entry.mds'), + '@import "../partials/greeting.mds" as g\n{g.greet()}\n', + ); + const entryPath = join(dir, 'src', 'entry.mds'); + + // -- Surface 1: napi compileFile ----------------------------------------- + const napiResult = await compileFile(entryPath, { sourceMap: true }); + assert.ok(napiResult.sourceMap != null, 'napi compileFile must produce sourceMap'); + const napiSources = napiResult.sourceMap.sources; + + // -- Surface 2: WASM via buildModulesMap + wasmMod.compile --------------- + const { entryFilename, modules } = await buildModulesMap( + entryPath, + (src) => wasmMod.scanImports(src), + ); + const entrySource = modules[entryFilename]; + const wasmModules = { ...modules }; + delete wasmModules[entryFilename]; + const wasmResult = createWasmBackend(wasmMod).compile(entrySource, { + filename: entryFilename, + modules: wasmModules, + sourceMap: true, + }); + assert.ok(wasmResult.sourceMap != null, 'WASM path must produce sourceMap'); + const wasmSources = wasmResult.sourceMap.sources; + + // -- Surface 3: CLI sidecar at project root ------------------------------- + // Output at /out.md so the .map file anchors at the project root, + // making CLI sources[] root-relative and byte-identical to the bindings. + const mdsCli = findMdsCli(); + assert.ok( + mdsCli != null, + 'mds CLI binary not found — set MDS_CLI_BIN or run `cargo build -p mds-cli`', + ); + const outFile = join(dir, 'out.md'); + const mapFile = join(dir, 'out.md.map'); + const cliProc = spawnSync(mdsCli, ['build', '--source-map', '-o', outFile, entryPath], { + encoding: 'utf-8', + }); + assert.equal( + cliProc.status, + 0, + `CLI build --source-map failed (rc=${cliProc.status}): ${cliProc.stderr}`, + ); + assert.ok(existsSync(mapFile), `CLI did not write sidecar map at ${mapFile}`); + const cliMap = JSON.parse(readFileSync(mapFile, 'utf-8')); + const cliSources = cliMap.sources; + + // -- Surface 4: Python binding compile_file ------------------------------- + // Use the repo-local venv Python which has the mdscript module installed + // by `maturin develop`, or the interpreter exported by MDS_PYTHON_BIN (CI). + // In CI all four surfaces must run — a missing surface is a hard failure + // (avoids PF-007: a gate that silently skips a surface reads as green). + // Locally, a missing interpreter warns and skips the Python leg only. + const python = findPythonForMdscript(); + let pySources = null; + if (python == null) { + if (process.env.CI) { + throw new Error( + 'CF-SM2: Python surface is required in CI but no interpreter was found. ' + + 'Set MDS_PYTHON_BIN to an interpreter that can import mdscript, or ' + + 'install the binding with `pip install ./crates/mds-python`. ' + + 'A missing surface silently breaks the PF-007 cross-surface parity gate.', + ); + } + console.warn( + 'CF-SM2: skipping Python surface — no Python interpreter found ' + + '(run `maturin develop` inside .venv to enable)', + ); + } else { + // Import mdscript from the Python environment directly (site-packages). + // Do NOT insert the source tree into sys.path: with `pip install`, the + // compiled extension (_mdscript.so) lands in site-packages, not in the + // source crates/mds-python/python/ directory, so prepending the source + // path causes `from ._mdscript import` to fail (it finds __init__.py in + // the source tree but the .so is elsewhere). Importing from site-packages + // works for both `pip install ./crates/mds-python` (CI) and + // `maturin develop` (local), since both make `mdscript` importable from + // the standard path. Pass entryPath as argv so no shell escaping is needed. + const pyScript = [ + 'import json, sys', + 'import mdscript as m', + 'result = m.compile_file(sys.argv[1], source_map=True)', + 'print(json.dumps(result.source_map["sources"]))', + ].join('\n'); + const pyProc = spawnSync(python, ['-c', pyScript, entryPath], { + encoding: 'utf-8', + }); + assert.equal( + pyProc.status, + 0, + `Python compile_file failed (rc=${pyProc.status}): ${pyProc.stderr}`, + ); + pySources = JSON.parse(pyProc.stdout.trim()); + } + + // -- Differential assertions -------------------------------------------- + // All surfaces that ran must agree on sources[]. + assert.deepEqual( + napiSources, + wasmSources, + `napi vs WASM sources[] mismatch (PF-007): ` + + `napi=${JSON.stringify(napiSources)} wasm=${JSON.stringify(wasmSources)}`, + ); + assert.deepEqual( + napiSources, + cliSources, + `napi vs CLI sources[] mismatch: ` + + `napi=${JSON.stringify(napiSources)} cli=${JSON.stringify(cliSources)}`, + ); + if (pySources != null) { + assert.deepEqual( + napiSources, + pySources, + `napi vs Python sources[] mismatch: ` + + `napi=${JSON.stringify(napiSources)} python=${JSON.stringify(pySources)}`, + ); + } + + // Extra invariant: all sources[] entries must be root-relative (not absolute). + for (const src of napiSources) { + assert.ok( + !src.startsWith('/') && !src.match(/^[A-Za-z]:[/\\]/), + `sources[] entry must be root-relative, not absolute: ${src}`, + ); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Regression: file-op error-contract (issue #35) +// +// checkFile and lintFile must NOT be async functions. When they were async, +// assertKnownKeys() (which runs synchronously before any I/O) would fire +// inside an async body, converting a synchronous throw into a promise +// rejection. This silently changed the public error contract and was +// invisible to assert.rejects() because Node's assert.rejects(fn) also +// accepts synchronous throws from fn() — so the existing options-validation +// tests passed under BOTH the broken (async) and correct (sync) forms. +// +// The tests below are intentionally SYNCHRONOUS (not async functions) and +// use assert.throws, not assert.rejects. assert.throws requires the callback +// to throw synchronously: if checkFile/lintFile were async they would return +// a rejected Promise instead of throwing, assert.throws would NOT catch it, +// and the test would fail with "Missing expected exception" — catching the +// regression that assert.rejects missed. +// +// assertKnownKeys fires before any I/O or backend access, so no real file +// path or init() call is required for these specific assertions. +// --------------------------------------------------------------------------- + +describe('file-op error contract — sync throw regression (#35)', () => { + before(() => init()); + + test('checkFile throws synchronously (not a rejected promise) for an unknown option key', () => { + // checkFile must be a plain function (not async). If it were async, + // this assert.throws call would see the function return without throwing + // and fail with "Missing expected exception". + assert.throws( + () => { checkFile('/any.mds', { sourceMap: true }); }, + (err) => { + assert.ok(isMdsError(err), `expected MdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options', + `expected mds::invalid_options, got: ${err.code}`); + return true; + }, + 'checkFile must throw synchronously for unknown option keys (not reject a promise)', + ); + }); + + test('lintFile throws synchronously (not a rejected promise) for an unknown option key', () => { + // Same contract for lintFile. + assert.throws( + () => { lintFile('/any.mds', { basePath: '.' }); }, + (err) => { + assert.ok(isMdsError(err), `expected MdsError, got: ${err}`); + assert.equal(err.code, 'mds::invalid_options', + `expected mds::invalid_options, got: ${err.code}`); + return true; + }, + 'lintFile must throw synchronously for unknown option keys (not reject a promise)', ); }); }); diff --git a/packages/mds/src/backend/native.ts b/packages/mds/src/backend/native.ts index 18396863..e0bb39f3 100644 --- a/packages/mds/src/backend/native.ts +++ b/packages/mds/src/backend/native.ts @@ -1,5 +1,6 @@ import type { BackendType, + CheckOptions, CheckResult, CompileOptions, CompileResult, @@ -89,7 +90,7 @@ export function createNativeBackend(addon: NapiAddon): MdsNodeBackend { return result as CompileResult; }, - check(source: string, options?: CompileOptions): CheckResult { + check(source: string, options?: CheckOptions): CheckResult { const result: unknown = addon.check(source, varsOpt(options)); assertResultShape(result, 'check'); return result as CheckResult; @@ -101,7 +102,7 @@ export function createNativeBackend(addon: NapiAddon): MdsNodeBackend { return result as CompileResult; }, - async checkFile(path: string, options?: FileOptions): Promise { + async checkFile(path: string, options?: CheckOptions): Promise { const result: unknown = await addon.checkFile(path, varsOpt(options)); assertResultShape(result, 'check'); return result as CheckResult; diff --git a/packages/mds/src/backend/wasm.ts b/packages/mds/src/backend/wasm.ts index 96ef176b..ed6e00b7 100644 --- a/packages/mds/src/backend/wasm.ts +++ b/packages/mds/src/backend/wasm.ts @@ -1,5 +1,6 @@ import type { BackendType, + CheckOptions, CheckResult, CompileOptions, CompileResult, @@ -329,9 +330,26 @@ const DEFAULT_COMPILE_OPTS = Object.freeze({ modules: Object.freeze({} as Record), }); +/** + * Extended options accepted by the internal WASM `compile` entry point. + * + * The public `CompileOptions` type omits `filename` and `modules` because + * callers of the high-level `compile(source, options)` wrapper do not need + * them — the WASM module uses sensible defaults for the string-compile path. + * + * This internal extension is used by `compileOpts()` so that callers who go + * through `createWasmBackend` directly (e.g. `wrapWithFileOps`, CF-SM1 test) + * and supply `filename`/`modules` explicitly get the expected WASM behaviour. + * The public API contract is unchanged: no public method accepts these keys. + */ +interface _WasmCompileInput extends CompileOptions { + filename?: string; + modules?: Record; +} + /** Build the options object for compile, merging vars and source-map options when present. */ function compileOpts( - options?: CompileOptions, + options?: _WasmCompileInput, ): { filename: string; modules: Record; @@ -340,17 +358,17 @@ function compileOpts( sourcesContent?: boolean; } { const extra = compileOpt(options); - if (extra == null) return DEFAULT_COMPILE_OPTS; - return { - filename: DEFAULT_COMPILE_OPTS.filename, - modules: DEFAULT_COMPILE_OPTS.modules, - ...extra, - }; + const filename = options?.filename ?? DEFAULT_COMPILE_OPTS.filename; + const modules = options?.modules ?? DEFAULT_COMPILE_OPTS.modules; + if (extra == null && filename === DEFAULT_COMPILE_OPTS.filename && modules === DEFAULT_COMPILE_OPTS.modules) { + return DEFAULT_COMPILE_OPTS; + } + return { filename, modules, ...extra }; } /** Build the options object for check, merging vars when present. */ function checkOpts( - options?: CompileOptions, + options?: CheckOptions, ): { filename: string; modules: Record; vars?: Record } { const vars = options?.vars; return vars != null @@ -388,12 +406,12 @@ export function fileOpts( export function createWasmBackend(wasmModule: WasmModule): MdsBaseBackend { return { compile(source: string, options?: CompileOptions): CompileResult { - const result: unknown = wasmModule.compile(source, compileOpts(options)); + const result: unknown = wasmModule.compile(source, compileOpts(options as _WasmCompileInput)); assertResultShape(result, 'compile'); return result as CompileResult; }, - check(source: string, options?: CompileOptions): CheckResult { + check(source: string, options?: CheckOptions): CheckResult { const result: unknown = wasmModule.check(source, checkOpts(options)); assertResultShape(result, 'check'); return result as CheckResult; diff --git a/packages/mds/src/browser.ts b/packages/mds/src/browser.ts index 36f6f42e..e7dd1443 100644 --- a/packages/mds/src/browser.ts +++ b/packages/mds/src/browser.ts @@ -1,9 +1,11 @@ -import type { BackendType, CheckResult, CompileOptions, CompileResult, InitOptions, MdsBaseBackend } from './types.js'; +import type { BackendType, CheckOptions, CheckResult, CompileOptions, CompileResult, InitOptions, MdsBaseBackend } from './types.js'; import { initWasmBrowser, createWasmBackend } from './backend/wasm.js'; +import { assertKnownKeys } from './util/options.js'; export { isMdsError } from './types.js'; export type { BackendType, + CheckOptions, CheckResult, CompileOptions, CompileResult, @@ -80,11 +82,13 @@ function assertReady(): MdsBaseBackend { /** Compile an MDS source string. Returns a discriminated-union CompileResult (kind: 'markdown' | 'messages'). Requires init() to have been called and awaited first. */ export function compile(source: string, options?: CompileOptions): CompileResult { + if (options != null) assertKnownKeys(options, 'compile'); return assertReady().compile(source, options); } /** Validate an MDS source string without rendering. Requires init() to have been called and awaited first. */ -export function check(source: string, options?: CompileOptions): CheckResult { +export function check(source: string, options?: CheckOptions): CheckResult { + if (options != null) assertKnownKeys(options, 'check'); return assertReady().check(source, options); } diff --git a/packages/mds/src/node.ts b/packages/mds/src/node.ts index 435e82f3..1f941f0c 100644 --- a/packages/mds/src/node.ts +++ b/packages/mds/src/node.ts @@ -2,6 +2,7 @@ import type { BackendType, MdsBaseBackend, MdsNodeBackend, + CheckOptions, CompileResult, CheckResult, CompileOptions, @@ -15,6 +16,7 @@ import { assertResultShape } from './backend/contract.js'; import { initWasmNode, createWasmBackend, fileOpts } from './backend/wasm.js'; import type { WasmModule } from './backend/wasm.js'; import { buildModulesMap } from './util/module-scanner.js'; +import { assertKnownKeys } from './util/options.js'; // Read MDS_BACKEND at module scope — sync, deterministic, no I/O. const rawBackend = process.env['MDS_BACKEND']; @@ -94,7 +96,7 @@ function wrapWithFileOps( return result as CompileResult; }, - async checkFile(path: string, options?: FileOptions): Promise { + async checkFile(path: string, options?: CheckOptions): Promise { const { source, opts } = await prepareFileArgs(path, options); const result: unknown = wasmModule.check(source, opts); assertResultShape(result, 'check'); @@ -240,31 +242,41 @@ function assertReady(): MdsNodeBackend { /** Compile an MDS source string. Returns a discriminated-union CompileResult (kind: 'markdown' | 'messages'). Requires init() to have been called and awaited first. */ export function compile(source: string, options?: CompileOptions): CompileResult { + if (options != null) assertKnownKeys(options, 'compile'); return assertReady().compile(source, options); } /** Validate an MDS source string without rendering. Requires init() to have been called and awaited first. */ -export function check(source: string, options?: CompileOptions): CheckResult { +export function check(source: string, options?: CheckOptions): CheckResult { + if (options != null) assertKnownKeys(options, 'check'); return assertReady().check(source, options); } /** Compile an MDS file, resolving @import directives relative to the file. Returns a discriminated-union CompileResult. Requires init() to have been called and awaited first. */ export function compileFile(path: string, options?: FileOptions): Promise { + if (options != null) assertKnownKeys(options, 'compileFile'); return assertReady().compileFile(path, options); } -/** Validate an MDS file without rendering, resolving @import directives relative to the file. Requires init() to have been called and awaited first. */ -export function checkFile(path: string, options?: FileOptions): Promise { +/** + * Validate an MDS file without rendering, resolving @import directives relative to the file. + * Only `vars` is forwarded; source-map options are not applicable to check operations. + * Requires init() to have been called and awaited first. + */ +export function checkFile(path: string, options?: CheckOptions): Promise { + if (options != null) assertKnownKeys(options, 'checkFile'); return assertReady().checkFile(path, options); } /** Lint an MDS source string. Returns a LintResult with per-rule findings. Requires init() to have been called and awaited first. */ export function lint(source: string, options?: LintOptions): LintResult { + if (options != null) assertKnownKeys(options, 'lint'); return assertReady().lint(source, options); } /** Lint an MDS file, resolving @import directives relative to the file. Requires init() to have been called and awaited first. */ export function lintFile(path: string, options?: LintFileOptions): Promise { + if (options != null) assertKnownKeys(options, 'lintFile'); return assertReady().lintFile(path, options); } @@ -274,6 +286,7 @@ export function lintVirtual( entry: string, options?: LintFileOptions, ): LintResult { + if (options != null) assertKnownKeys(options, 'lintVirtual'); return assertReady().lintVirtual(modules, entry, options); } @@ -285,6 +298,7 @@ export function getBackend(): BackendType { export { isMdsError } from './types.js'; export type { BackendType, + CheckOptions, CheckResult, CompileOptions, CompileResult, diff --git a/packages/mds/src/types.ts b/packages/mds/src/types.ts index 6611b01f..2b98e4ef 100644 --- a/packages/mds/src/types.ts +++ b/packages/mds/src/types.ts @@ -82,10 +82,22 @@ export interface CheckResult { warnings: string[]; } -/** Options shared by compile and check operations. */ -export interface CompileOptions { +/** + * Options for check-only operations (no source-map generation). + * Accepted by {@link MdsBaseBackend.check} and {@link MdsNodeBackend.checkFile}. + */ +export interface CheckOptions { /** Runtime variables made available for interpolation in the template. */ vars?: Record; +} + +/** + * Options for compile operations. + * + * Extends {@link CheckOptions}: check accepts a strict subset of compile's options. + * The `vars` field is inherited from {@link CheckOptions}. + */ +export interface CompileOptions extends CheckOptions { /** * When `true`, appends a {@link SourceMapV3} document to the result as * `result.sourceMap`. Ignored for `@message`-mode templates (no renderable @@ -104,21 +116,14 @@ export interface CompileOptions { sourcesContent?: boolean; } -/** Options shared by file-based compile and check operations. */ -export interface FileOptions { - /** Runtime variables made available for interpolation in the template. */ - vars?: Record; - /** - * When `true`, appends a {@link SourceMapV3} document to the result as - * `result.sourceMap`. Ignored for `@message`-mode templates. Defaults to `false`. - */ - sourceMap?: boolean; - /** - * When `true`, embeds the original source text in `sourceMap.sourcesContent`. - * Requires `sourceMap: true`. Defaults to `false`. - */ - sourcesContent?: boolean; -} +/** + * Options for file-based compile operations. + * + * Structurally identical to {@link CompileOptions} (inherits `vars`, `sourceMap`, + * `sourcesContent`). Kept as a distinct named type so `compileFile` and + * `checkFile` can evolve their option sets independently. + */ +export interface FileOptions extends CompileOptions {} // --------------------------------------------------------------------------- // Lint types @@ -244,7 +249,7 @@ export interface InitOptions { */ export interface MdsBaseBackend { compile(source: string, options?: CompileOptions): CompileResult; - check(source: string, options?: CompileOptions): CheckResult; + check(source: string, options?: CheckOptions): CheckResult; /** * Lint an MDS source string. * Runs the check gate first; returns a LintResult with per-rule findings. @@ -264,7 +269,11 @@ export interface MdsBaseBackend { */ export interface MdsNodeBackend extends MdsBaseBackend { compileFile(path: string, options?: FileOptions): Promise; - checkFile(path: string, options?: FileOptions): Promise; + /** + * Validate an MDS file without rendering. Only `vars` is forwarded; + * source-map options are not applicable to check operations. + */ + checkFile(path: string, options?: CheckOptions): Promise; /** Lint an MDS file, resolving @import directives relative to the file. */ lintFile(path: string, options?: LintFileOptions): Promise; } diff --git a/packages/mds/src/util/module-scanner.ts b/packages/mds/src/util/module-scanner.ts index 0c5011fa..7c527121 100644 --- a/packages/mds/src/util/module-scanner.ts +++ b/packages/mds/src/util/module-scanner.ts @@ -223,7 +223,20 @@ export async function buildModulesMap( const maxModules = options?.maxModules ?? DEFAULT_MAX_MODULES; const maxAggregateSize = options?.maxAggregateSize ?? DEFAULT_MAX_AGGREGATE_SIZE; - const absoluteEntry = resolve(entryPath); + // Resolve the parent directory to its canonical form before computing the + // project root and security boundaries. This eliminates false-positive + // "possible symlink" errors on platforms with OS-level directory symlinks + // (e.g. macOS /var → /private/var), where realpath(absolutePath) differs + // from absolutePath even for a regular non-symlink file. + // + // Only the PARENT directory is canonicalized, NOT the final path component. + // A symlink at the file level is still caught by O_NOFOLLOW (openNoFollow) + // and the post-open realpath mismatch check inside openAndValidateModule. + // This mirrors the pattern in NativeFs::check_symlink (Rust): canonicalize + // the parent, join the filename, then check the file for symlinks. + const rawAbsoluteEntry = resolve(entryPath); + const canonicalParentDir = await realpath(dirname(rawAbsoluteEntry)); + const absoluteEntry = canonicalParentDir + sep + rawAbsoluteEntry.slice(rawAbsoluteEntry.lastIndexOf(sep) + 1); const projectRoot = findProjectRoot(dirname(absoluteEntry)); // Virtual keys are always slash-separated to mirror Rust's VirtualFs; on // Windows `relative` yields backslashes, so normalize to '/'. diff --git a/packages/mds/src/util/options.ts b/packages/mds/src/util/options.ts index b9bb6d56..d825a9ab 100644 --- a/packages/mds/src/util/options.ts +++ b/packages/mds/src/util/options.ts @@ -1,4 +1,146 @@ -import type { CompileOptions, FileOptions } from '../types.js'; +import type { CompileOptions, CheckOptions, FileOptions, LintOptions, LintFileOptions } from '../types.js'; + +// ── keysOf helper ────────────────────────────────────────────────────────────── + +/** + * Returns the keys of `T` as a readonly string array. + * + * The `witness` parameter must supply `true` for every key of `T` — this + * binds the returned list to the interface at compile time. If `T` gains a + * new key the witness literal must be updated; failing to do so is a compile + * error, not a silent omission. + * + * @example + * ```typescript + * keysOf({ basePath: true, vars: true, rules: true }) + * // → readonly ['basePath', 'vars', 'rules'] + * ``` + */ +function keysOf(witness: Record): readonly string[] { + return Object.keys(witness); +} + +// ── Public method name union ─────────────────────────────────────────────────── + +/** + * All public wrapper method names that accept an options object. + * + * The literal union is the compile-time guard for {@link assertKnownKeys}: any + * call site that passes a method name not in this union is a type error, so a + * newly-added method that is not yet registered is caught at build time rather + * than silently disabling validation. + */ +export type MethodName = + | 'compile' + | 'check' + | 'compileFile' + | 'checkFile' + | 'lint' + | 'lintFile' + | 'lintVirtual'; + +// ── Internal backend option shapes ──────────────────────────────────────────── + +// These represent what napi's option parsers accept — wider than the public +// TypeScript interfaces for compile and check, which intentionally omit basePath +// (open issue #180). The backend (parse_compile_opts / parse_check_opts) accepts +// basePath; the public TS types do not expose it yet. + +/** Backend-accepted options for `compile` — superset of public {@link CompileOptions}. */ +interface _CompileBackendOpts extends CompileOptions { + basePath?: string; +} + +/** Backend-accepted options for `check` — superset of public {@link CheckOptions}. */ +interface _CheckBackendOpts extends CheckOptions { + basePath?: string; +} + +// ── Per-method key table ─────────────────────────────────────────────────────── + +/** + * Allowed option keys per public wrapper method. + * + * Each list is derived via {@link keysOf} from the corresponding backend option + * interface, binding the table to the interface at compile time. When a method's + * accepted options change, the witness object in the {@link keysOf} call must be + * updated, or the call becomes a type error. + * + * Reconciliation against napi option parsers (`crates/mds-napi/src/lib.rs`): + * - `compile`/`check`: `basePath` added — napi's `parse_compile_opts` / + * `parse_check_opts` accept it; the public TS types do not yet expose it + * (open issue #180). + * - `compileFile`/`checkFile`: `basePath` is NOT in the key list; instead it is + * passed through to the backend without wrapper interception so napi's purpose-built + * error fires ("not valid for compileFile/checkFile; the base directory is derived + * from the file path"). See {@link BASEPATH_PASSTHROUGH} and issue #74. + * - `lint`, `lintFile`, `lintVirtual`: key lists match napi exactly. + */ +const METHOD_KEYS: Readonly> = { + compile: keysOf<_CompileBackendOpts>({ basePath: true, vars: true, sourceMap: true, sourcesContent: true }), + check: keysOf<_CheckBackendOpts>({ basePath: true, vars: true }), + compileFile: keysOf({ vars: true, sourceMap: true, sourcesContent: true }), + checkFile: keysOf({ vars: true }), + lint: keysOf({ basePath: true, vars: true, rules: true }), + lintFile: keysOf({ vars: true, rules: true }), + lintVirtual: keysOf({ vars: true, rules: true }), +}; + +/** + * Methods for which `basePath` is passed through to the backend without wrapper + * interception (issue #74). The backend emits a purpose-built actionable error + * for these methods ("not valid for compileFile/checkFile; the base directory is + * derived from the file path") rather than the generic "unknown option key" format. + */ +const BASEPATH_PASSTHROUGH: ReadonlySet = new Set([ + 'compileFile', + 'checkFile', +]); + +// ── Main validator ───────────────────────────────────────────────────────────── + +/** + * Assert that every key in `options` is in the allowed list for `method`. + * + * Uses the same message format as `format_unknown_keys_error` in + * `crates/mds-core/src/options.rs`. For all methods except `compileFile` and + * `checkFile`, the wrapper and napi produce byte-identical messages for the same + * unknown key. For `compileFile` and `checkFile`, `basePath` is not intercepted + * here — it is passed through so the backend can emit its own purpose-built error + * (issue #74; open issue #180). + * + * The `method` parameter is typed as the {@link MethodName} literal union — + * passing an unrecognised method name is a compile-time error, not a silent no-op. + * + * Throws `Error & { code: 'mds::invalid_options' }` — satisfies `isMdsError`. + * + * @param options - The caller-supplied options object (never null/undefined; guard before calling). + * @param method - Public method name, restricted to {@link MethodName} at compile time. + */ +export function assertKnownKeys(options: object, method: MethodName): void { + // hasOwnProperty prevents prototype-chain resolution for callers that bypass + // the MethodName compile-time union via a runtime cast (e.g. 'toString' as MethodName). + if (!Object.prototype.hasOwnProperty.call(METHOD_KEYS, method)) return; + const known = METHOD_KEYS[method]; + const unknowns = Object.keys(options).filter((k) => { + // basePath is passed through for file-based methods so the backend emits its + // own purpose-built error rather than this generic rejection (issue #74). + if (BASEPATH_PASSTHROUGH.has(method) && k === 'basePath') return false; + return !known.includes(k); + }); + if (unknowns.length === 0) return; + const recognised = known.join(', '); + let message: string; + if (unknowns.length === 1) { + message = `unknown option key "${unknowns[0]}"; recognised keys are: ${recognised}`; + } else { + const listed = unknowns.map((k) => `"${k}"`).join(', '); + message = `unknown option keys: ${listed}; recognised keys are: ${recognised}`; + } + const err = new Error(message) as Error & { code: string }; + err.code = 'mds::invalid_options'; + throw err; +} /** * Build the `{ vars }` sub-object only when `options.vars` is defined and non-null. @@ -7,7 +149,9 @@ import type { CompileOptions, FileOptions } from '../types.js'; * When the caller passes no vars, omitting the key entirely avoids unnecessary * object creation and keeps the options shape minimal. */ -export function varsOpt(options?: CompileOptions | FileOptions): { vars: Record } | undefined { +export function varsOpt( + options?: { vars?: Record }, +): { vars: Record } | undefined { return options?.vars != null ? { vars: options.vars } : undefined; } diff --git a/spec.md b/spec.md index 82e29c19..1c98d9c8 100644 --- a/spec.md +++ b/spec.md @@ -72,6 +72,7 @@ Hello {name}! **Fence out-of-scope:** - 4-space indented code blocks (CommonMark style, without fence markers) are **not** recognized as passthrough regions — interpolation IS parsed inside them. - Leaving a blockquote does not implicitly close a blockquoted fence: `> ``` ... content without > prefix ... > ` ``` `` — the fence closes only on an explicit matching closer (`> ` `` ` or `> ~~~`). Without an explicit closer the fence extends to end-of-file. +- **Unclosed code fence is a hard error** (`mds::syntax "unclosed code fence"`): a fence that is never closed by end-of-file causes compilation to fail rather than silently extend to end-of-file. --- @@ -169,7 +170,7 @@ Free tier. - Maximum 16 leaf operands per logical expression - Falsy values: `false`, `null`, empty string `""`, empty array `[]`, empty object `{}`, `0`, `NaN` - Everything else is truthy -- Equality is **strict**, no type coercion: comparing values of different types (e.g. `@if count == "3":` when `count` is the number `3`) is a **runtime error** (`mds::type_mismatch`). Convert explicitly: `@if str(count) == "3":` or `@if count == 3:` +- Equality is **strict**, no type coercion: comparing values of different types (e.g. `@if count == "3":` when `count` is the number `3`) is a **runtime error** (`mds::type_mismatch`). Convert explicitly: `@if string(count) == "3":` or `@if count == 3:` - `NaN == NaN` is false (IEEE 754) - `@elseif` branches are evaluated in order; first matching branch wins (short-circuit) - `@elseif` must appear before `@else:`; `@else:` cannot be followed by `@elseif` @@ -246,10 +247,14 @@ With default arguments: Hello {name}! @end -{greet()} @# → Hello World! -{greet("Alice")} @# → Hello Alice! +{greet()} +{greet("Alice")} ``` +> **Note — no comment syntax:** MDS has no comment syntax. Unknown `@directives` (any +> `@word` not recognized by the compiler) are **syntax errors**, not comments. The `@#` +> annotation style used in some older examples is not valid MDS. + Invocation: ```mds @@ -452,6 +457,12 @@ You have access to: Output: ```markdown +--- +user: Alice +tools: [search, code, browse] +--- + + Hello Alice! You have access to: @@ -1206,7 +1217,7 @@ A `tree-sitter-mds` grammar that extends Markdown parsing. Provides structural p A language server (Rust) providing diagnostics, completions, go-to-definition for `@import` paths, hover info for variables, and validation errors. Works across all editors that support LSP. -**Markdown Preview**: The recommended approach is to compile `.mds` → `.md` and preview the output. The CLI supports this: `mds build input.mds | less` or pipe to any Markdown viewer. +**Markdown Preview**: The recommended approach is to compile `.mds` → `.md` and preview the output. The CLI supports this: `mds build input.mds -o - | less` or pipe to any Markdown viewer. (`mds build` without `-o -` writes `input.md` beside the source and emits only a status line on stderr.) ---