diff --git a/.devflow/features/mds-fmt/KNOWLEDGE.md b/.devflow/features/mds-fmt/KNOWLEDGE.md index 7f747def..fe69ee58 100644 --- a/.devflow/features/mds-fmt/KNOWLEDGE.md +++ b/.devflow/features/mds-fmt/KNOWLEDGE.md @@ -210,7 +210,8 @@ Notable divergences worth knowing before touching this file: - **Adding a field to `MdsConfig` ripples into unrelated existing tests.** The `fmt: FmtConfig` field addition required `..Default::default()` in pre-existing struct-literal test sites in `build.rs` and `watch.rs`. - **Partials are reformatted but `is_partial` still gates output emission elsewhere.** Don't conflate the two meanings of "partial" across `fmt` vs. `build`/`check`. - **Hidden FILES are still collected by the walker.** `is_default_excluded_dir` excludes hidden *directories* from recursion, not hidden `.mds` files at the traversed level. A `.dotfile.mds` at the root of a traversed directory is collected and formatted. -- **`atomic_write_file` is the write primitive — not `std::fs::write`.** `fmt.rs` calls `atomic_write_file` from `output.rs` (shared with `lint.rs`). It provides a TOCTOU guard, Unix permission preservation (`mode & 0o7777`), and `sync_all()` + atomic rename. This means a failed write leaves the original file intact — it does NOT leave a partially-written file. +- **`atomic_write_file` is the write primitive — not `std::fs::write`.** `fmt.rs` calls `atomic_write_file` from `output.rs` (shared with `lint.rs`). It provides a TOCTOU guard, Unix permission preservation (`mode & 0o7777`), and `sync_all()` + atomic rename. This means a failed write leaves the original file intact — it does NOT leave a partially-written file. And, since #227, `write_output`/dir-mode build/`.map` sidecars route through it too, with `Durability::RenameOnly` (fmt/lint pass `Fsync`). +- **`crates/mds-cli/tests/write_funnel.rs` fails CI on any raw `fs::write(` / `File::create(` in `crates/mds-cli/src` outside the two allow-listed sites** (`mds init` in main.rs; the test-only readiness marker in watch.rs). ## Deferred follow-ups (recorded to avoid re-flagging as new debt) @@ -228,7 +229,7 @@ These items were consciously deferred and have back-ref comments in source: - `crates/mds-core/src/formatter.rs` — the entire engine: `format_str_named` (~line 135), region computation, the rewrite pass, `strip_trailing_insignificant_text` (~line 522), `in_raw_content` binary-search helper, `assert_equivalent` (~line 446), and `structural_equivalent` (~line 565) - `crates/mds-core/src/lib.rs:60` — `pub use formatter::{format_str, format_str_named, format_str_with}` (the public re-export); `:665-681` — `clean_output` - `crates/mds-core/src/fs.rs:287` — `effective_parent` (bare-filename fix, maps `Some("")`/`None` → `"."`) -- `crates/mds-cli/src/output.rs:164,177` — `is_default_excluded_dir` / `is_within_default_excluded_dir` (shared walker exclusions; applies to all subcommands and both watch paths); `:432` — `atomic_write_file` (shared write primitive: TOCTOU guard + permissions restore + sync_all + atomic rename; used by both `fmt.rs` and `lint.rs`) +- `crates/mds-cli/src/output.rs:164,177` — `is_default_excluded_dir` / `is_within_default_excluded_dir` (shared walker exclusions; applies to all subcommands and both watch paths); `:651` — `atomic_write_file` (shared write primitive: TOCTOU guard + permissions restore + atomic rename, with `sync_all` under `Durability::Fsync`; used by `fmt.rs`, `lint.rs`, and — since #227 — `build.rs`/`watch.rs` outputs and `.map` sidecars) - `crates/mds-core/src/evaluator.rs:845` — the `evaluate_nodes(...).trim()` call (spec §4.11 edge-trim) that makes `@message` bodies bypass `clean_output` - `crates/mds-core/src/resolver/frontmatter.rs:53-129` — `deep_merge_yaml` (`@extends` frontmatter merge) - `crates/mds-core/src/parser.rs:545-594` — `parse_block`; the `@block`-cannot-nest-in-`@message` guard diff --git a/.devflow/features/mds-lint/KNOWLEDGE.md b/.devflow/features/mds-lint/KNOWLEDGE.md index bc81eb91..6d80906b 100644 --- a/.devflow/features/mds-lint/KNOWLEDGE.md +++ b/.devflow/features/mds-lint/KNOWLEDGE.md @@ -204,7 +204,7 @@ The output-equality sub-check (3) is **skipped** when the plan contains any edit **Emit-before-exit ordering rule** (`lint.rs` output-contract, PF-004 recurring defect class — 7 realized defects across ~33 emitter sites): The invariant is **emit the envelope before any early exit; write first, then print/accumulate**. Two defects fixed in PR #308: (1) `lint --fix --check --format json` called `process::exit(1)` before `emit_result`, emitting zero stdout bytes (AC-F-14 held for `` but not ``); (2) JSON write-failure arms pushed the post-fix result before the write, so a failed write emitted `{"files":[],…}` (reads as a clean tree) with exit 2. The `Fixed:` arms were already correct; `PartiallyFixed` arms were not. The `ResultSink` redesign that makes `--quiet` structurally unbypassable is deferred to issue **#309**. -**Atomic write** (`atomic_write_file` in `output.rs`, imported by both `lint.rs` and `fmt.rs`): TOCTOU guard + permissions restore + `sync_all()` + `persist()` (intra-filesystem rename). Temp prefix `.mds-tmp-`. `Fixed:` is printed only AFTER a successful write. +**Atomic write** (`atomic_write_file` in `output.rs`, imported by both `lint.rs` and `fmt.rs`): TOCTOU guard + permissions restore + `sync_all()` + `persist()` (intra-filesystem rename). Temp prefix `.mds-tmp-`. `Fixed:` is printed only AFTER a successful write. Shared with `build`/`watch` since #227; third parameter `Durability` (`Fsync` for lint/fmt, `RenameOnly` for build/watch). ### CLI Preview Pipeline (`preview_fixes`) @@ -547,7 +547,9 @@ LintDiagnostic.fix_removals (FixLineSpan) OR .fix_edits (TextEdit) **`assertKnownKeys` must be called before backend dispatch**: The validation runs synchronously in the wrapper, before `init()` is awaited or any backend is invoked. -**atomic_write_file temp prefix**: The temp file prefix is `.mds-tmp-`. Both lint and fmt share the same `atomic_write_file` from `output.rs`. +**atomic_write_file temp prefix**: The temp file prefix is `.mds-tmp-`. Both lint and fmt share the same `atomic_write_file` from `output.rs`. Shared with `build`/`watch` since #227; third parameter `Durability` (`Fsync` for lint/fmt, `RenameOnly` for build/watch). + +**`crates/mds-cli/tests/write_funnel.rs` fails CI on any raw `fs::write(` / `File::create(` in `crates/mds-cli/src`** outside the two allow-listed sites (`mds init` in main.rs; the test-only readiness marker in watch.rs). **Python `LintDiagnostic.fix_edits` getter vs `#[pyo3(get)]`**: `Vec` does not implement `IntoPy`. Use the custom `#[getter]` which calls `value_to_py`. Stored internally as `Option>`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c388a150..f498cf4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING (CLI): `mds build`, `mds check`, `mds fmt` and `mds lint` now exit + non-zero on a directory that contains no `.mds` files (#204).** `build`/`check`/`fmt` + exit 1 and `lint` exits 2 (its usage-error code), printing `no .mds files found in + ; nothing was built` (`…checked` / `…formatted` / `…linted`) on stderr — even + under `--quiet`, exactly like the existing all-under-excluded-directories diagnostic. + Previously an empty tree exited 0 with `No .mds files found in ` (silent under + `--quiet`), so a mistyped or not-yet-populated directory passed CI green. Scripts that + relied on exit 0 for an empty directory must create at least one `.mds` file or skip + the call. `mds watch ` is unchanged: it starts on an empty tree and compiles files + created later. The bare auto-detect form (`mds build` with no argument in a directory + holding no `.mds` file) already exited non-zero; this aligns the explicit directory form. - **`mds watch --debounce` is now a quiet period with a hard cap (#379).** Each content event restarts the window instead of the window expiring at a fixed offset from the first event, so a save burst longer than the window coalesces into @@ -74,6 +85,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 empty-string key renders as an empty segment; the file-load and string-load error codes (`mds::invalid_vars` vs `mds::json`) remain deliberately un-unified (pre-existing split, unchanged). +- **`mds build` and `mds watch` outputs are written atomically (#227).** Compiled + artifacts, directory-mode outputs and `.map` sidecars are now written to a temporary + file in the destination directory and renamed into place — the same primitive + `mds fmt` and `mds lint --fix` already use — so a crash, kill or write error can never + leave a truncated file; the previous output survives until the rename. Source rewrites + keep their fsync-before-rename; compiled outputs and sidecars use rename only + (regenerable; an unconditional fsync made directory-mode watch startup ~3× slower on + macOS). A first build into a new file keeps the umask default mode (typically 0644); an + existing output keeps its mode. Consequences: an output path that is a symlink (live or + dangling) is refused instead of written through; a destination directory that is not + writable fails the build even when the file itself is writable; a read-only (0444) + existing output is replaced, mode preserved; an output path that is not a regular file + in a writable directory — for example `-o /dev/null` or `/dev/stdout` — is no longer + accepted (use stdout, i.e. omit `-o`, or `mds check` instead); a FIFO or other special + file at the output path is replaced by a regular file rather than opened. The spurious + `cannot get metadata` message that #240 emitted on every first write of a + not-yet-existing file is gone; a stat failure other than "not found" is now a hard + error rather than a warning (#225). A new test, `write_funnel.rs`, fails CI on any raw + `fs::write`/`File::create` in the CLI outside the two justified sites. - **Fix stale `lint_str` rustdoc and lint-rule Tier tables (#329).** `mds-core`'s `lint_str` rustdoc said "applies the 9 lint rules" after a 10th rule (`legacy-interpolation`) had shipped; the Tier tables in `lint/tier.rs` and @@ -87,6 +117,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal +- **`atomic_write_file` replace-by-rename contract documented (#226).** The temp-file-then-rename write used by `mds fmt`, `mds lint --fix` and (with #227) `mds build`/`mds watch` outputs and `.map` sidecars gives the target a new inode, so hard links, ACLs, xattrs and owner/group of a pre-existing target are not preserved (permission bits are, on Unix). Stated in spec §7.2 "Output writing", `SECURITY.md`, the helper's rustdoc and `RELEASING.md`. - Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243. - npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. - GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (`3e2bdf6`) to the commit it points to (`e83996d1`), same version (PF-040); Dependabot `ignore` for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169. diff --git a/README.md b/README.md index 6ba0659e..d39a84d8 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Exit codes: 3 Resource limit exceeded ``` -**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 (`N built, N failed`; `N passed, N failed` for `check`) is printed on a successful run or when any file fails; the exit code is non-zero if any file fails. Under `--quiet`, the summary is suppressed on a fully-successful run but is always emitted when any file fails, so the non-zero exit is never unexplained. 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. +**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 (`N built, N failed`; `N passed, N failed` for `check`) is printed on a successful run or when any file fails; the exit code is non-zero if any file fails. Under `--quiet`, the summary is suppressed on a fully-successful run but is always emitted when any file fails, so the non-zero exit is never unexplained. 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) also exits non-zero (`1`) with `no .mds files found in ; nothing was built` (`…checked` for `check`), likewise even under `--quiet` — an empty tree is treated as a misconfiguration, not a success. (Changed in v0.4.3; previously exited 0.) `mds watch ` is the exception: it starts on an empty tree and compiles files created later. 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. @@ -161,6 +161,10 @@ mds watch src/ --out-dir dist # mirror source subtree under dist/ # src/a/b/foo.mds → dist/a/b/foo.md (not dist/foo.md) ``` +`mds watch src/` starts even when `src/` has no `.mds` files yet; files created later are +compiled as they appear (unlike `build`/`check`/`fmt`/`lint`, which exit non-zero on an empty +tree). + > **Changed in v0.4.0:** Directory mode with `--out-dir` or `mds.json output_dir` > now mirrors the source subtree instead of writing flat stems. Old flat outputs are > orphaned and must be removed manually. @@ -216,7 +220,9 @@ continuing past per-file errors and printing a summary (`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` +suppresses status but never errors. A directory with no `.mds` files exits 1 with +`no .mds files found in ; nothing was formatted`, even under `--quiet`. +Reads a `fmt` section from `mds.json` (`{"fmt": {"sort_frontmatter_keys": true}}`) for forward compatibility — the field doesn't drive any formatting behavior yet; frontmatter key sorting is deferred to a future version. @@ -236,6 +242,9 @@ mds lint --quiet . # directory lint: silent on clean/warn-only; sum Directory mode (`mds lint `) lints every `.mds` file recursively (partials included) and prints one summary line to stderr after processing all files: `N clean, N with warnings, N with errors, N resource-limited`. +A directory with no `.mds` files exits 2 (lint's usage-error code) with +`no .mds files found in ; nothing was linted` on stderr, even under `--quiet`, and prints +no summary. Under `--quiet`, the summary is suppressed when the worst outcome is warnings or clean; it is always printed when any file has errors or hits a resource limit, so the non-zero exit is never unexplained in those cases. Two exits are deliberately left unexplained under `--quiet`, because diff --git a/RELEASING.md b/RELEASING.md index 303f38ce..c3eefa30 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -408,5 +408,5 @@ The `release.yml` workflow runs, in order: - The 8 Python artifacts (7 `cp311-abi3` wheels + 1 sdist): manylinux x86_64 and aarch64, musllinux_1_2 x86_64 and aarch64, macOS x86_64 and arm64, Windows x86_64, plus one source distribution. Built by `PyO3/maturin-action@v1.51.0` (maturin 1.13.3). The musl and manylinux legs run inside Docker containers that maturin-action manages; the readelf linkage gate asserts the `.so` inside each Linux wheel links the correct libc (musl or glibc), with a positive control and a non-vacuity guard (PF-038). Platform wheels cannot be built or validated locally — use the branch dry-run workflow instead. - wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-sign-ext", ...] is enabled in crates/mds-wasm/Cargo.toml; CI installs wasm-pack and Binaryen v129 via the composite action at .github/actions/setup-wasm/ (version pins live there). Local builds do not need system Binaryen — wasm-pack auto-downloads wasm-opt (v117) on first use; install Binaryen v129+ (brew install binaryen / apt install binaryen) only for offline builds, to override a stale wasm-opt on PATH, or to reproduce CI's exact release optimizer. - Platform packages are generated in CI only — they cannot be validated with a local npm pack; use the dry-run workflow instead. -- Due to its temp-file-then-rename implementation, atomic_write_file does not preserve hard links, ACLs, extended attributes (xattrs), or owner/group metadata of the original file. +- `atomic_write_file` (`crates/mds-cli/src/output.rs`) is replace-by-rename: it does not preserve hard links, ACLs, extended attributes (xattrs), or owner/group of the original file (permission bits are restored on Unix). This applies to `mds fmt`, `mds lint --fix`, and — since #227 — `mds build`/`mds watch` compiled outputs and `.map` sidecars (source rewrites fsync before the rename; compiled outputs and sidecars rely on the rename alone). The user-facing contract lives in spec §7.2 ("Output writing") and `SECURITY.md`; hard-link preservation is won't-fix by construction, the rest is not planned (#226). - `Swatinem/rust-cache` computes its key as `v0-rust[-]-----`; the env hash covers `rustc -vV` (the HOST triple) plus `CARGO*`/`CC*`/`CFLAGS` env. The cross TARGET is not in it, so without `key:` all four ubuntu legs in `build-napi` and both macOS legs share one blob. Both matrix jobs carry per-leg keys: `build-napi` uses `key: ${{ matrix.settings.target }}` (#352, PF-041); `build-python` uses `key: ${{ matrix.target }}-${{ matrix.manylinux }}` (#347) so a containerised Linux leg never restores host-built build scripts or proc-macro `.so` files written by another leg. `publish-crates` and `publish-npm` are single-leg and use the automatic key. Spec S20 in `scripts/__test__/release-auth-probe.spec.mjs` pins this — dropping `with: key:` from a matrix job's rust-cache step causes S20 to fail `Version gate`. Validation: two runs in the SAME cache scope — first run shows `No cache found.`; second shows `Restored from cache key "v0-rust--build-napi-…" full match: true.` for every leg with the readelf gates green. `pull_request` caches live under `refs/pull/N/merge` and are invisible to a branch dispatch, so warm evidence comes from a second dispatch on the same branch (or a PR-run rerun), never from a dispatch that follows a PR run. diff --git a/SECURITY.md b/SECURITY.md index 7c2d7a04..0608e375 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,6 +46,13 @@ input. The compiler enforces several defense-in-depth controls: boundary rather than being passed to the OS. - **Non-UTF-8 paths** are rejected at the public API boundary with an explicit error instead of producing corrupted output. +- **Replace-by-rename writes**: `mds fmt`, `mds lint --fix`, and `mds build`/`mds + watch` outputs and `.map` sidecars are written to a same-directory temp file and + renamed over the target after a final symlink re-check (`mds-cli/src/output.rs`, + `atomic_write_file`; enforced by `crates/mds-cli/tests/write_funnel.rs`), so a + crash never leaves a truncated target and a symlinked output path is refused. + Consequence: hard links, ACLs, xattrs, and owner/group of a pre-existing target + are not preserved (permission bits are, on Unix) — see spec §7.2 "Output writing". ### Resource limits diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index 349966fe..61fc684f 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -7,6 +7,7 @@ use std::ffi::OsString; use std::io::Read; use std::path::{Path, PathBuf}; +use crate::output::Durability; use mds::{ effective_parent, CompiledOutput, MdsError, MAX_FILE_SIZE, MAX_TRAVERSAL_DEPTH, STRING_SOURCE_MAP_LABEL, @@ -719,6 +720,15 @@ pub(crate) fn read_stdin() -> Result<(String, PathBuf)> { /// Set `announce = false` in watch-loop rebuilds so only the `"Recompiled …"` /// summary line is emitted (not a redundant `"Compiled to …"` line). /// Set `announce = true` for the initial/startup compile and for `mds build`. +/// +/// The file write goes through [`crate::output::atomic_write_file`] (#227): a crash or +/// write error mid-way never leaves a truncated artifact — the previous output, if any, +/// survives until the rename — and a symlink at the output path is refused. The parent +/// directory is created first. The stdout arm is unchanged (streaming). +/// +/// Compiled artifacts are written with [`crate::output::Durability::RenameOnly`]: they +/// are derived files a rebuild reproduces, and `F_FULLFSYNC` per artifact tripled a +/// 500-template watch startup. Source rewrites (`fmt`, `lint --fix`) keep the fsync. pub(crate) fn write_output( output_path: Option, compiled: &str, @@ -734,8 +744,11 @@ pub(crate) fn write_output( })?; } } - std::fs::write(&path, compiled) - .map_err(|e| miette::miette!("cannot write {}: {e}", path.display()))?; + // #227: temp-file + fsync + rename, so a failed or interrupted build leaves + // the previous artifact intact instead of a truncated one. The primitive + // owns the symlink refusal; the create_dir_all above stays here because the + // primitive deliberately does not create directories. + crate::output::atomic_write_file(&path, compiled, Durability::RenameOnly)?; if !quiet && announce { eprintln!("Compiled to {}", crate::output::safe_path(&path)); } @@ -1383,9 +1396,11 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { // untrusted; see the `mds::SourceMap` rustdoc. The status // line below is a diagnostic surface and IS escaped. let map_json = sm.to_json(); - std::fs::write(&map_path, &map_json).map_err(|e| { - miette::miette!("cannot write {}: {e}", map_path.display()) - })?; + crate::output::atomic_write_file( + &map_path, + &map_json, + Durability::RenameOnly, + )?; if !quiet { eprintln!( "Source map written to {}", @@ -1515,9 +1530,11 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { if let Some(ref sm) = source_map { let map_path = map_path_for(out); let map_json = sm.to_json(); - std::fs::write(&map_path, &map_json).map_err(|e| { - miette::miette!("cannot write {}: {e}", map_path.display()) - })?; + crate::output::atomic_write_file( + &map_path, + &map_json, + Durability::RenameOnly, + )?; if !quiet { eprintln!( "Source map written to {}", @@ -1560,6 +1577,14 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { /// the summary is always emitted so the non-zero exit is never unexplained. /// This mirrors the gate used by `mds check` (`main.rs`) and `mds fmt` (`fmt.rs`). /// +/// **Nothing to build is an error (#204):** when the walk yields no files the run +/// exits 1 with a one-line stderr diagnostic that bypasses `--quiet` — either the +/// all-excluded count diagnostic or `no .mds files found in ; nothing was built`. +/// Both call `process::exit` directly: no `MdsError` variant exists for "nothing to +/// do" and `exit_code` must not grow one for a non-error class. +/// `mds check` and `mds fmt` mirror this with exit 1, `mds lint` with exit 2; +/// `mds watch ` deliberately does NOT error on an empty tree. +/// /// **Documented limitation (AC-Q05):** two warning writers reachable from this /// function do not accept a `quiet` parameter — `output.rs::collect_mds_files_inner` /// (depth-limit warning, fires on trees deeper than MAX_DEPTH=64) and @@ -1627,10 +1652,17 @@ fn run_build_directory( ); std::process::exit(1); } - if !quiet { - eprintln!("No .mds files found in {}", crate::output::safe_path(dir)); - } - return Ok(()); + // #204: an empty tree is "nothing to build", not success. Same shape as the + // all-excluded arm above — emitted even under --quiet (a silent green pass on + // a mistyped or not-yet-populated directory is the CI failure mode this + // closes) and exit 1, the build/check/fmt "nothing was done" code (spec §7.9). + // `mds watch ` deliberately still starts on an empty tree: a file created + // later is a valid flow there. + eprintln!( + "no .mds files found in {}; nothing was built", + crate::output::safe_path(dir) + ); + std::process::exit(1); } let mut ok_count: usize = 0; @@ -1712,7 +1744,14 @@ fn run_build_directory( // not an empty success. let wrote_empty = final_content.is_empty(); - match std::fs::write(&out_path, &final_content) { + // #227: the dir-mode twin of `write_output` — same atomic contract, but + // it accumulates per-file counters instead of returning early, so it is + // its own call site. Both are enforced by `tests/write_funnel.rs`. + match crate::output::atomic_write_file( + &out_path, + &final_content, + Durability::RenameOnly, + ) { Ok(()) => { if wrote_empty { empty_count += 1; @@ -1727,12 +1766,14 @@ fn run_build_directory( if let Some(ref sm) = compiled.source_map { let map_path = map_path_for(&out_path); let map_json = sm.to_json(); - if let Err(e) = std::fs::write(&map_path, &map_json) { - eprintln!( - "error: cannot write {}: {}", - crate::output::safe_path(&map_path), - crate::output::safe_inline(&e) - ); + if let Err(e) = crate::output::atomic_write_file( + &map_path, + &map_json, + Durability::RenameOnly, + ) { + // The primitive's message already names the path — + // re-prefixing it would print the path twice (#227). + eprintln!("error: {}", crate::output::safe_inline(&e)); fail_count += 1; continue; } @@ -1768,11 +1809,8 @@ fn run_build_directory( ok_count += 1; } Err(e) => { - eprintln!( - "error: cannot write {}: {}", - crate::output::safe_path(&out_path), - crate::output::safe_inline(&e) - ); + // The primitive's message already names the path (#227). + eprintln!("error: {}", crate::output::safe_inline(&e)); fail_count += 1; } } diff --git a/crates/mds-cli/src/fmt.rs b/crates/mds-cli/src/fmt.rs index 06d3c10a..1a19315e 100644 --- a/crates/mds-cli/src/fmt.rs +++ b/crates/mds-cli/src/fmt.rs @@ -29,7 +29,9 @@ use mds::{effective_parent, FileSystem}; use miette::Result; use crate::build::{ensure_existing_mds_file, load_config, read_stdin, resolve_input}; -use crate::output::{atomic_write_file, collect_mds_files_detailed, render_unified_diff}; +use crate::output::{ + atomic_write_file, collect_mds_files_detailed, render_unified_diff, Durability, +}; pub(crate) struct FmtArgs { pub(crate) input: Option, @@ -185,7 +187,7 @@ fn run_fmt_file(path: &Path, flags: FmtFlags) -> Result<()> { // 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)?; + atomic_write_file(path, &result.formatted, Durability::Fsync)?; if !quiet { eprintln!("Formatted: {}", crate::output::safe_path(path)); } @@ -278,7 +280,7 @@ fn format_one_file(file: &Path, flags: FmtFlags) -> FileOutcome { // 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) { + match atomic_write_file(file, &result.formatted, Durability::Fsync) { Ok(()) => { if !quiet { eprintln!("Formatted: {}", crate::output::safe_path(file)); @@ -326,10 +328,14 @@ fn run_fmt_directory(dir: &Path, flags: FmtFlags) -> Result<()> { ); std::process::exit(1); } - if !flags.quiet { - eprintln!("No .mds files found in {}", crate::output::safe_path(dir)); - } - return Ok(()); + // #204: an empty tree is "nothing to format", not success (mirrors build.rs). + // Emitted even under --quiet and exit 1. This arm sits BEFORE the `read_only` + // split below, so `--check` and `--diff` behave identically on an empty tree. + eprintln!( + "no .mds files found in {}; nothing was formatted", + crate::output::safe_path(dir) + ); + std::process::exit(1); } let read_only = flags.check || flags.diff; diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index c3a8bb4d..15ea2427 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -80,7 +80,7 @@ use crate::build::{ use crate::output::{ atomic_write_file, collect_mds_files_detailed, eprint_error, eprint_warning, relabel_stdin_error, render_unified_diff, safe_file_display, safe_inline, safe_path, - STDIN_DISPLAY_LABEL, + Durability, STDIN_DISPLAY_LABEL, }; // AC-224-15: No local rule-name list. The single source of truth is @@ -1071,7 +1071,7 @@ fn run_lint_file( residual, } => { emit_result(format, &residual, quiet, named_source); - atomic_write_file(path, &new_source)?; + atomic_write_file(path, &new_source, Durability::Fsync)?; if !quiet { eprintln!("Fixed: {}", safe_path(path)); } @@ -1084,7 +1084,7 @@ fn run_lint_file( total_count, } => { emit_result(format, &residual, quiet, named_source); - atomic_write_file(path, &new_source)?; + atomic_write_file(path, &new_source, Durability::Fsync)?; // Print status AFTER write succeeds so "Partially fixed:" never // precedes "error writing" for a file that was never modified. if !quiet { @@ -1371,11 +1371,12 @@ fn run_lint_directory( let walk = collect_mds_files_detailed(dir, MAX_DEPTH, None); let mut files = walk.files; - // AD-216-9: empty-dir early return emits no summary — the per-file loop never + // AD-216-9: neither early exit below emits a summary — the per-file loop never // runs, so all four counters stay at zero and there is nothing meaningful to - // print. The all-excluded diagnostic (below) bypasses --quiet and exits 2: - // parity with `build`/`check`/`fmt` (a silent non-zero exit here would be a - // bug, not a feature). + // print. Both diagnostics bypass --quiet and exit 2 (lint's usage-error code; + // build/check/fmt use 1): the all-excluded arm, and since #204 the empty-tree + // arm — a silent non-zero exit here would be a bug, and a silent ZERO exit on an + // empty tree was the CI green-pass hole #204 closes. if files.is_empty() { if walk.excluded_by_default > 0 { // Always emit — not suppressed by --quiet (avoids silent CI green pass). @@ -1387,10 +1388,14 @@ fn run_lint_directory( ); std::process::exit(2); } - if !quiet { - eprintln!("No .mds files found in {}", safe_path(dir)); - } - return Ok(()); + // #204: an empty tree is "nothing to lint", not success (mirrors build.rs). + // Emitted even under --quiet. Exit 2 is lint's usage-error code (module doc), + // matching the all-excluded arm above; build/check/fmt use 1. + eprintln!( + "no .mds files found in {}; nothing was linted", + safe_path(dir) + ); + std::process::exit(2); } // F1: sort by (sanitized_display_key, raw_os_path) so that: @@ -1657,7 +1662,7 @@ fn lint_one_file_accumulating( // Write first (AC-F-14): on failure push a structured error entry so // the JSON envelope truthfully reflects what happened rather than // accumulating the clean post-fix result before the write is attempted. - if let Err(e) = atomic_write_file(file, &new_source) { + if let Err(e) = atomic_write_file(file, &new_source, Durability::Fsync) { json_files.push(serde_json::json!({ "file": file_key, "error": MdsError::Io { message: format!("{e}") }.serialize() @@ -1679,7 +1684,7 @@ fn lint_one_file_accumulating( } => { // Write first (AC-F-14 + print-after-write): on failure push a // structured error entry; only accumulate and print on success. - if let Err(e) = atomic_write_file(file, &new_source) { + if let Err(e) = atomic_write_file(file, &new_source, Durability::Fsync) { json_files.push(serde_json::json!({ "file": file_key, "error": MdsError::Io { message: format!("{e}") }.serialize() @@ -1861,7 +1866,7 @@ fn lint_one_file_human( residual, } => { render_result_human(&residual, quiet, named_source); - if let Err(e) = atomic_write_file(file, &new_source) { + if let Err(e) = atomic_write_file(file, &new_source, Durability::Fsync) { eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e)); return FileTally::Error; } @@ -1877,7 +1882,7 @@ fn lint_one_file_human( total_count, } => { render_result_human(&residual, quiet, named_source); - if let Err(e) = atomic_write_file(file, &new_source) { + if let Err(e) = atomic_write_file(file, &new_source, Durability::Fsync) { eprintln!("error writing {}: {}", safe_path(file), safe_inline(&e)); return FileTally::Error; } diff --git a/crates/mds-cli/src/main.rs b/crates/mds-cli/src/main.rs index bb846fcb..8d2a6b20 100644 --- a/crates/mds-cli/src/main.rs +++ b/crates/mds-cli/src/main.rs @@ -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"; repeating a key warns, last value wins) #[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, e.g. -o out.md → out.md.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, @@ -342,10 +342,14 @@ fn run_check_directory( ); std::process::exit(1); } - if !quiet { - eprintln!("No .mds files found in {}", output::safe_path(dir)); - } - return Ok(()); + // #204: an empty tree is "nothing to check", not success (mirrors build.rs). + // Emitted even under --quiet and exit 1, the same "nothing was done" code + // build and fmt use. + eprintln!( + "no .mds files found in {}; nothing was checked", + output::safe_path(dir) + ); + std::process::exit(1); } let mut ok_count: usize = 0; @@ -414,6 +418,13 @@ Your items: - {{item}} @end "; + // Raw std::fs::write is deliberate (#227). What it is NOT justified by: "init only + // ever creates a file". `--force` skips the exists-check above and truncates in + // place, and both `exists()` and `write` follow a symlink, so a link at `filename` + // — dangling, or live under `--force` — is written through to its target. The raw + // write stays because the blast radius is small and entirely user-directed: + // `filename` is typed on the command line and `starter` is a fixed public template + // that a re-run reproduces. Allow-listed in tests/write_funnel.rs. std::fs::write(&filename, starter) .map_err(|e| miette::miette!("cannot write {}: {e}", filename.display()))?; if !quiet { diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index 95e64eec..8b7e14a4 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -8,7 +8,10 @@ //! - [`probe_and_remove_stale`]: stale-output cleanup for format-flip (AC-FUNC-23). //! - [`eprint_error`]: the single CLI stderr choke-point — escapes every report's //! message, help, and label text before miette renders it (CWE-150 / PF-014). -//! - [`atomic_write_file`]: temp-file-then-rename writer shared by `fmt` and `lint --fix`. +//! - [`atomic_write_file`]: temp-file-then-rename writer shared by `fmt` and `lint --fix`, +//! and — since #227 — by every `build` / `watch` output and `.map` sidecar. The +//! [`Durability`] argument says whether the bytes are fsynced before the rename; +//! atomicity does not depend on it. //! - [`preview_text_for`]: `--diff` preview output — neutralized on TTY, byte-faithful //! when piped, so redirected diffs stay applicable by `patch`/tooling. //! @@ -580,56 +583,123 @@ pub(crate) fn output_base_no_ext(source: &Path, root: &Path, base: &OutputBase) // ── Atomic file write ───────────────────────────────────────────────────────── +/// How hard [`atomic_write_file`] works to make the new bytes survive a crash. +/// +/// Atomicity — a reader sees either the whole old file or the whole new one, never a +/// truncated mix — is unconditional: it comes from the rename, not from the fsync. This +/// knob only chooses whether the data is forced to stable storage *before* that rename. +/// +/// The split exists because the two families of file MDS writes have different recovery +/// costs, and on macOS `sync_all()` is `F_FULLFSYNC` — a full drive cache flush, ~7 ms +/// per file. Measured on a 500-template `mds watch` startup (#227): 1.44 s → 4.69 s, and +/// the `cli_watch` suite 4.2 s → 8.3 s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Durability { + /// `sync_all()` before the rename. For files whose content exists nowhere else: + /// `mds fmt` and `mds lint --fix` rewrite the user's hand-authored `.mds` source in + /// place, so bytes lost to a power failure are lost for good. + Fsync, + /// Rename only. For **derived** artifacts — compiled outputs and `.map` sidecars — + /// which are reproducible by re-running `mds build`. A crash can leave the previous + /// artifact or an unflushed new one; either way the fix is one rebuild, and paying + /// `F_FULLFSYNC` per file to avoid it costs more than it saves. + RenameOnly, +} + /// 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). /// +/// This is the single write primitive for every file the CLI produces: `fmt` and +/// `lint --fix` rewrites, and — since #227 — every `mds build` / `mds watch` +/// artifact and `.map` sidecar. The parent directory must already exist; callers +/// that need directories create them first. +/// +/// Behaviour: the target is probed with `lstat`. A regular file is replaced +/// (final-component symlink re-check, Unix mode preserved with `& 0o7777`). A +/// symlink at the target — live or dangling — is refused rather than written +/// through. An absent target is created with mode `0666 & !umask`, i.e. what +/// `std::fs::write` produced. Any other stat failure is an error, never a silent +/// mode guess (#225). +/// /// 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. -/// -/// Note: Due to the temp-file-then-rename approach, this function does NOT -/// preserve hard links, ACLs, extended attributes (xattrs), or owner/group -/// metadata of the original file. -pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { +/// `File`) for crash durability before the rename, when `durability` is +/// [`Durability::Fsync`]. Under [`Durability::RenameOnly`] the fsync is skipped; +/// the rename — and therefore the atomicity — is unchanged. See [`Durability`] +/// for which callers pick which and why. +/// - Directory-level symlinks in the path are resolved, not rejected (the same +/// rule `NativeFs::check_symlink` applies). +/// +/// # Contract (#226) +/// +/// This is replace-by-rename, not an in-place rewrite. The target path receives a +/// NEW inode, so the write does NOT preserve hard links (other links keep the old +/// content), ACLs, extended attributes (xattrs), or owner/group of the original +/// file; only the permission bits are carried over (Unix). This applies to every +/// path routed through this helper: `mds fmt` and `mds lint --fix` source +/// rewrites and, under #227, `mds build` / `mds watch` compiled outputs and +/// `.map` sidecars. Hard-link preservation is out of scope by construction (it +/// would require truncate-in-place and forfeit crash safety); ACL/xattr/ +/// owner-group preservation is not planned — MDS only rewrites its own outputs +/// and `.mds` sources. +pub(crate) fn atomic_write_file(path: &Path, content: &str, durability: Durability) -> 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. + // #227: `mds build` targets may not exist yet. Probe with lstat, which never + // follows a symlink: `Ok` means something is there (a regular file, or a + // symlink — live or dangling — which is refused below); `Err(NotFound)` means + // create a new file. Any other lstat failure is a hard error (#225: silently + // writing with a guessed mode was the defect, and a warning is not a decision). + let existing = match path.symlink_metadata() { + Ok(m) => Some(m), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(miette::miette!("cannot stat {}: {e}", path.display())), + }; + + if let Some(m) = &existing { + if m.file_type().is_symlink() { + return Err(miette::miette!( + "cannot write {}: refusing to replace a symlink", + path.display() + )); + } + // Re-check for symlink right before writing (TOCTOU guard). + NativeFs::check_symlink(path) + .map_err(|e| miette::miette!("cannot write {}: {e}", path.display()))?; + } + + // Mode to restore on Unix. The lstat result of a non-symlink IS the file's + // metadata, so there is no second stat call and no site left for the spurious + // metadata warning that fired on every first build (#225, #227). + // `None` = new file. #[cfg(unix)] let original_mode: Option = { use std::os::unix::fs::PermissionsExt as _; - match std::fs::metadata(path) { - Ok(m) => Some(m.permissions().mode()), - Err(e) => { - eprint_error(miette::miette!( - "cannot get metadata for {}: {e}", - path.display() - )); - None - } - } + existing.as_ref().map(|m| m.permissions().mode()) }; // Temp file in same directory so rename is always intra-filesystem. - let mut tmp = tempfile::Builder::new() - .prefix(".mds-tmp-") - .suffix(".tmp") + let mut builder = tempfile::Builder::new(); + builder.prefix(".mds-tmp-").suffix(".tmp"); + // New file: request 0666 and let the kernel apply the umask, so a first + // `mds build` creates the same mode `std::fs::write` did (typically 0644). + // `tempfile`'s default is 0600, which would make every fresh artifact + // owner-only. + #[cfg(unix)] + if original_mode.is_none() { + use std::os::unix::fs::PermissionsExt as _; + builder.permissions(std::fs::Permissions::from_mode(0o666)); + } + let mut tmp = builder .tempfile_in(parent) .map_err(|e| miette::miette!("cannot create temp file for {}: {e}", path.display()))?; @@ -651,10 +721,13 @@ pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { .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()))?; + // unbuffered File and provides no crash durability guarantee). Skipped for + // derived artifacts, which a rebuild reproduces — see `Durability`. + if durability == Durability::Fsync { + 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) @@ -743,12 +816,12 @@ const MAX_AUX_DEPTH: usize = 16; /// /// Every prose surface (`message`, `help`, `code`, `url`, label text) is escaped with /// HUMAN-mode [`mds::sanitize_control_chars`] when the node is built. Label byte spans -/// are copied verbatim, exactly as [`SanitizedReport::labels`] does, so caret geometry +/// are copied verbatim, exactly as `SanitizedReport::labels` does, so caret geometry /// against the parent's already-neutralized source stays exact. /// /// `source_code()` returns `None` by design: a `&dyn miette::SourceCode` cannot be /// cloned out of the inner diagnostic, and miette falls back to the *parent* report's -/// source — which [`SanitizedReport::source_code`] forwards — when a nested diagnostic +/// source — which `SanitizedReport::source_code` forwards — when a nested diagnostic /// supplies none. So a nested diagnostic still renders against neutralized source. struct SanitizedNode { message: String, @@ -2327,4 +2400,320 @@ mod tests { assert!(colorized.contains("\x1b[36m--- a\x1b[0m")); assert!(colorized.contains("\x1b[36m+++ b\x1b[0m")); } + + // ── atomic_write_file ───────────────────────────────────────────────────── + + /// Names of leftover `.mds-tmp-*` entries directly inside `dir`. + fn temp_residue(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".mds-tmp-")) + .collect() + } + + /// T-U1: `mds build` writes artifacts that do not exist yet (#227). The + /// primitive must create the target instead of failing the existence probe. + #[test] + fn atomic_write_file_creates_missing_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("fresh.md"); + assert!(!target.exists(), "precondition: target must be absent"); + + atomic_write_file(&target, "CREATED", Durability::Fsync) + .expect("writing an absent target must succeed"); + + assert_eq!(std::fs::read_to_string(&target).unwrap(), "CREATED"); + let residue = temp_residue(dir.path()); + assert!( + residue.is_empty(), + "no .mds-tmp- residue may survive a successful write; got {residue:?}" + ); + } + + /// T-U9: `Durability::RenameOnly` changes ONLY whether the temp file is fsynced. + /// Everything the callers rely on — the content, the mode of a freshly created + /// artifact, the symlink refusal, and leaving no temp residue — must be identical + /// to `Fsync` (#227). The fsync itself is not observable from a passing process; + /// what this pins is that skipping it did not quietly relax anything else. + #[test] + fn atomic_write_file_rename_only_matches_fsync_contract() { + let dir = tempfile::tempdir().unwrap(); + + // Fresh target: created, with the same content and mode as the Fsync sibling. + let quick = dir.path().join("quick.md"); + let synced = dir.path().join("synced.md"); + atomic_write_file(&quick, "DERIVED", Durability::RenameOnly).unwrap(); + atomic_write_file(&synced, "DERIVED", Durability::Fsync).unwrap(); + assert_eq!(std::fs::read_to_string(&quick).unwrap(), "DERIVED"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&quick).unwrap().permissions().mode() & 0o777, + std::fs::metadata(&synced).unwrap().permissions().mode() & 0o777, + "RenameOnly must not change the mode a fresh artifact is created with" + ); + } + + // Existing target: replaced, previous content gone. + atomic_write_file(&quick, "REBUILT", Durability::RenameOnly).unwrap(); + assert_eq!(std::fs::read_to_string(&quick).unwrap(), "REBUILT"); + + // Symlink target: still refused (the fsync is not what enforces this). + #[cfg(unix)] + { + let real = dir.path().join("real.md"); + std::fs::write(&real, "REAL").unwrap(); + let link = dir.path().join("link.md"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let err = atomic_write_file(&link, "NEW", Durability::RenameOnly) + .expect_err("RenameOnly must still refuse a symlink target"); + assert!( + err.to_string().contains("symlink"), + "the refusal must say why; got: {err}" + ); + assert_eq!(std::fs::read_to_string(&real).unwrap(), "REAL"); + } + + let residue = temp_residue(dir.path()); + assert!( + residue.is_empty(), + "RenameOnly must leave no .mds-tmp- residue; got {residue:?}" + ); + } + + /// T-U2: a freshly created artifact must carry the same mode `std::fs::write` + /// would have produced (`0666 & !umask`), not `tempfile`'s owner-only 0600. + /// The sibling control makes the assertion umask-independent. + #[cfg(unix)] + #[test] + fn atomic_write_file_new_file_mode_matches_std_fs_write() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out.md"); + let ctl = dir.path().join("ctl.md"); + + atomic_write_file(&out, "X", Durability::Fsync).unwrap(); + std::fs::write(&ctl, "X").unwrap(); + + let mode_out = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777; + let mode_ctl = std::fs::metadata(&ctl).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode_out, mode_ctl, + "new-file mode must match std::fs::write; got 0{mode_out:o} vs control 0{mode_ctl:o}" + ); + } + + /// T-U3: an existing file keeps its mode across the replace-by-rename cycle. + #[cfg(unix)] + #[test] + fn atomic_write_file_existing_mode_0640_preserved() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("src.mds"); + std::fs::write(&target, "OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + + atomic_write_file(&target, "NEW", Durability::Fsync).unwrap(); + + let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777; + assert_eq!( + mode, 0o640, + "existing mode must be preserved; got 0{mode:o}" + ); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW"); + } + + /// T-U4: a symlink at the target is refused, never written through. The + /// control writes the symlink's own target directly and must succeed, so the + /// refusal is not passing on an unrelated failure. + #[cfg(unix)] + #[test] + fn atomic_write_file_refuses_live_symlink_target() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real.md"); + let link = dir.path().join("link.md"); + std::fs::write(&real, "REAL").unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let err = atomic_write_file(&link, "NEW", Durability::Fsync) + .expect_err("writing through a symlink must be refused") + .to_string(); + assert!( + err.contains("symlink"), + "expected a symlink refusal; got {err}" + ); + assert_eq!( + std::fs::read_to_string(&real).unwrap(), + "REAL", + "the symlink's target must not be written through" + ); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink(), + "the symlink itself must survive the refusal" + ); + + // CONTROL: the same directory and content, addressed at the real file. + atomic_write_file(&real, "NEW", Durability::Fsync) + .expect("writing the real file must succeed"); + assert_eq!(std::fs::read_to_string(&real).unwrap(), "NEW"); + } + + /// T-U5: a dangling symlink is still a symlink — refuse it rather than + /// materialising the missing file it points at. + #[cfg(unix)] + #[test] + fn atomic_write_file_refuses_dangling_symlink_target() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.md"); + let link = dir.path().join("link.md"); + std::os::unix::fs::symlink(&missing, &link).unwrap(); + + let err = atomic_write_file(&link, "NEW", Durability::Fsync) + .expect_err("writing through a dangling symlink must be refused") + .to_string(); + assert!( + err.contains("symlink"), + "expected a symlink refusal; got {err}" + ); + assert!( + !missing.exists(), + "the dangling link's target must not be created" + ); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink(), + "the symlink itself must survive the refusal" + ); + } + + /// T-U6: a failed write leaves the original inode, bytes and mtime untouched + /// and drops the temp file. The control proves the same call succeeds once + /// the directory is writable again, and that success DOES replace the inode. + #[cfg(unix)] + #[test] + fn atomic_write_file_failure_preserves_original_and_leaves_no_temp() { + use std::os::unix::fs::MetadataExt as _; + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + let target = sub.join("locked.mds"); + std::fs::write(&target, "OLD").unwrap(); + + let before = std::fs::metadata(&target).unwrap(); + let (ino, mtime) = (before.ino(), before.modified().unwrap()); + + std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o555)).unwrap(); + let result = atomic_write_file(&target, "NEW", Durability::Fsync); + // Restore before asserting so a failed assertion cannot leave an + // undeletable tempdir behind. + std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let err = result + .expect_err("a read-only parent directory must fail the write") + .to_string(); + assert!( + err.contains("locked.mds"), + "error must name the target; got {err}" + ); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "OLD"); + let after = std::fs::metadata(&target).unwrap(); + assert_eq!( + after.ino(), + ino, + "a failed write must not replace the inode" + ); + assert_eq!( + after.modified().unwrap(), + mtime, + "a failed write must not touch the mtime" + ); + let residue = temp_residue(&sub); + assert!( + residue.is_empty(), + "failed write left temp residue: {residue:?}" + ); + + // CONTROL: writable again — the same call succeeds and swaps the inode. + atomic_write_file(&target, "NEW", Durability::Fsync) + .expect("write must succeed once the dir is writable"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW"); + assert_ne!( + std::fs::metadata(&target).unwrap().ino(), + ino, + "replace-by-rename must produce a new inode" + ); + } + + /// T-U7: a directory at the target is an error, not a clobber, and leaves no + /// temp file behind in the parent. + #[test] + fn atomic_write_file_directory_target_refused_without_residue() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("adir"); + std::fs::create_dir(&target).unwrap(); + + let err = atomic_write_file(&target, "X", Durability::Fsync) + .expect_err("a directory target must not be written") + .to_string(); + assert!( + err.contains("adir"), + "error must name the target; got {err}" + ); + assert!(target.is_dir(), "the directory must survive the refusal"); + let residue = temp_residue(dir.path()); + assert!( + residue.is_empty(), + "refused write left temp residue: {residue:?}" + ); + } + + /// T-U8: a stat failure that is NOT `NotFound` is a hard error — never a + /// warning followed by a write with a guessed mode (#225). + #[cfg(unix)] + #[test] + fn atomic_write_file_unreadable_parent_is_hard_error() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("nosearch"); + std::fs::create_dir(&p).unwrap(); + // Planted before the chmod so the root probe below has something to stat. + let probe = p.join("probe"); + std::fs::write(&probe, "").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap(); + + if std::fs::metadata(&probe).is_ok() { + // Root bypasses the mode bits; EACCES cannot be provoked here. + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + eprintln!("running as root; cannot exercise EACCES"); + return; + } + + let result = atomic_write_file(&p.join("x.md"), "X", Durability::Fsync); + // Restore before asserting so tempdir cleanup always succeeds. + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let err = result + .expect_err("an unstattable target must be a hard error") + .to_string(); + assert!( + err.contains("cannot stat"), + "expected a stat error; got {err}" + ); + assert!( + err.contains("x.md"), + "error must name the target; got {err}" + ); + } } diff --git a/crates/mds-cli/src/watch.rs b/crates/mds-cli/src/watch.rs index 88a86569..53c2306a 100644 --- a/crates/mds-cli/src/watch.rs +++ b/crates/mds-cli/src/watch.rs @@ -524,6 +524,8 @@ fn emit_ready_marker() { let mut tmp = path.clone().into_os_string(); tmp.push(".tmp"); let tmp = PathBuf::from(tmp); + // Raw write is deliberate (#227): this is already temp+rename. Allow-listed in + // tests/write_funnel.rs. if std::fs::write(&tmp, READY_MARKER).is_ok() { let _ = std::fs::rename(&tmp, &path); } diff --git a/crates/mds-cli/tests/cli_build.rs b/crates/mds-cli/tests/cli_build.rs index 90b48b14..76a03917 100644 --- a/crates/mds-cli/tests/cli_build.rs +++ b/crates/mds-cli/tests/cli_build.rs @@ -1720,3 +1720,343 @@ fn fmt_unknown_lint_rule_in_mds_json_emits_no_warning() { String::from_utf8_lossy(&out_bad.stderr) ); } + +// ── Atomic build outputs (#227) ────────────────────────────────────────────── +// +// `mds build -o ` routes its write through `crate::output::atomic_write_file` +// (temp file in the target directory → fsync → rename). These tests pin the four +// user-visible consequences: a first build stays silent apart from `Compiled to`, the +// created file keeps the mode `std::fs::write` produced, an existing file keeps its own +// mode, a symlinked target is refused, and no failure path leaves a temp file behind. + +/// Any `.mds-tmp-` prefixed entry directly inside `dir` (the temp-file prefix used by +/// `atomic_write_file`). Empty means the write left no residue. +fn temp_residue(dir: &std::path::Path) -> Vec { + std::fs::read_dir(dir) + .expect("directory must be readable") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".mds-tmp-")) + .collect() +} + +/// T-B1: the first build into a nonexistent nested directory announces exactly one line. +/// +/// Sentinel against a naive reroute: before #227 the primitive stat'd the target and +/// warned when it was absent, so a reroute that kept that warning would add a second +/// stderr line on every first build (#225). This test is GREEN on the pre-reroute tree +/// and must stay green. +#[test] +fn build_o_first_build_emits_only_compiled_to() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let out = dir.path().join("new").join("dir").join("out.md"); + + let output = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_eq!( + output.status.code(), + Some(0), + "first build into a nested new directory must succeed; stderr: {stderr}" + ); + let written = std::fs::read_to_string(&out).unwrap(); + assert!( + written.contains("Hello!"), + "the compiled artifact must be written; got: {written:?}" + ); + + let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!( + lines.len(), + 1, + "a first build must emit exactly one stderr line; got: {lines:?}" + ); + assert!( + lines[0].starts_with("Compiled to"), + "the one line must be the `Compiled to` announce; got: {:?}", + lines[0] + ); + for forbidden in ["cannot get metadata", "cannot stat", "warning"] { + assert!( + !stderr.contains(forbidden), + "a first build must not emit {forbidden:?}; got: {stderr:?}" + ); + } +} + +/// T-B2: a newly created output has the same mode `std::fs::write` would have produced +/// (umask-dependent, so it is compared against a live control in the same directory). +#[cfg(unix)] +#[test] +fn build_o_new_output_mode_matches_std_fs_write() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let out = dir.path().join("out.md"); + let ctl = dir.path().join("ctl.md"); + std::fs::write(&ctl, "Hello!\n").unwrap(); + + let output = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + output.status.success(), + "build must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let got = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777; + let want = std::fs::metadata(&ctl).unwrap().permissions().mode() & 0o777; + assert_eq!( + got, want, + "a fresh build artifact must have the same mode std::fs::write produces \ + (got {got:o}, control {want:o})" + ); +} + +/// T-B3: building over an existing output preserves that file's mode. +/// +/// This is a pin: `std::fs::write` over an existing file also preserves the mode, so it +/// was already true before the reroute. It exists so a future change to the primitive +/// cannot quietly widen or narrow permissions on rebuild. +#[cfg(unix)] +#[test] +fn build_o_existing_output_mode_0640_preserved() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let out = dir.path().join("out.md"); + std::fs::write(&out, "STALE").unwrap(); + std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o640)).unwrap(); + + let output = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + output.status.success(), + "rebuild must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o640, "rebuild must preserve mode 0640, got {mode:o}"); + let written = std::fs::read_to_string(&out).unwrap(); + assert!( + written.contains("Hello!") && !written.contains("STALE"), + "rebuild must replace the stale content; got: {written:?}" + ); +} + +/// T-B4: `-o` at a symlink is refused; the link and its target are left untouched. +/// Positive control in the same test: the same build against the real file succeeds. +#[cfg(unix)] +#[test] +fn build_o_symlinked_output_target_rejected() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let real = dir.path().join("real.md"); + std::fs::write(&real, "REAL").unwrap(); + let link = dir.path().join("link.md"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let refused = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&link) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8(refused.stderr).unwrap(); + assert_ne!( + refused.status.code(), + Some(0), + "building onto a symlink must fail; stderr: {stderr}" + ); + assert!( + stderr.contains("symlink"), + "the refusal must say why; got: {stderr:?}" + ); + assert_eq!( + std::fs::read_to_string(&real).unwrap(), + "REAL", + "the symlink target must not be written through" + ); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink(), + "the symlink itself must survive the refusal" + ); + + // Positive control: the same build against the real path is accepted. + let ok = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&real) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert_eq!( + ok.status.code(), + Some(0), + "control: building onto the real file must succeed; stderr: {}", + String::from_utf8_lossy(&ok.stderr) + ); + let updated = std::fs::read_to_string(&real).unwrap(); + assert!( + updated.contains("Hello!") && !updated.contains("REAL"), + "control: the real file must be updated; got: {updated:?}" + ); +} + +/// T-B5: a failed write leaves the previous artifact intact and no temp file behind. +#[cfg(unix)] +#[test] +fn build_o_write_failure_preserves_existing_output_no_temp_residue() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let out_dir = dir.path().join("d"); + std::fs::create_dir(&out_dir).unwrap(); + let out = out_dir.join("out.md"); + std::fs::write(&out, "OLD").unwrap(); + + // Read-only parent: the temp file cannot be created, so the write fails before the + // rename — exactly the crash window `atomic_write_file` exists to close. + std::fs::set_permissions(&out_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + + let output = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + // Restore writability BEFORE asserting so a failing assertion cannot leave an + // undeletable tempdir behind. + let _ = std::fs::set_permissions(&out_dir, std::fs::Permissions::from_mode(0o755)); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_ne!( + output.status.code(), + Some(0), + "a build into a read-only directory must fail; stderr: {stderr}" + ); + assert!( + stderr.contains("out.md"), + "the error must name the target file; got: {stderr:?}" + ); + assert_eq!( + std::fs::read_to_string(&out).unwrap(), + "OLD", + "the previous artifact must survive a failed write" + ); + assert!( + temp_residue(&out_dir).is_empty(), + "a failed write must leave no temp file; found: {:?}", + temp_residue(&out_dir) + ); +} + +/// T-B6: a successful build leaves no temp file behind either. +#[test] +fn build_o_success_leaves_no_temp_residue() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello!\n").unwrap(); + let out = dir.path().join("out.md"); + + let output = mds_bin() + .arg("build") + .arg(&src) + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + output.status.success(), + "build must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + std::fs::read_to_string(&out).unwrap().contains("Hello!"), + "non-vacuity: the artifact must actually have been written" + ); + assert!( + temp_residue(dir.path()).is_empty(), + "a successful write must leave no temp file; found: {:?}", + temp_residue(dir.path()) + ); +} + +/// T-B7: a bare `-o out.md` resolves against the process cwd (the temp-file directory is +/// `effective_parent`, which maps "" to "."). +#[test] +fn build_o_bare_filename_writes_in_cwd() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("in.mds"), "Hello!\n").unwrap(); + + let output = mds_bin() + .current_dir(dir.path()) + .args(["build", "in.mds", "-o", "out.md"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!( + output.status.success(), + "bare-filename -o must succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + std::fs::read_to_string(dir.path().join("out.md")) + .unwrap() + .contains("Hello!"), + "out.md must be written in the process cwd" + ); + assert!( + temp_residue(dir.path()).is_empty(), + "no temp residue; found: {:?}", + temp_residue(dir.path()) + ); +} diff --git a/crates/mds-cli/tests/cli_commands.rs b/crates/mds-cli/tests/cli_commands.rs index a88d9e94..aa2fcedc 100644 --- a/crates/mds-cli/tests/cli_commands.rs +++ b/crates/mds-cli/tests/cli_commands.rs @@ -569,10 +569,14 @@ fn exit_code_fmt_oversized() { ); } -// Directory input: an empty directory exits 0 (prints "No .mds files found"). -// Directory build is now supported — the old "must fail" test is updated to match new behavior. -#[test] -fn cli_build_directory_empty_exits_zero() { +// #204 reversal: until v0.4.3 this test was `cli_build_directory_empty_exits_zero` +// and pinned exit 0 for an empty directory. +// Directory input: an empty directory exits 1 (#204) — "nothing to build" is an +// error, as it already was for bare auto-detect +// (cli_build.rs::build_errors_when_no_mds_files_in_directory). A missing path is NOT +// a directory and takes the single-file path → exit 2. +#[test] +fn cli_build_directory_empty_exits_one_missing_root_exits_two() { let dir = tempfile::tempdir().unwrap(); let output = mds_bin() @@ -583,15 +587,37 @@ fn cli_build_directory_empty_exits_zero() { .output() .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "build on an empty directory must exit 1; stderr: {stderr}" + ); assert!( - output.status.success(), - "build on an empty directory should succeed (no .mds files); stderr: {}", - String::from_utf8_lossy(&output.stderr) + stderr.contains("no .mds files found in"), + "stderr must carry the empty-tree diagnostic; got: {stderr:?}" + ); + + // A path that does not exist is not a directory: it takes the single-file path + // and reports mds::file_not_found (exit 2), never the empty-tree diagnostic. + let missing = dir.path().join("does-not-exist"); + let output_missing = mds_bin() + .arg("build") + .arg(&missing) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr_missing = String::from_utf8_lossy(&output_missing.stderr); + assert_eq!( + output_missing.status.code(), + Some(2), + "build on a missing path must exit 2; stderr: {stderr_missing}" ); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("No .mds files") || stderr.contains("no .mds"), - "stderr should mention no .mds files found; got: {stderr}" + !stderr_missing.contains("no .mds files found in"), + "a missing path must NOT produce the empty-tree diagnostic; got: {stderr_missing:?}" ); } diff --git a/crates/mds-cli/tests/cli_lint.rs b/crates/mds-cli/tests/cli_lint.rs index 2270ed21..b81ca507 100644 --- a/crates/mds-cli/tests/cli_lint.rs +++ b/crates/mds-cli/tests/cli_lint.rs @@ -5659,39 +5659,123 @@ fn bare_lint_never_enters_directory_mode() { // ── AC-Q21: empty and all-excluded directory print no summary ───────────────── +/// #204 reversal: until v0.4.3 this test was `lint_directory_empty_prints_no_summary` +/// and pinned exit 0 with a silent `--quiet` half. An empty tree is now "nothing was +/// linted" — exit 2 (lint's usage-error code), diagnostic bypassing `--quiet`, same +/// as lint's all-excluded arm. The no-summary half of AC-Q21 is unchanged and still +/// asserted, with the same positive control proving `clean,` is the real needle. #[test] -fn lint_directory_empty_prints_no_summary() { +fn lint_directory_empty_exits_two_prints_no_summary() { // Empty directory. let dir = tempfile::tempdir().unwrap(); let non_quiet = lint_path(dir.path(), &[]); let stderr_nq = String::from_utf8_lossy(&non_quiet.stderr); + let stdout_nq = String::from_utf8_lossy(&non_quiet.stdout); assert_eq!( non_quiet.status.code(), - Some(0), - "empty directory lint must exit 0; stderr: {stderr_nq}" + Some(2), + "empty directory lint must exit 2; stderr: {stderr_nq}" + ); + assert!( + stderr_nq.contains("no .mds files found in"), + "empty directory must print the empty-tree diagnostic; got: {stderr_nq:?}" ); assert!( - stderr_nq.contains("No .mds files found"), - "empty directory must print 'No .mds files found'; got: {stderr_nq:?}" + stderr_nq.contains("nothing was linted"), + "empty directory must say nothing was linted; got: {stderr_nq:?}" ); assert!( !stderr_nq.contains("clean,"), "AC-Q21: empty directory must NOT print a summary; got: {stderr_nq:?}" ); + assert!( + !stdout_nq.contains("clean,"), + "AC-Q21: empty directory must NOT print a summary on stdout; got: {stdout_nq:?}" + ); let quiet = lint_path(dir.path(), &["--quiet"]); let stderr_q = String::from_utf8_lossy(&quiet.stderr); + let stdout_q = String::from_utf8_lossy(&quiet.stdout); assert_eq!( quiet.status.code(), - Some(0), - "empty directory lint --quiet must exit 0; stderr: {stderr_q}" + Some(2), + "empty directory lint --quiet must exit 2; stderr: {stderr_q}" + ); + assert!( + stderr_q.contains("no .mds files found in"), + "#204: the empty-tree diagnostic must appear under --quiet; got: {stderr_q:?}" ); assert!( - stderr_q.is_empty(), - "AC-Q21: empty directory under --quiet must produce no stderr; got: {stderr_q:?}" + stderr_q.contains("nothing was linted"), + "#204: --quiet must still say nothing was linted; got: {stderr_q:?}" + ); + assert!( + !stderr_q.contains("clean,"), + "AC-Q21: empty directory under --quiet must NOT print a summary; got: {stderr_q:?}" + ); + assert!( + !stdout_q.contains("clean,"), + "AC-Q21: empty directory under --quiet must NOT print a summary on stdout; \ + got: {stdout_q:?}" + ); + + // Positive control: prove "clean," is the exact substring a real directory summary + // contains, so the absence assertions above cannot pass vacuously. + let control_dir = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_clean.mds"), control_dir.path().join("a.mds")).unwrap(); + let control = lint_path(control_dir.path(), &[]); + assert!( + String::from_utf8_lossy(&control.stderr).contains("clean,"), + "positive control: a clean file in directory mode must produce a summary \ + containing the exact needle \"clean,\"; got: {:?}", + String::from_utf8_lossy(&control.stderr) + ); +} + +/// #204: `--format json` on an empty tree exits 2 with the stderr diagnostic and +/// emits NO JSON envelope on stdout — the empty-tree arm returns before any emitter +/// runs, exactly as lint's all-excluded arm does (it emits no envelope either). +/// Relax the stdout-empty half if a JSON envelope for "nothing to lint" is ever added. +#[test] +fn lint_directory_empty_format_json_exits_two_no_envelope() { + let dir = tempfile::tempdir().unwrap(); + + let out = lint_path(dir.path(), &["--format", "json"]); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert_eq!( + out.status.code(), + Some(2), + "empty directory lint --format json must exit 2; stderr: {stderr}" + ); + assert!( + stderr.contains("no .mds files found in"), + "the empty-tree diagnostic must appear on stderr in JSON mode; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was linted"), + "JSON mode must still say nothing was linted; got: {stderr:?}" + ); + assert!( + stdout.is_empty(), + "empty directory lint --format json must emit no envelope on stdout; \ + got: {stdout:?}" + ); + + // Positive control: a directory with one .mds file DOES emit a + // JSON envelope on stdout, so the emptiness assertion above is not vacuous. + let control_dir = tempfile::tempdir().unwrap(); + fs::copy(fixture("lint_clean.mds"), control_dir.path().join("a.mds")).unwrap(); + let control = lint_path(control_dir.path(), &["--format", "json"]); + assert!( + String::from_utf8_lossy(&control.stdout).contains('{'), + "positive control: a non-empty directory must emit a JSON envelope on stdout; \ + got: {:?}", + String::from_utf8_lossy(&control.stdout) ); } diff --git a/crates/mds-cli/tests/cli_source_map.rs b/crates/mds-cli/tests/cli_source_map.rs index 485f99ff..739ccf94 100644 --- a/crates/mds-cli/tests/cli_source_map.rs +++ b/crates/mds-cli/tests/cli_source_map.rs @@ -1542,3 +1542,103 @@ fn sm20b_embed_sources_with_source_map_no_warning() { "embed_sources+source_map must NOT warn about no effect; got: {stderr:?}" ); } + +// ── Atomic `.map` sidecars (#227) ──────────────────────────────────────────── +// +// Both sidecar writers — the single-file `-o` path and the stdin `-o` path — go through +// `crate::output::atomic_write_file`, so a symlink at `.map` is refused instead of +// being written through. These two tests are the only way to tell the sites apart from +// the CLI: they differ only in how the source reaches the compiler. + +/// T-S1: single-file `--source-map` refuses a symlinked sidecar path. +#[cfg(unix)] +#[test] +fn build_source_map_sidecar_symlink_target_rejected() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("in.mds"); + std::fs::write(&src, "Hello sidecar!\n").unwrap(); + let out = dir.path().join("out.md"); + let real = dir.path().join("real.map"); + std::fs::write(&real, "OLD").unwrap(); + std::os::unix::fs::symlink(&real, dir.path().join("out.md.map")).unwrap(); + + let result = mds_bin() + .arg("build") + .arg(&src) + .arg("--source-map") + .arg("-o") + .arg(&out) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&result.stderr).into_owned(); + assert_ne!( + result.status.code(), + Some(0), + "a symlinked sidecar path must fail the build; stderr: {stderr}" + ); + assert!( + stderr.contains("symlink"), + "the refusal must say why; got: {stderr:?}" + ); + assert!( + out.exists(), + "the compiled artifact itself is written before the sidecar and must survive" + ); + assert_eq!( + std::fs::read_to_string(&real).unwrap(), + "OLD", + "the symlink target must not be written through" + ); +} + +/// T-S2: the stdin `--source-map` sidecar writer is a second, distinct site — it must +/// refuse a symlinked sidecar path too. +#[cfg(unix)] +#[test] +fn build_stdin_source_map_sidecar_symlink_target_rejected() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out.md"); + let real = dir.path().join("real.map"); + std::fs::write(&real, "OLD").unwrap(); + std::os::unix::fs::symlink(&real, dir.path().join("out.md.map")).unwrap(); + + 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(); + let stderr = String::from_utf8_lossy(&result.stderr).into_owned(); + assert_ne!( + result.status.code(), + Some(0), + "a symlinked sidecar path must fail the stdin build; stderr: {stderr}" + ); + assert!( + stderr.contains("symlink"), + "the refusal must say why; got: {stderr:?}" + ); + assert!( + out.exists(), + "the compiled artifact itself is written before the sidecar and must survive" + ); + assert_eq!( + std::fs::read_to_string(&real).unwrap(), + "OLD", + "the symlink target must not be written through" + ); +} diff --git a/crates/mds-cli/tests/cli_watch.rs b/crates/mds-cli/tests/cli_watch.rs index cf016283..ab216733 100644 --- a/crates/mds-cli/tests/cli_watch.rs +++ b/crates/mds-cli/tests/cli_watch.rs @@ -469,6 +469,48 @@ fn watch_dir_mode_picks_up_new_files() { drop(child); } +// ── #204 pin: watch is UNCHANGED on an empty root ────────────────────────── +// +// GREEN before and after #204. `mds build|check|fmt|lint ` now exits +// non-zero, but `mds watch ` deliberately does NOT error: a file +// created later is a valid flow there, and this test is what pins that. + +#[test] +fn watch_dir_mode_empty_root_starts_and_picks_up_new_file() { + let dir = tempfile::tempdir().unwrap(); + let out_dir = dir.path().join("out"); + std::fs::create_dir(&out_dir).unwrap(); + + // `spawn_ready` returning IS the armed proof: it panics with "exited before + // signalling readiness" if the watcher bailed out on the empty root. + let (child, _stderr_tap) = spawn_ready( + mds_bin() + .args([ + "watch", + dir.path().to_str().unwrap(), + "--out-dir", + out_dir.to_str().unwrap(), + "--debounce", + "0", + "-q", + ]) + .stdout(Stdio::null()), + ); + + // Create the first .mds file AFTER the watcher armed on the empty root. + write_atomic( + &dir.path().join("c.mds"), + "---\nname: C\n---\nNew file {{name}}\n", + ); + + assert!( + wait_for_file_contains(&out_dir.join("c.md"), "New file C", TIMEOUT), + "a file created under an initially empty watch root should be compiled" + ); + + drop(child); +} + // ── T-I8: Directory mode deletes output when source is deleted ───────────── #[test] diff --git a/crates/mds-cli/tests/dir_build.rs b/crates/mds-cli/tests/dir_build.rs index f98e4edf..b81a138b 100644 --- a/crates/mds-cli/tests/dir_build.rs +++ b/crates/mds-cli/tests/dir_build.rs @@ -400,21 +400,78 @@ fn dir_check_continues_on_error_nonzero_exit() { ); } +/// #204 reversal: until v0.4.3 this test was `dir_check_empty_dir_exits_zero` and +/// pinned exit 0. `mds check` now mirrors `mds build`: an empty tree is "nothing +/// was checked", exit 1. #[test] -fn dir_check_empty_dir_exits_zero() { +fn dir_check_empty_dir_exits_one() { let src = tempfile::tempdir().unwrap(); let output = check_dir(src.path(), &[]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "check on an empty dir must exit 1; stderr: {stderr}" + ); assert!( - output.status.success(), - "check on empty dir should succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) + stderr.contains("no .mds files found in"), + "stderr must carry the empty-tree diagnostic; got: {stderr:?}" ); + assert!( + stderr.contains("nothing was checked"), + "stderr must say nothing was checked; got: {stderr:?}" + ); +} + +/// #204: `--quiet` must not suppress `mds check`'s empty-tree diagnostic. +#[test] +fn dir_check_empty_dir_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let output = check_dir(src.path(), &["--quiet"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "check --quiet on an empty dir must exit 1; stderr: {stderr}" + ); assert!( - stderr.contains("No .mds files") || stderr.contains("no .mds"), - "stderr should mention no files found; got: {stderr}" + !stderr.is_empty(), + "stderr must not be empty under --quiet on an empty tree" + ); + assert!( + stderr.contains("no .mds files found in"), + "empty-tree diagnostic must appear under --quiet; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was checked"), + "stderr must say nothing was checked under --quiet; got: {stderr:?}" + ); +} + +/// #204 boundary pin: a path that does NOT exist is not a directory, so it takes +/// the single-file path and exits 2 (`mds::file_not_found`) — unchanged by #204. +/// GREEN both before and after the fix; it exists to prove the new exit-1 arm +/// did not swallow the missing-path case. +#[test] +fn dir_check_missing_root_exits_two() { + let src = tempfile::tempdir().unwrap(); + let missing = src.path().join("nope"); + + let output = check_dir(&missing, &[]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(2), + "check on a missing path must exit 2; stderr: {stderr}" + ); + assert!( + !stderr.contains("no .mds files found in"), + "a missing path must NOT produce the empty-tree diagnostic; got: {stderr:?}" ); } @@ -484,23 +541,60 @@ fn dir_build_stale_output_cleaned_on_format_flip() { ); } -// ── T-CLI: empty dir build exits zero ──────────────────────────────────────── +// ── T-CLI: empty dir build exits one (#204) ────────────────────────────────── +/// #204 reversal: until v0.4.3 this test was `dir_build_empty_dir_exits_zero` and +/// pinned exit 0 on an empty tree. "Nothing to build" is an error, exactly as the +/// all-excluded case already was — a silent green pass on a mistyped or +/// not-yet-populated directory is the CI failure mode #204 closes. #[test] -fn dir_build_empty_dir_exits_zero() { +fn dir_build_empty_dir_exits_one() { let src = tempfile::tempdir().unwrap(); let output = build_dir(src.path(), &[]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "build on an empty dir must exit 1; stderr: {stderr}" + ); assert!( - output.status.success(), - "build on empty dir should succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) + stderr.contains("no .mds files found in"), + "stderr must carry the empty-tree diagnostic; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was built"), + "stderr must say nothing was built; got: {stderr:?}" ); +} + +/// #204: `--quiet` must not suppress the empty-tree diagnostic — this is the exact +/// CI invocation where a silent green pass is the danger. Mirrors +/// `dir_build_all_excluded_quiet_still_emits_diagnostic`. +#[test] +fn dir_build_empty_dir_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let output = build_dir(src.path(), &["--quiet"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "build --quiet on an empty dir must exit 1; stderr: {stderr}" + ); assert!( - stderr.contains("No .mds files") || stderr.contains("no .mds"), - "stderr should mention no .mds files; got: {stderr}" + !stderr.is_empty(), + "stderr must not be empty under --quiet on an empty tree" + ); + assert!( + stderr.contains("no .mds files found in"), + "empty-tree diagnostic must appear under --quiet; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was built"), + "stderr must say nothing was built under --quiet; got: {stderr:?}" ); } @@ -598,28 +692,56 @@ fn dir_build_all_excluded_quiet_still_emits_diagnostic() { ); } +// #204 reversal: until v0.4.3 this test pinned "genuinely empty dir exits 0 — only +// the all-excluded case exits non-zero". Both now exit 1; what this test still +// guards is that the two cases stay DISTINGUISHABLE by message: an empty tree says +// "no .mds files found in …", never the excluded-directories diagnostic (and vice +// versa — inline positive control below). #[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. +fn dir_build_genuinely_empty_exits_one_without_excluded_diagnostic() { 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_eq!( + output.status.code(), + Some(1), + "genuinely empty dir must exit 1; stderr: {stderr}" + ); assert!( - stderr.contains("No .mds files") || stderr.contains("no .mds"), - "empty-dir message should mention no .mds files; got: {stderr}" + stderr.contains("no .mds files found in"), + "empty-dir message must be the empty-tree diagnostic; got: {stderr:?}" ); assert!( !stderr.contains("excluded"), - "empty-dir must NOT show the excluded diagnostic; got: {stderr}" + "empty-dir must NOT show the excluded diagnostic; got: {stderr:?}" + ); + + // Positive control: the sibling all-excluded scenario exits 1 too, + // so the exit code alone cannot tell them apart — the message must, and the + // control proves "excluded" is really the substring that appears there. + let control = tempfile::tempdir().unwrap(); + let hidden = control.path().join(".github"); + fs::create_dir(&hidden).unwrap(); + fs::write(hidden.join("x.mds"), "Hello!\n").unwrap(); + + let control_out = build_dir(control.path(), &[]); + let control_stderr = String::from_utf8_lossy(&control_out.stderr); + assert_eq!( + control_out.status.code(), + Some(1), + "positive control: the all-excluded case also exits 1; stderr: {control_stderr}" + ); + assert!( + control_stderr.contains("excluded"), + "positive control: the all-excluded case must carry the excluded diagnostic; \ + got: {control_stderr:?}" + ); + assert!( + !control_stderr.contains("no .mds files found in"), + "positive control: the all-excluded case must NOT carry the empty-tree \ + diagnostic; got: {control_stderr:?}" ); } @@ -792,6 +914,83 @@ fn dir_fmt_all_excluded_quiet_still_emits_diagnostic() { ); } +// ── #204: empty directory errors on fmt too ────────────────────────────────── + +/// #204: `mds fmt ` exits 1 — "nothing was formatted" is an error, the +/// same shape as `mds build` / `mds check` and as fmt's own all-excluded arm. +#[test] +fn dir_fmt_empty_dir_exits_one() { + let src = tempfile::tempdir().unwrap(); + + let output = fmt_dir(src.path(), &[]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "fmt on an empty dir must exit 1; stderr: {stderr}" + ); + assert!( + stderr.contains("no .mds files found in"), + "stderr must carry the empty-tree diagnostic; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was formatted"), + "stderr must say nothing was formatted; got: {stderr:?}" + ); +} + +/// #204: `--quiet` must not suppress `mds fmt`'s empty-tree diagnostic. +#[test] +fn dir_fmt_empty_dir_quiet_still_emits_diagnostic() { + let src = tempfile::tempdir().unwrap(); + + let output = fmt_dir(src.path(), &["--quiet"]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "fmt --quiet on an empty dir must exit 1; stderr: {stderr}" + ); + assert!( + !stderr.is_empty(), + "stderr must not be empty under --quiet on an empty tree" + ); + assert!( + stderr.contains("no .mds files found in"), + "empty-tree diagnostic must appear under --quiet; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was formatted"), + "stderr must say nothing was formatted under --quiet; got: {stderr:?}" + ); +} + +/// #204: the empty-tree arm sits BEFORE fmt's read-only split (`check || diff`), +/// so `--check` behaves identically to a plain `fmt` on an empty tree. +#[test] +fn dir_fmt_empty_dir_check_flag_exits_one() { + let src = tempfile::tempdir().unwrap(); + + let output = fmt_dir(src.path(), &["--check"]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "fmt --check on an empty dir must exit 1; stderr: {stderr}" + ); + assert!( + stderr.contains("no .mds files found in"), + "stderr must carry the empty-tree diagnostic under --check; got: {stderr:?}" + ); + assert!( + stderr.contains("nothing was formatted"), + "stderr must say nothing was formatted under --check; got: {stderr:?}" + ); +} + // ── AC-Q01: quiet build on all-success tree produces no stderr ──────────────── // // Positive control: the identical tree without --quiet MUST still print the summary, @@ -1010,3 +1209,173 @@ fn d5_dir_build_quiet_gate_unchanged_with_empty_outputs() { when empty outputs exist (gate unchanged); got: {stderr}" ); } + +// ── Atomic directory-mode outputs (#227) ───────────────────────────────────── +// +// Directory mode has its own writer (it accumulates per-file counters instead of +// returning early), so it is a second site with the same obligation as `write_output`: +// every compiled artifact and every `.map` sidecar goes through +// `crate::output::atomic_write_file`. + +/// Every `.mds-tmp-` prefixed entry anywhere under `dir` (the temp-file prefix used by +/// `atomic_write_file`). Empty means no write left residue. +fn temp_residue_recursive(dir: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + // Bounded: the output tree is finite and acyclic (read_dir does not follow symlinks). + while let Some(d) = stack.pop() { + let Ok(rd) = fs::read_dir(&d) else { continue }; + for entry in rd.flatten() { + let p = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with(".mds-tmp-") { + found.push(p.display().to_string()); + } + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + stack.push(p); + } + } + } + found +} + +/// T-D1: a clean `--out-dir --source-map` run writes every artifact and every sidecar +/// and leaves no temp file anywhere in the output tree. +#[test] +fn dir_build_out_dir_source_map_no_temp_residue() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + for name in ["one.mds", "two.mds", "three.mds"] { + create_plain_mds(src.path(), name); + } + + let output = build_dir( + src.path(), + &["--out-dir", out.path().to_str().unwrap(), "--source-map"], + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "dir build with --source-map must succeed; stderr: {stderr}" + ); + + for stem in ["one", "two", "three"] { + let md = out.path().join(format!("{stem}.md")); + let map = out.path().join(format!("{stem}.md.map")); + assert!(md.is_file(), "{stem}.md must be written"); + assert!(map.is_file(), "{stem}.md.map sidecar must be written"); + } + let residue = temp_residue_recursive(out.path()); + assert!( + residue.is_empty(), + "a successful dir build must leave no temp file; found: {residue:?}" + ); +} + +/// T-D2: when the output directory is not writable, every pre-existing artifact survives +/// intact, the run reports the failures, and nothing is left behind. +#[cfg(unix)] +#[test] +fn dir_build_write_failure_preserves_existing_outputs() { + use std::os::unix::fs::PermissionsExt as _; + + let src = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let out = root.path().join("out"); + fs::create_dir(&out).unwrap(); + + create_plain_mds(src.path(), "a.mds"); + create_plain_mds(src.path(), "b.mds"); + fs::write(out.join("a.md"), "OLD").unwrap(); + fs::write(out.join("b.md"), "OLD").unwrap(); + + fs::set_permissions(&out, fs::Permissions::from_mode(0o555)).unwrap(); + let output = build_dir(src.path(), &["--out-dir", out.to_str().unwrap()]); + // Restore writability BEFORE asserting so a failing assertion cannot leave an + // undeletable tempdir behind. + let _ = fs::set_permissions(&out, fs::Permissions::from_mode(0o755)); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert_ne!( + output.status.code(), + Some(0), + "a dir build into a read-only output dir must fail; stderr: {stderr}" + ); + assert!( + stderr.contains("0 built"), + "the summary must report nothing built; got: {stderr}" + ); + assert!( + stderr.contains("failed"), + "the summary must report the failures; got: {stderr}" + ); + assert!( + stderr.contains("error:"), + "each failure must be reported on stderr; got: {stderr}" + ); + assert!( + stderr.contains("a.md"), + "the per-file error must name the artifact; got: {stderr}" + ); + assert_eq!( + fs::read_to_string(out.join("a.md")).unwrap(), + "OLD", + "a.md must survive the failed write" + ); + assert_eq!( + fs::read_to_string(out.join("b.md")).unwrap(), + "OLD", + "b.md must survive the failed write" + ); + let residue = temp_residue_recursive(&out); + assert!( + residue.is_empty(), + "a failed dir build must leave no temp file; found: {residue:?}" + ); +} + +/// T-D3: the directory-mode `.map` sidecar writer is its own site — a symlinked sidecar +/// path is refused, the artifact beside it is still written, and the run reports one +/// failure. +#[cfg(unix)] +#[test] +fn dir_build_source_map_sidecar_symlink_target_rejected() { + let src = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let out = root.path().join("out"); + fs::create_dir(&out).unwrap(); + + create_plain_mds(src.path(), "page.mds"); + let real = root.path().join("real.map"); + fs::write(&real, "OLD").unwrap(); + std::os::unix::fs::symlink(&real, out.join("page.md.map")).unwrap(); + + let output = build_dir( + src.path(), + &["--out-dir", out.to_str().unwrap(), "--source-map"], + ); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_ne!( + output.status.code(), + Some(0), + "a symlinked sidecar must fail the dir build; stderr: {stderr}" + ); + assert!( + out.join("page.md").is_file(), + "the compiled artifact is written before the sidecar and must survive" + ); + assert!( + stderr.contains("symlink"), + "the refusal must say why; got: {stderr}" + ); + assert!( + stderr.contains("1 failed"), + "the summary must report exactly one failure; got: {stderr}" + ); + assert_eq!( + fs::read_to_string(&real).unwrap(), + "OLD", + "the symlink target must not be written through" + ); +} diff --git a/crates/mds-cli/tests/write_funnel.rs b/crates/mds-cli/tests/write_funnel.rs new file mode 100644 index 00000000..95177217 --- /dev/null +++ b/crates/mds-cli/tests/write_funnel.rs @@ -0,0 +1,452 @@ +//! Write-funnel guard (#227): every artifact `mds` writes from production code must go +//! through the single atomic choke point `crate::output::atomic_write_file`. +//! +//! # Why this exists +//! +//! `atomic_write_file` is temp-file + fsync + rename: a crash or a mid-write error never +//! leaves a truncated artifact, and a symlink at the target is refused. A raw +//! `std::fs::write` at any *one* remaining site silently forfeits all of that for the +//! artifact it writes — and "did we remember every write site?" is an unbounded search +//! that three reviewers can each answer differently. This test converts it into a +//! machine-checked invariant: a raw write in `crates/mds-cli/src/**` is a failure unless +//! it appears in [`ALLOWED_RAW_WRITES`] with a written justification. +//! +//! # Scope and lexical limits (what this guard does NOT see) +//! +//! The scan is lexical. It matches the two needles in [`NEEDLES`] after masking comment +//! and string-literal text and stripping `#[cfg(test)] mod … { … }` blocks (test code +//! legitimately writes fixtures with `std::fs::write`). It therefore does NOT catch: +//! +//! - `OpenOptions::new(…).write(true)` followed by `write_all` on a hand-opened `File`. +//! No such site exists in this crate today. Needling `OpenOptions::new(` was rejected +//! deliberately: it would fire on read-only opens too, and an allow-list full of +//! read-only entries is an allow-list nobody reads. +//! - A write reached through an alias (`use std::fs::write as w;`) or a helper in another +//! crate. +//! - `#[cfg(test)] fn` items outside a `mod tests` block (the crate has none). +//! +//! The scanner helpers below are copied from `crates/mds-core/tests/yaml_funnel.rs`: +//! integration-test binaries are separate crates and cannot share code across crates. + +use std::path::{Path, PathBuf}; + +/// Raw write entry points that must be funnelled. `std::fs::write(` contains +/// `fs::write(`, so the short form matches both the qualified and imported spellings. +const NEEDLES: &[&str] = &["fs::write(", "File::create("]; + +/// Production sites that may keep a raw write: `(file basename, needle, max hits, why)`. +/// +/// `max` is exact, not a ceiling with slack: an entry whose site count drops to zero is +/// reported as dead by [`write_sites_are_funnelled`] and by +/// [`every_allowlist_entry_is_live`], so a removed site cannot leave a stale licence +/// behind for a future raw write to hide under. +const ALLOWED_RAW_WRITES: &[(&str, &str, usize, &str)] = &[ + ( + "main.rs", + "fs::write(", + 1, + "mds init scaffolds a fixed public template at a user-typed path, reproducible by \ + a re-run — NOT because it only ever creates: --force truncates in place and the \ + write follows a symlink at the target (#227)", + ), + ( + "watch.rs", + "fs::write(", + 1, + "test-only readiness marker: written to .tmp then renamed — already atomic", + ), +]; + +#[test] +fn write_sites_are_funnelled() { + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let files = rust_files(&src_dir); + + // Non-vacuity: the source tree was actually walked. + assert!( + files.len() >= 5, + "non-vacuity: expected to walk at least 5 source files under {}, found {}", + src_dir.display(), + files.len() + ); + + let mut violations: Vec = Vec::new(); + // Hits observed per allow-list entry, indexed in step with ALLOWED_RAW_WRITES. + let mut allowed_seen = vec![0usize; ALLOWED_RAW_WRITES.len()]; + let mut durability_pin_checked = false; + + for file in &files { + let name = file + .file_name() + .and_then(|n| n.to_str()) + .expect("source file names are UTF-8"); + let raw = std::fs::read_to_string(file).expect("source must be readable"); + let code = strip_cfg_test_mods(&mask_comments_and_strings(&raw)); + + // Lexical pin for the durability tail of the primitive itself: the funnel is + // only worth enforcing while what sits at the end of it still fsyncs and renames. + if name == "output.rs" { + assert!( + code.contains(".sync_all()"), + "output.rs must still call .sync_all() before the rename — the funnel \ + guard is pointless if the choke point stops being durable" + ); + assert!( + code.contains(".persist("), + "output.rs must still finish the write with a .persist() rename" + ); + durability_pin_checked = true; + } + + for needle in NEEDLES { + let hits = needle_lines(&code, needle); + if hits.is_empty() { + continue; + } + let allowed = match ALLOWED_RAW_WRITES + .iter() + .position(|(f, n, _, _)| *f == name && n == needle) + { + Some(idx) => { + allowed_seen[idx] += hits.len(); + ALLOWED_RAW_WRITES[idx].2 + } + None => 0, + }; + for line in hits.iter().skip(allowed) { + violations.push(format!( + " {name}:{line}: raw `{needle}` ({allowed} allow-listed for this file)" + )); + } + } + } + + assert!( + durability_pin_checked, + "non-vacuity: output.rs was never scanned — the walk did not reach the choke point" + ); + + // Anti-rot: an allow-list entry whose site is gone would licence a future raw write. + let dead: Vec = ALLOWED_RAW_WRITES + .iter() + .zip(&allowed_seen) + .filter(|((_, _, max, _), seen)| *seen != max) + .map(|((f, n, max, _), seen)| format!(" {f}: `{n}` expected {max} hit(s), found {seen}")) + .collect(); + assert!( + dead.is_empty(), + "allow-list entry is dead or drifted ({} entr(ies)). Update ALLOWED_RAW_WRITES \ + to match reality — a stale entry is a licence for a raw write nobody reviewed.\n{}", + dead.len(), + dead.join("\n") + ); + + assert!( + violations.is_empty(), + "raw write sites outside the atomic funnel ({} found).\n{}\n\n\ + Route the write through `crate::output::atomic_write_file` (and create the parent \ + directory first — the primitive deliberately does not). If a site genuinely must \ + stay raw, add it to ALLOWED_RAW_WRITES with a written justification.", + violations.len(), + violations.join("\n") + ); +} + +/// Positive self-check (the guard must be observed rejecting something before "no +/// violations" is evidence of anything). Operates on synthetic source strings only — no +/// filesystem access, so it cannot be satisfied by the repo happening to be clean. +#[test] +fn the_guard_flags_a_planted_raw_write() { + // A raw write in ordinary production code is a violation. + assert_eq!( + scan_violation_count("fn f(p: &Path, s: &str) { std::fs::write(&p, s).unwrap(); }"), + 1, + "a planted std::fs::write in a plain fn must be flagged" + ); + + // The same call inside a #[cfg(test)] mod is not. + assert_eq!( + scan_violation_count( + "fn f() {}\n#[cfg(test)]\nmod tests {\n fn t(p: &Path) { std::fs::write(&p, \"x\").unwrap(); }\n}\n" + ), + 0, + "a write inside #[cfg(test)] mod tests must not be flagged" + ); + + // Inside a line comment it is not. + assert_eq!( + scan_violation_count("fn f() {\n // std::fs::write(&p, s);\n}\n"), + 0, + "a needle inside a // comment must not be flagged" + ); + + // Inside a string literal it is not. + assert_eq!( + scan_violation_count("fn f() -> &'static str { \"std::fs::write(&p, s)\" }"), + 0, + "a needle inside a string literal must not be flagged" + ); + + // The second needle is live too. + assert_eq!( + scan_violation_count("fn f(p: &Path) { let _ = std::fs::File::create(p); }"), + 1, + "a planted File::create in a plain fn must be flagged" + ); +} + +/// Anti-rot companion: every allow-list entry names a file that exists and still contains +/// at least one masked hit of its needle. +#[test] +fn every_allowlist_entry_is_live() { + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + for (file, needle, max, why) in ALLOWED_RAW_WRITES { + let path = src_dir.join(file); + assert!( + path.is_file(), + "allow-list names {file}, which does not exist under {} (justification: {why})", + src_dir.display() + ); + let raw = std::fs::read_to_string(&path).expect("source must be readable"); + let code = strip_cfg_test_mods(&mask_comments_and_strings(&raw)); + let hits = count_occurrences(&code, needle); + assert_eq!( + hits, *max, + "allow-list expects {max} raw `{needle}` in {file}, found {hits} \ + (justification: {why})" + ); + } +} + +// ── Scanner ─────────────────────────────────────────────────────────────────── + +/// Total needle hits in `src` after masking and `#[cfg(test)]` stripping. +fn scan_violation_count(src: &str) -> usize { + let code = strip_cfg_test_mods(&mask_comments_and_strings(src)); + NEEDLES.iter().map(|n| count_occurrences(&code, n)).sum() +} + +/// 1-based line numbers of every non-overlapping `needle` occurrence in `code`. +/// +/// `mask_comments_and_strings` preserves both byte offsets and newlines, so a line +/// number computed over the masked text is the line number in the original source. +fn needle_lines(code: &str, needle: &str) -> Vec { + let mut lines = Vec::new(); + let mut start = 0usize; + // Bounded: `start` advances by at least `needle.len()` each iteration. + while let Some(pos) = code[start..].find(needle) { + let abs = start + pos; + lines.push(code[..abs].matches('\n').count() + 1); + start = abs + needle.len(); + } + lines +} + +/// Count non-overlapping occurrences of `needle` in `haystack`. +fn count_occurrences(haystack: &str, needle: &str) -> usize { + let mut count = 0; + let mut start = 0; + // Bounded: `start` advances by at least `needle.len()` each iteration. + while let Some(pos) = haystack[start..].find(needle) { + count += 1; + start += pos + needle.len(); + } + count +} + +fn rust_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + // Bounded: the source tree is finite and acyclic (read_dir does not traverse symlinks). + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for entry in rd.flatten() { + let p = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + stack.push(p); + } else if ft.is_file() && p.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(p); + } + } + } + out.sort(); + out +} + +/// Replace the CONTENT of line comments, block comments, string literals and char +/// literals with spaces, preserving overall length and newlines. This keeps needles that +/// appear in rustdoc or inside a string from counting, and makes the brace matching in +/// [`strip_cfg_test_mods`] safe (no braces hide inside strings/comments). +fn mask_comments_and_strings(src: &str) -> String { + let b = src.as_bytes(); + let mut out = vec![b' '; b.len()]; + // Copy newlines through so line structure is preserved. + for (i, &c) in b.iter().enumerate() { + if c == b'\n' { + out[i] = b'\n'; + } + } + let mut i = 0usize; + while i < b.len() { + // Line comment. + if b[i] == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + continue; + } + // Block comment (not nested — Rust allows nesting, but the codebase does not rely + // on it here; a needle would have to sit inside a nested comment to escape). + if b[i] == b'/' && b.get(i + 1) == Some(&b'*') { + i += 2; + while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(b.len()); + continue; + } + // Raw string: r"...", r#"..."#, r##"..."##, ... — only when `r` starts a token. + if b[i] == b'r' + && matches!(b.get(i + 1), Some(&b'"') | Some(&b'#')) + && !prev_is_ident_byte(b, i) + { + let mut hashes = 0usize; + let mut j = i + 1; + while b.get(j) == Some(&b'#') { + hashes += 1; + j += 1; + } + if b.get(j) == Some(&b'"') { + j += 1; + // Scan to a `"` followed by exactly `hashes` `#`. + while j < b.len() { + if b[j] == b'"' && (0..hashes).all(|k| b.get(j + 1 + k) == Some(&b'#')) { + j += 1 + hashes; + break; + } + j += 1; + } + i = j.min(b.len()); + continue; + } + } + // Normal string literal. + if b[i] == b'"' { + let mut j = i + 1; + while j < b.len() { + if b[j] == b'\\' { + j += 2; + continue; + } + if b[j] == b'"' { + j += 1; + break; + } + j += 1; + } + i = j.min(b.len()); + continue; + } + // Char literal `'x'` / `'\n'` — but not a lifetime `'a`. Only treat as a literal + // when a closing quote follows within a few bytes. + if b[i] == b'\'' { + let mut j = i + 1; + let mut closed = false; + let mut steps = 0; + while j < b.len() && steps < 8 { + if b[j] == b'\\' { + j += 2; + steps += 1; + continue; + } + if b[j] == b'\'' { + closed = true; + j += 1; + break; + } + j += 1; + steps += 1; + } + if closed { + i = j.min(b.len()); + continue; + } + } + // Not a masked region: copy the byte through verbatim. + out[i] = b[i]; + i += 1; + } + String::from_utf8(out).expect("masking preserves UTF-8 boundaries on ASCII delimiters") +} + +/// Remove every `#[cfg(test)]`-guarded item from already-masked source. Handles both an +/// inline `mod name { ... }` (brace-matched) and a `mod name;` / `#[path=...] mod name;` +/// declaration. Individual `#[cfg(test)] fn ...` items are left in place; this crate +/// places all test code inside `mod tests`. +fn strip_cfg_test_mods(code: &str) -> String { + let mut result = code.to_string(); + // Bounded: at most one removal per `#[cfg(test)]` occurrence, and each iteration + // either removes a block or blanks the attribute, so no occurrence is seen twice. + while let Some(attr) = result.find("#[cfg(test)]") { + // Find the next `mod` keyword after the attribute. + let after = attr + "#[cfg(test)]".len(); + let Some(mod_rel) = result[after..].find("mod ") else { + // No module follows (e.g. a cfg(test) fn) — blank the attribute and move on. + result.replace_range(attr..after, &" ".repeat(after - attr)); + continue; + }; + let mod_start = after + mod_rel; + // Look for the block-open `{` or the statement-terminating `;`. + let brace = result[mod_start..].find('{'); + let semi = result[mod_start..].find(';'); + match (brace, semi) { + (Some(bo), semi_opt) if semi_opt.is_none_or(|s| bo < s) => { + let open = mod_start + bo; + if let Some(close) = match_brace(&result, open) { + result.replace_range(attr..=close, ""); + } else { + result.replace_range(attr..open, ""); + } + } + (_, Some(so)) => { + // `mod name;` declaration — remove the attribute + statement. + let end = mod_start + so + 1; + result.replace_range(attr..end, ""); + } + _ => { + result.replace_range(attr..after, &" ".repeat(after - attr)); + } + } + } + result +} + +/// Is the byte before `i` part of an identifier (so `r` is a suffix, not a raw-string +/// prefix, e.g. `str` / `for`)? +fn prev_is_ident_byte(b: &[u8], i: usize) -> bool { + i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_') +} + +/// Index of the `}` matching the `{` at `open` in already-masked text. +fn match_brace(text: &str, open: usize) -> Option { + let b = text.as_bytes(); + let mut depth = 0i32; + let mut i = open; + while i < b.len() { + match b[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} diff --git a/spec.md b/spec.md index 618ea15c..1cca27ab 100644 --- a/spec.md +++ b/spec.md @@ -896,7 +896,7 @@ mds build src/ --out-dir dist # Mirror subtree: src/a/b.mds → dis - With `--out-dir `, mirrors the source subtree under `/`; without it, writes next to source. - `-o` is rejected for a directory input. - Continue-on-error: all compilable files are attempted; a summary (`N built, N failed`) is printed when any file fails or when `--quiet` is not passed; non-zero exit when any failed. Under `--quiet`, the summary is suppressed on a fully-successful run and emitted when any file fails, so the non-zero exit is never unexplained. -- When the directory contains no `.mds` files, exits 0 with a "no files found" message. +- When the directory contains no `.mds` files at all, exits 1 with `no .mds files found in ; nothing was built` on stderr — emitted even under `--quiet`, like the all-excluded diagnostic — so an empty tree cannot pass a CI gate silently. (Changed in v0.4.3; previously exited 0.) `mds watch ` is unaffected: it starts on an empty tree and compiles files created later. - **Stale-flip cleanup**: when a file's kind changes (e.g., markdown → messages), the old-extension sibling (`.md` or `.json`) is removed automatically. - stdin (`mds build -`) with `--out-dir`: the fallback output name is `output.md` (markdown) or `output.json` (messages). @@ -926,6 +926,8 @@ mds build src/ --out-dir dist # Mirror subtree: src/a/b.mds → dis In all paths, `` is `md` for Markdown templates and `json` for messages templates. +**Output writing.** Compiled outputs and `.map` sidecars written by `mds build` and `mds watch`, and `.mds` sources rewritten by `mds fmt` and `mds lint --fix`, are written to a temporary file in the target's directory and then renamed over the target, after a final symlink re-check of the target: a crash, kill or full disk never leaves a truncated file behind, and a destination path that is a symlink is refused. Because the output is created as a sibling temporary file and renamed into place, the destination must be a regular-file path inside a writable directory: device files such as `/dev/null` and FIFOs are not supported as `-o` targets (write to stdout instead). Source rewrites (`fmt`, `lint --fix`) are additionally fsynced before the rename; compiled outputs and sidecars rely on the rename alone — they are regenerable, and an unconditional fsync made directory-mode startup several times slower on macOS. Because the rename gives the target a new inode, a pre-existing target's hard links (other links keep the old content), ACLs, extended attributes, and owner/group are not preserved; permission bits are preserved on Unix. This is enforced for the CLI's write sites by `crates/mds-cli/tests/write_funnel.rs`. + ### 7.3 `mds check` ```bash @@ -936,7 +938,7 @@ echo "@if flag:" | mds check - # Validate from stdin mds check src/ # Validate every non-partial .mds in the tree ``` -Exits 0 if all templates are valid, non-zero on any error. Same `--vars`/`--set`/`--set-string`/`--quiet` options as `mds build`. Directory mode follows the same semantics as `mds build ` (partial skipping, symlink rejection, continue-on-error) but does not write any output files. In directory mode the summary line is `N passed, N failed`, emitted under the same `--quiet` rule as `mds build ` (§7.2): suppressed on a fully-successful run, emitted when any file fails. +Exits 0 if all templates are valid, non-zero on any error. Same `--vars`/`--set`/`--set-string`/`--quiet` options as `mds build`. Directory mode follows the same semantics as `mds build ` (partial skipping, symlink rejection, continue-on-error, and the two nothing-to-process exits — empty tree and all-excluded — which exit 1 with `…; nothing was checked`) but does not write any output files. In directory mode the summary line is `N passed, N failed`, emitted under the same `--quiet` rule as `mds build ` (§7.2): suppressed on a fully-successful run, emitted when any file fails. ### 7.4 `mds fmt` @@ -956,7 +958,7 @@ Every rewrite is **safety-gated**: the formatter re-compiles both the original a |--------|-------------| | `--check` | Exit non-zero without writing if any file would change. | | `--diff` | Print a unified diff of proposed changes without writing. | -| `-q, --quiet` | Suppress per-file status messages and the directory summary on a successful run. The summary is still emitted when any file fails to format. Exception: under `--check`, a run where files would reformat but none failed exits 1 with no summary — the would-reformat count is treated as status output and is suppressed by `--quiet` (mirrors the same rule for `mds lint --fix --check`). (Two notices bypass `--quiet`: the directory-depth warning and the all-files-excluded diagnostic.) | +| `-q, --quiet` | Suppress per-file status messages and the directory summary on a successful run. The summary is still emitted when any file fails to format. Exception: under `--check`, a run where files would reformat but none failed exits 1 with no summary — the would-reformat count is treated as status output and is suppressed by `--quiet` (mirrors the same rule for `mds lint --fix --check`). (Three notices bypass `--quiet`: the directory-depth warning, the all-files-excluded diagnostic, and the empty-tree diagnostic `no .mds files found in ; nothing was formatted` — both diagnostics exit 1.) | ### 7.5 `mds lint` @@ -1015,6 +1017,11 @@ cat template.mds | mds lint --fix - # Fix from stdin, write fixed source t stderr bytes when pending fixes exist but no file is in the error or resource-limited bucket — the `--fix --check` pending-fix signal is treated as status output and is suppressed by `--quiet` alongside the summary line. +- A directory with no `.mds` files at all exits 2 (the usage-error code) with + `no .mds files found in ; nothing was linted` on stderr; a directory whose + every `.mds` file lies under a default-excluded directory exits 2 with a + diagnostic carrying the skip count. Both bypass `--quiet`, print no summary + line, and write nothing to stdout even under `--format json`. - The JSON stdout envelope (`{"files":…,"truncated":…,"version":1}`) is unchanged regardless of `--quiet` or directory mode — no `"summary"` key is added. @@ -1310,7 +1317,7 @@ Maximum config file size: 1 MB. | Code | Meaning | |------|---------| | `0` | Success | -| `1` | Template error (syntax, undefined variable, arity mismatch, recursion, etc.) | +| `1` | Template error (syntax, undefined variable, arity mismatch, recursion, etc.); in directory mode, also "nothing to process" (no `.mds` files, or all under default-excluded directories) | | `2` | I/O or file-system error (file not found, not an MDS file, I/O failure) | | `3` | Resource limit exceeded (output too large, too many iterations, message count exceeds `MAX_MESSAGE_COUNT` (10,000), cumulative message content exceeds 50 MB, or frontmatter over 1 MiB, over 200,000 YAML nodes, or flow-nesting deeper than 1024 levels) | @@ -1320,7 +1327,7 @@ Maximum config file size: 1 MB. |------|---------| | `0` | Clean — no warning- or error-severity findings | | `1` | Warning-severity findings only (no errors) | -| `2` | Error-severity finding, analysis failure, or usage error | +| `2` | Error-severity finding, analysis failure, or usage error (including a directory with nothing to lint) | | `3` | Resource limit exceeded | ---