Skip to content

fix(cli): atomic mds build/watch outputs and .map sidecars via atomic_write_file (removes the spurious metadata warning); empty directory exits non-zero; atomic_write_file contract documented (#227, #225, #204, #226) - #385

Merged
dean0x merged 8 commits into
mainfrom
fix/c4-atomic-outputs-empty-dir
Sep 14, 2026

Conversation

@dean0x

@dean0x dean0x commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

Three changes to mds-cli, all at the filesystem boundary:

  1. Atomic build outputs (build::write_output still uses truncate-then-write (non-atomic) for compiled artifacts #227, atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225). Every mds build / mds watch compiled artifact and .map sidecar now goes through crate::output::atomic_write_file (temp file in the target directory → rename), the same primitive mds fmt / mds lint --fix already used. The primitive itself was built for existing files only; it now creates missing targets, refuses symlink targets (live or dangling), and takes the Unix mode from a single lstat — which removes the spurious cannot get metadata for {path} warning that fired on every first build (atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225/fix: atomic_write_file: warn on metadata failure instead of silently using 0600 #240). A new crates/mds-cli/tests/write_funnel.rs guard makes "every write is funnelled" a machine-checked invariant.
  2. Empty directory exits non-zero (build: empty directory build exits 0 — silent success hazard in CI #204). mds build|check|fmt|lint <dir> on a tree with zero .mds files exited 0, and printed nothing at all under --quiet — a silent CI green pass on a mistyped or not-yet-populated directory. It now exits 1/1/1/2 with a diagnostic that bypasses --quiet, matching the sibling all-excluded arm. mds watch <dir> is unchanged.
  3. The replace-by-rename contract is documented (atomic_write_file: rename-based write does not preserve platform ACLs, xattrs, or owner/group #226). atomic_write_file gives the target a new inode, so it preserves permission bits but not hard links, ACLs, xattrs or owner/group. Stated in the rustdoc, spec §7.2, SECURITY.md and RELEASING.md.

security/snyk (dean0x) is SCA-only for this PR — the Snyk MCP server failed to connect locally (ENOENT on snyk-macos-arm64), so no snyk_code_scan ran on crates/mds-cli/src/output.rs; Rust is not scanned by Snyk Code here in any case.

What was wrong

  • Truncate-then-write. mds build and mds watch wrote artifacts with std::fs::write, which is O_TRUNC + write. A failed or interrupted build left a truncated artifact where a valid one had been, and a symlink at the output path was written through.
  • The primitive could not be used as-is. atomic_write_file had four first-build blockers: it opened with NativeFs::check_symlink(path), which hard-fails file not found on a nonexistent target; it does not create_dir_all; tempfile::Builder's default mode is 0600, so every fresh artifact would have been owner-only; and its second std::fs::metadata call printed cannot get metadata for {path}: {e} on any Err, including NotFound — i.e. on every first build — then silently continued with no mode (fix: atomic_write_file: warn on metadata failure instead of silently using 0600 #240, atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225).
  • A parallel path. The directory-mode compiled writer in build.rs was a separate raw writer, so even rerouting write_output would have left it bypassing the funnel. Three .map sidecar writes (stdin, single-file, directory mode) were raw as well: five raw sites in total.
  • Empty directory = exit 0, silently. Under --quiet nothing was printed either, while the adjacent "all candidates under default-excluded directories" case already errored and already bypassed --quiet.
  • atomic_write_file: rename-based write does not preserve platform ACLs, xattrs, or owner/group #226 was a doc-vs-implement question — whether to preserve hard links/xattrs/owner or document that we do not. Documented; see Design.

Design

  • Existence is decided by one lstat (Path::symlink_metadata), not exists() or metadata(). Both of the latter follow symlinks, so a dangling link reads as "absent" and the create path would then follow it on rename; lstat never follows and yields the mode in the same call, so no second stat and no warning site remains.

  • No parent-directory symlink check. NativeFs::check_symlink resolves directory-level links by documented rule, and macOS /tmp/private/tmp would break under a strict check. Only the final component is refused.

  • Builder::permissions(0o666) on the new-file branch only, so the kernel applies the umask and a fresh artifact matches what std::fs::write produced (0644 under 022). The existing-file branch keeps taking mode & 0o7777 from the lstat result.

  • create_dir_all stays out of the primitive — callers own directory creation; the build.rs call sites already had it.

  • D6 — the durability gate was BREACHED, so contingency C-1 was applied. sync_all() is F_FULLFSYNC on macOS (~7 ms/file). sync_all was to stay unconditional behind a measured +20 % budget; it was measured (medians of 3, same session, back to back, only build.rs differing):

    measurement raw writes atomic + unconditional fsync atomic + C-1
    watch_dir_mode_idle_500_files_no_recompile (solo) 1.436 s 4.694 s (+227 %) 1.554 s (+8 %)
    cli_watch suite (wall) 4.527 s 8.667 s (+91 %) 4.659 s (+3 %)
    dir_build suite 0.652 s 0.725 s 0.483 s

    The cli_watch suite grew +91 % wall against the +20 % budget, and inside the parallel suite the 500-file test reached 8.418 s against the harness's 10 s READY_TIMEOUT — one slower runner from a hard failure. C-1: pub(crate) enum Durability { Fsync, RenameOnly } is a third parameter to atomic_write_file. fmt.rs (2 sites) and lint.rs (6 sites) pass Fsync — they rewrite the user's hand-authored .mds source in place, so bytes lost to a power failure are gone for good. The six build/watch sites pass RenameOnly — compiled artifacts are derived and a rebuild reproduces them. Atomicity is unconditional either way: it comes from the rename, not the fsync.

  • Empty-tree exits mirror the all-excluded arms exactly: build/check/fmt exit 1, lint exits 2 (its usage-error code, matching lint's own all-excluded arm). Each is an inline eprintln! + process::exit directly beside the arm it mirrors.

  • No helper, no MdsError variant, no exit_code arm, no --allow-empty flag. Four independent sites with four different message tails; a shared print helper is exactly the kind of indirection that rots print discipline. "Nothing to do" is not an error class the error enum should grow, and with zero users an opt-out flag can be added on demand rather than shipped speculatively.

  • crates/mds-cli/tests/write_funnel.rs is the single enforcement point. It counts fs::write( / File::create( across crates/mds-cli/src/** after masking comments and string literals and stripping #[cfg(test)] mod blocks, against an exact-max allow list with exactly two entries: mds init (creates only, after an explicit exists-check) and the test-only watch readiness marker (already temp + rename). A dead entry fails the scan too.

Behaviour changes

  • (a) A symlink at the output path — live or dangling — is now REFUSED (cannot write {path}: refusing to replace a symlink); main wrote through it. T-B4, T-S1, T-S2, T-D3, T-U4/T-U5.
  • (b) A writable output inside a read-only directory now FAILS. std::fs::write succeeded there (it only needs write permission on the file); the temp-file create does not. T-B5, T-D2, T-U6.
  • (c) A read-only (0444) existing output is now REPLACED, mode kept — main's std::fs::write refused. T-B3 / T-U3 territory.
  • (d) BREAKING: mds build|check|fmt|lint <dir> with no .mds files exits 1/1/1/2 with no .mds files found in <dir>; nothing was built|checked|formatted|linted, emitted even under --quiet. E-1a..E-4c. mds watch <dir> is unchanged and still arms on an empty tree — W-E1.
  • (e) The spurious cannot get metadata for {path} message is gone, and a non-NotFound stat error is now a hard error (cannot stat {path}: {e}) rather than a printed notice plus a silent guess (atomic_write_file: metadata() failure silently yields mode 0600 via .ok() #225). T-B1, T-U8.
  • (f) First-build outputs keep the umask default mode (0644 under 022), not tempfile's 0600. T-U2, T-B2.
  • (g) mds build --help now renders `<output-file>` in backticks — a side effect of fixing a pre-existing rustdoc error (unclosed HTML tag) in the clap doc comment. No test pinned that line.
  • (h) An output path that is not a regular file in a writable directory (-o /dev/null, /dev/stdout) is no longer accepted — exit 1, cannot create temp file for /dev/null: Operation not permitted; write to stdout (omit -o) or use mds check instead (documented in CHANGELOG + spec §7.2).
  • (i) A FIFO or other special file at the output path is replaced by a regular file rather than opened (previously the open blocked).

Write-failure messages also changed shape: cannot create temp file for {path}: {io error} at path "{tmp}" where it was cannot write {path}: {io error}, and directory mode no longer re-prefixes the path (the primitive's message already names it).

Evidence

commit scope
ee8c3f0 primitive: create / lstat / symlink refusal / mode, # Contract rustdoc
d44cc47 five reroutes + write_funnel.rs + Durability split (C-1)
f26b820 RED tests for #204
588a8e3 #204 fix
92322e6 docs (spec, README, CHANGELOG, SECURITY, RELEASING, KBs) + 3 rustdoc fixes
98614f0 drop decision-ledger ids from added test comments; record the exit-0 reversal in cli_commands.rs
ad026f2 correct the mds init raw-write justification — --force truncates in place and both exists()/write follow a symlink at the target
683bbb6 docs: non-regular-file output targets (/dev/null, FIFOs) are not accepted by the atomic writer

The last three are review-pass commits: alignment → polish, self-review → justification fix, and a documented consequence surfaced by review.

Per-commit RED → GREEN. Commit 1: 8 unit tests written first and run against the unmodified primitive — 4 passed / 4 RED (T-U1 create, T-U2 fresh-file mode, T-U5 dangling symlink, T-U8 unreadable parent), all four failing with file not found from the existence probe; T-U3/U4/U6/U7 pass before and after. Commit 2: 121 tests run, 114 passed / 7 RED — T-F1 (raw write sites outside the atomic funnel (5 found), all in build.rs), T-B4, T-B5, T-S1, T-S2, T-D2, T-D3; T-B1/B2/B3/B6/B7 and T-D1 are pins that must stay green (T-B1 is the sentinel against a naive reroute re-adding the #225 warning). Commit 3: 11 test fns RED, every failure on the exit-code assertion, with E-5 (dir_check_missing_root_exits_two) and W-E1 (watch_dir_mode_empty_root_starts_and_picks_up_new_file) green pins. Commit 4: GREEN, 875/875. Every rejection test carries a positive control in the same test; every chmod is restored before the assertions run; no #[ignore], no checked-in fixtures.

Mutation testing — atomic-write path (M1–M14):

id mutation result
M1 write_output back to raw caught — F1, B4, B5
M2 single-file .map back to raw caught — F1, S1
M3 dir-mode writer back to raw caught — F1, D2
M4 drop permissions(0o666) caught — U2, B2
M5 drop the set_permissions restore caught — U3, B3, fmt_single_file_preserves_mode_0644, lint_fix_preserves_mode_0644
M6 metadata() probe instead of lstat caught — U5
M7 drop the is_symlink refusal caught — U5
M8 drop create_dir_all caught — B1 (build_out_dir_creates_directory unaffected: --out-dir path)
M9 NotFoundErr caught — U1, B1, +3 -o tests
M10 all lstat ErrNone caught — U8
M11 delete sync_all F1 lexical pin only — WEAK, unobservable without power loss
M12 reinstate the cannot get metadata print caught — B1
M13a / M13b bogus allow-list entry / remove the main.rs entry caught — F3 + F1 / F1
M14 permissions(0o666) unconditional NOT CAUGHT — equivalent mutant: the existing-file branch re-applies mode & 0o7777 afterwards, so there is no observable effect

Mutation testing — empty-directory exits (N1–N9): all caught. Four independent process::exit sites, so a mutation at one site is caught by exactly that command's E-test trio (plain / --quiet / variant); exit-code-only and message-only mutations are caught separately by the assert_eq! on status.code() and the contains needles.

Flake screen. cli_watch ×3: 79/79 each time, no recurrence of the LEAK marker seen while the unconditional-fsync variant was in the tree; the 500-file idle test at 0.955 / 0.967 / 0.971 s.

Gates. cargo nextest run -p mds-cli 875/875 (baseline 843); mds-core 1445; cargo test --workspace green; doctests 53; cargo +1.88 check ok; RUSTDOCFLAGS="-D warnings" cargo doc --no-deps clean for both mds-core and mds-cli (the 3 pre-existing mds-cli rustdoc errors are fixed here); cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings (plus the startup-race-probe feature) exit 0; verify-no-control-bytes.mjs and verify-versions.mjs pass; npm run test:gates 212 / 0 fail; print_discipline 11/11 with ZERO registry edits (safe_inline and safe_path are already registered sanitizers); release-surface path diff empty.

Review passes.

  • Evaluate agent → ALIGNED (12/12 design items, 37/37 tests, no forbidden simplification).
  • Scrutinize (9-pillar) → one P1 fixed: the mds init allow-list justification claimed "only ever creates a file" but --force truncates in place and exists()/write follow a symlink at the target — text corrected in main.rs + write_funnel.rs, behaviour unchanged.
  • Scenario QA (S1–S15) → PASS — 15/15 scenarios (S1–S15) against the built binary at ad026f2: first-build mode/stderr, symlink refusal (live + dangling, output and sidecar), read-only-parent preservation, dir build with source maps, /dev/null consequence, empty-dir exits 1/1/1/2 incl. --quiet and --format json, excluded-only diagnostic, missing root exit 2, watch on empty root, fmt mode/symlink regression, zero .mds-tmp-* residue

Known limitations / out of scope

  • O_NOFOLLOW on the write fd is still deferred. The refusal is an lstat check plus the existing check_symlink TOCTOU re-check, not an open-time guarantee.
  • Write failures still exit 1, not the I/O class 2: the primitive returns a bare miette::miette! report which does not downcast to MdsError. Arguably wrong, but unchanged by this PR and left for a follow-up.
  • .mds-tmp-*.tmp residue can survive a SIGKILL between create and rename. Walker and watch filters ignore .tmp, so it is inert; this is the same exposure mds fmt / mds lint --fix already have.
  • An unreadable root still reports "no .mds files found" — the walker swallows the read_dir error, which is pre-existing behaviour and not touched here.
  • --out-dir pointing at a symlinked directory stays allowed (directory-level links are resolved by rule) — spec §4.6 Imports "Rules:" omits symlink rejection — spec/implementation drift on a security control #327.
  • No JSON envelope for "nothing to lint" — emitting one would be a §7.5 wire change.
  • No --allow-empty escape hatch; add on demand.
  • load_config still runs before the walk, so a malformed config still errors before the empty-tree diagnostic.
  • mds init follows a symlink at the target path and --force truncates in place (pre-existing; deliberately left raw and allow-listed; follow-up issue to be filed post-merge).
  • A directory whose only .mds files are partials (_foo.mds) still exits 0 with nothing built — the walker collects partials so the empty-tree arm is not reached; same "silent green" shape as build: empty directory build exits 0 — silent success hazard in CI #204, follow-up issue post-merge.
  • The symlink refusal and other primitive failures exit 1 (bare miette report, not an MdsError), whereas spec §7.9 maps I/O errors to 2 — unchanged from main's write-failure code; needs an MdsError variant, out of this PR.
  • Windows: replace-by-rename (MoveFileExW with replace) can fail with a sharing violation when another process holds the output open, where the old truncating write succeeded — pre-existing for fmt/lint, now also reaches build/watch; not testable in this CI matrix.
  • write_funnel.rs keys its allow-list on file basename; crates/mds-cli/src is flat today so this is latent.
  • The mode restore uses path-based set_permissions with & 0o7777; fchmod via the open handle and & 0o777 would be strictly tighter (pre-existing, untouched).
  • spec §7.2 says a crash/kill/full disk never leaves a truncated file; for RenameOnly artifacts a power loss can still leave an unflushed new file (the rustdoc says so) — the rename guarantees atomicity, fsync guarantees durability, and only fmt/lint get the latter.

Changes

  • Primitivecrates/mds-cli/src/output.rs: lstat-based existence, symlink refusal, new-file mode, Durability enum, # Contract (#226) rustdoc, 9 unit tests.
  • Reroutescrates/mds-cli/src/build.rs: write_output, the directory-mode compiled writer and all three .map sidecar writers now call atomic_write_file(..., Durability::RenameOnly); the four empty-tree exits live in build.rs / main.rs / fmt.rs / lint.rs. crates/mds-cli/src/{fmt,lint}.rs pass Durability::Fsync at their 8 existing sites.
  • Allow-list commentscrates/mds-cli/src/main.rs (mds init) and crates/mds-cli/src/watch.rs (readiness marker).
  • New guardcrates/mds-cli/tests/write_funnel.rs (scan, planted-write self-check, anti-rot).
  • Testscrates/mds-cli/tests/{cli_build,cli_source_map,dir_build,cli_commands,cli_lint,cli_watch}.rs.
  • Docsspec.md §7.2 (output writing + dir-mode exit), §7.3, §7.4, §7.5, §7.9; README.md ×4 (dir mode, watch, fmt, lint); CHANGELOG.md (Changed — BREAKING (CLI); Fixed; Internal); SECURITY.md (replace-by-rename bullet); RELEASING.md (ops note); .devflow/features/mds-{fmt,lint}/KNOWLEDGE.md.
  • Rustdoc fixes — two unresolved intra-doc links in output.rs and the unclosed <output-file> tag in main.rs, so the mds-cli rustdoc gate is clean for the first time.

Related Issues

Closes #227
Closes #204
Refs #226 (closed post-merge with a disposition comment)
Refs #225 #240 #239 #196

…targets, takes the mode from lstat — removes the spurious metadata warning (#227, #225)

`atomic_write_file` was built for EXISTING files: it opened with
`NativeFs::check_symlink(path)`, which hard-fails `file not found` on an
absent target, and then took the Unix mode from a second `std::fs::metadata`
call whose every `Err` printed `cannot get metadata for {path}: {e}` to
stderr and silently continued with no mode (PR #240). That warning fired on
every first `mds build` and a stat failure was never a decision, only a
notice (#225).

Existence is now decided by one `lstat` (`Path::symlink_metadata`, which never
follows):
- `Ok` + symlink -> refused, live or dangling: `cannot write {path}:
  refusing to replace a symlink`.
- `Ok` + regular  -> existing file: the `NativeFs::check_symlink` TOCTOU
  re-check runs exactly as before, and the mode comes from the lstat result
  already in hand — no second stat, so the warning has no site left.
- `Err(NotFound)` -> new file: the final-component check is skipped and
  `tempfile::Builder::permissions(0o666)` is requested so the kernel applies
  the umask, matching what `std::fs::write` produced. `tempfile`'s default is
  0600, which would have made every fresh artifact owner-only.
- any other `Err` -> hard error `cannot stat {path}: {e}`, never a guess.

`create_dir_all` stays out of the primitive (callers own it) and no
parent-directory symlink check is added — macOS `/tmp` -> `/private/tmp`
would break under one. Existing-file behaviour is byte-identical.

RED (8 new unit tests written first, run against the unmodified primitive —
`cargo nextest run -p mds-cli --bin mds atomic_write_file`: 4 passed,
4 failed):
- atomic_write_file_creates_missing_target — FAIL: `cannot write …/fresh.md:
  file not found: …/fresh.md`
- atomic_write_file_new_file_mode_matches_std_fs_write — FAIL: same
  `file not found` (never reached the 0600-vs-0644 mode assertion)
- atomic_write_file_refuses_dangling_symlink_target — FAIL: got
  `file not found` instead of a symlink refusal
- atomic_write_file_unreadable_parent_is_hard_error — FAIL: got
  `file not found` instead of `cannot stat`
- atomic_write_file_existing_mode_0640_preserved — PASS (must stay passing)
- atomic_write_file_refuses_live_symlink_target — PASS (must stay passing)
- atomic_write_file_failure_preserves_original_and_leaves_no_temp — PASS
- atomic_write_file_directory_target_refused_without_residue — PASS

Every rejection test carries a positive control in the same test (the live
symlink test writes the real file, the failure test re-runs once the
directory is writable and asserts the inode DID change) and every chmod is
restored before the assertions run.

GREEN:
- `cargo nextest run -p mds-cli --bin mds atomic_write_file` — 8/8 passed
- `cargo nextest run -p mds-cli --bin mds` — 155/155 passed
- `cargo nextest run -p mds-cli --test cli_fmt --test cli_lint` — 172/172
  passed, unchanged-green (includes `fmt_single_file_preserves_mode_0644`,
  `lint_fix_preserves_mode_0644` and the stderr filename gates)
- `cargo fmt --all --check` clean; `cargo clippy --workspace --all-targets
  -- -D warnings` clean
- `grep -rn 'cannot get metadata' crates/` — 0 hits (positive control: the
  same grep finds a planted copy of the string)

The rustdoc block gains a `# Contract (#226)` section stating that
replace-by-rename gives the target a new inode and therefore preserves
neither hard links nor ACLs, xattrs or owner/group — only the permission
bits.
…gh atomic_write_file; add the write-funnel guard (#227)

Five raw `std::fs::write` sites in build.rs now go through
`crate::output::atomic_write_file` (temp file in the target directory → rename):
`write_output`, the directory-mode compiled-output writer, and all three `.map`
sidecar writers (stdin, single-file, directory mode). A failed or interrupted
build can no longer leave a truncated artifact, and a symlink at an output path
is refused instead of being written through. `create_dir_all` stays at each call
site — the primitive deliberately does not create directories.

Two production writes stay raw, each with a justification comment and an
allow-list entry in the new guard: `mds init` (creates only, after an explicit
exists-check) and the test-only watch readiness marker (already temp+rename).

## Write-funnel guard

`crates/mds-cli/tests/write_funnel.rs` converts "did we remember every write
site?" into a machine-checked invariant over `crates/mds-cli/src/**`: needles
`fs::write(` / `File::create(` counted after masking comments and string
literals and stripping `#[cfg(test)] mod` blocks. Three tests: the scan itself
(with a lexical pin that the choke point still `.sync_all()`s and `.persist(`s),
a positive self-check on synthetic sources, and an anti-rot check that every
allow-list entry is still live. Lexical limits (`OpenOptions`, aliases) are
stated in the module doc. Scanner helpers are copied from
`crates/mds-core/tests/yaml_funnel.rs` — integration-test binaries cannot share
code across crates.

## RED → GREEN evidence

Tests written first and run against ee8c3f0 (`--no-fail-fast`, 121 tests,
114 passed / 7 failed):

| id | test | RED @ ee8c3f0 | GREEN |
|---|---|---|---|
| T-F1 | write_sites_are_funnelled | FAIL — "raw write sites outside the atomic funnel (5 found)", all in build.rs | pass |
| T-F2 | the_guard_flags_a_planted_raw_write | pass (positive self-check, synthetic sources only) | pass |
| T-F3 | every_allowlist_entry_is_live | pass | pass |
| T-B1 | build_o_first_build_emits_only_compiled_to | pass (sentinel pin against a naive reroute re-adding the #225 warning) | pass |
| T-B2 | build_o_new_output_mode_matches_std_fs_write | pass (pin) | pass |
| T-B3 | build_o_existing_output_mode_0640_preserved | pass — `std::fs::write` over an existing file already preserved the mode, so this is a pin, not a fix | pass |
| T-B4 | build_o_symlinked_output_target_rejected | FAIL — exit 0, "Compiled to …/link.md"; main wrote through the link | pass |
| T-B5 | build_o_write_failure_preserves_existing_output_no_temp_residue | FAIL — exit 0; main overwrote inside a 0555 directory | pass |
| T-B6 | build_o_success_leaves_no_temp_residue | pass (pin) | pass |
| T-B7 | build_o_bare_filename_writes_in_cwd | pass (pin) | pass |
| T-S1 | build_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "Source map written to …" | pass |
| T-S2 | build_stdin_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "Source map written to …" | pass |
| T-D1 | dir_build_out_dir_source_map_no_temp_residue | pass (pin) | pass |
| T-D2 | dir_build_write_failure_preserves_existing_outputs | FAIL — exit 0, "2 built, 0 failed" into a 0555 out-dir | pass |
| T-D3 | dir_build_source_map_sidecar_symlink_target_rejected | FAIL — exit 0, "1 built, 0 failed" | pass |

Every rejection test carries a positive control; every chmod is restored before
the assertions run. All Unix-only tests are `#[cfg(unix)]`. No `#[ignore]`, no
checked-in fixtures.

Gates: `cargo nextest run -p mds-cli` 867/867 passed (851 before this commit);
`--test print_discipline` 11/11 (no sanitizer-registry edits needed —
`safe_inline` is already registered); `--test cli_watch` 79/79;
`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`
and `cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings`
all exit 0; `RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core --no-deps` clean.

## Durability split (measured, contingency C-1 applied)

`sync_all()` is `F_FULLFSYNC` on macOS. Routing every artifact through it was
measured on this machine (medians of 3, same session, back to back, only
build.rs differing):

| measurement | raw writes | atomic + unconditional fsync | atomic + C-1 |
|---|---|---|---|
| `watch_dir_mode_idle_500_files_no_recompile` (solo) | 1.436 s | 4.694 s (+227 %) | 1.554 s (+8 %) |
| `cli_watch` suite (nextest) | 4.205 s | 8.347 s (+98 %) | 4.331 s (+3 %) |
| `cli_watch` suite (wall) | 4.527 s | 8.667 s (+91 %) | 4.659 s (+3 %) |
| `dir_build` suite | 0.652 s | 0.725 s | 0.483 s |

Threshold BREACHED: the `cli_watch` suite grew +91 % wall against a +20 % budget,
and inside the parallel suite the 500-file test reached 8.418 s against a 10 s
`READY_TIMEOUT` — one slower runner from failing. Contingency C-1 applied:

`pub(crate) enum Durability { Fsync, RenameOnly }` is now a third parameter to
`atomic_write_file`. `fmt.rs` / `lint.rs` pass `Fsync` — they rewrite the user's
hand-authored `.mds` source in place, so bytes lost to a power failure are lost
for good. `write_output`, the dir-mode writer and all three `.map` sites pass
`RenameOnly` — compiled artifacts are derived and a rebuild reproduces them.
Atomicity is unconditional either way: it comes from the rename, not the fsync.
`atomic_write_file_rename_only_matches_fsync_contract` pins that `RenameOnly`
changes nothing else (content, fresh-file mode, symlink refusal, no residue).

## User-visible text (D7)

On temp-file creation failure the message is now
`cannot create temp file for {path}: {io error} at path "{tmp}"` where it was
`cannot write {path}: {io error}`. In directory mode the per-file line is
`error: cannot create temp file for …` where it was `error: cannot write …` —
the primitive's message already names the path, so the call site no longer
prefixes it (printing the path twice). Write failures still exit 1: the
primitive returns a bare `miette::miette!` report, which `build::exit_code` maps
to 1 rather than the I/O class 2. That is unchanged behaviour, not a decision
taken here; exit 2 is arguably more correct and is left for a follow-up.

Refs #227, #225
…h keeps waiting (#204)

Tests first, deliberately failing. `mds build|check|fmt|lint <dir>` on a tree
with zero .mds files exits 0 today (and prints nothing under --quiet), which is
a silent CI green pass on a mistyped or not-yet-populated directory. The sibling
all-excluded case already errors; these tests pin the empty case to the same
shape. `mds watch <dir>` stays unchanged and is pinned as such.

Three exit-0 tests are rewritten IN PLACE (not deleted); each records the
reversal in its doc comment.

RED evidence, at d44cc47 with the tests applied and no production change:

  cargo nextest run -p mds-cli --test dir_build --test cli_commands \
      --test cli_lint --no-fail-fast
  → 197 tests run: 186 passed, 11 failed

  cargo nextest run -p mds-cli --test cli_watch watch_dir_mode_empty_root
  → 1 test run: 1 passed

| id   | test                                                        | RED |
|------|-------------------------------------------------------------|-----|
| E-1a | dir_build_empty_dir_exits_one                                | FAIL — exit Some(0), want Some(1) |
| E-1b | dir_build_empty_dir_quiet_still_emits_diagnostic             | FAIL — exit Some(0), stderr empty |
| E-1c | dir_build_genuinely_empty_exits_one_without_excluded_diagnostic | FAIL — exit Some(0), want Some(1) |
| E-1d | cli_build_directory_empty_exits_one_missing_root_exits_two   | FAIL — exit Some(0) on the empty half |
| E-2a | dir_check_empty_dir_exits_one                                | FAIL — exit Some(0), want Some(1) |
| E-2b | dir_check_empty_dir_quiet_still_emits_diagnostic             | FAIL — exit Some(0), stderr empty |
| E-3a | dir_fmt_empty_dir_exits_one                                  | FAIL — exit Some(0), want Some(1) |
| E-3b | dir_fmt_empty_dir_quiet_still_emits_diagnostic               | FAIL — exit Some(0), stderr empty |
| E-3c | dir_fmt_empty_dir_check_flag_exits_one                       | FAIL — exit Some(0), want Some(1) |
| E-4a | lint_directory_empty_exits_two_prints_no_summary             | FAIL — exit Some(0), want Some(2) |
| E-4c | lint_directory_empty_format_json_exits_two_no_envelope       | FAIL — exit Some(0), want Some(2) |
| E-5  | dir_check_missing_root_exits_two                             | PASS — pin of unchanged behaviour |
| W-E1 | watch_dir_mode_empty_root_starts_and_picks_up_new_file       | PASS — pin that watch is unchanged |

Every failure is the exit-code assertion, i.e. the behaviour under change; the
message needles are asserted in the same tests and are reached once the exit
code is right. The two pins are green before and after by construction.

cargo fmt --all: clean. cargo clippy -p mds-cli --all-targets -- -D warnings: clean.
…thing to do is an error, like all-excluded (#204)

`mds build|check|fmt|lint <dir>` on a tree with zero .mds files exited 0 and,
under --quiet, printed nothing at all: a silent CI green pass on a mistyped or
not-yet-populated directory. The sibling "all candidates under default-excluded
directories" case already errored and already bypassed --quiet; the empty case
now does the same thing in the same shape.

  no .mds files found in <dir>; nothing was built      exit 1   build.rs
  no .mds files found in <dir>; nothing was checked    exit 1   main.rs
  no .mds files found in <dir>; nothing was formatted  exit 1   fmt.rs
  no .mds files found in <dir>; nothing was linted     exit 2   lint.rs

Exit 2 for lint is its usage-error code, matching lint's own all-excluded arm;
build/check/fmt use 1. Each arm is an inline `eprintln!` + `process::exit`
mirroring the all-excluded arm directly above it: no new flag, no new `MdsError`
variant, no `exit_code` arm, no shared helper — "nothing to do" is not an error
class the error enum should grow.

`mds watch <dir>` is UNCHANGED and deliberately so: it still starts and arms on
an empty root, because a file created later is a valid flow there.
`watch_dir_mode_empty_root_starts_and_picks_up_new_file` pins that.

fmt's arm sits before the `read_only = check || diff` split, so --check and
--diff behave identically on an empty tree.

The path is interpolated with the existing `safe_path` sanitizer, so
tests/print_discipline.rs needed zero registry edits (11/11 green).

Also documents the contract in code: the `run_build_directory` rustdoc gains a
"Nothing to build is an error (#204)" paragraph; lint.rs's AD-216-9 comment now
covers both early exits.

GREEN: cargo nextest run -p mds-cli → 875/875 passed, 0 skipped
       (867 before + 8 new tests; 5 more were rewritten in place).
All all-excluded tests and the mixed-tree positive controls
(dir_build_mixed_excluded_and_normal_processes_normal, dir_fmt_skips_node_modules)
still pass.

cargo fmt --all: clean.
cargo clippy --workspace --all-targets -- -D warnings: clean.
cargo clippy -p mds-cli --all-targets --features startup-race-probe -- -D warnings: clean.
…7.2, SECURITY.md, RELEASING.md); empty-directory exit contract (spec §7.2/§7.4/§7.5/§7.9, README); CHANGELOG (#227, #225, #226, #204)

Documents the four behaviour changes this branch landed, at the sites that carry
the user-facing contract:

- spec §7.2 "Output writing" — temp-file-then-rename for mds build / mds watch
  compiled outputs and .map sidecars and for mds fmt / mds lint --fix source
  rewrites; the symlink refusal; the fsync split (sources fsync, derived
  artifacts rename only); the not-preserved list (hard links, ACLs, xattrs,
  owner/group) with permission bits preserved on Unix. Same contract restated in
  SECURITY.md (filesystem boundary) and RELEASING.md (ops note, replacing the
  older two-line version). crates/mds-cli/tests/write_funnel.rs is named as the
  enforcer at every site; the claim is scoped to the enumerated write paths, not
  to every file MDS writes.
- spec §7.2/§7.3/§7.4/§7.5/§7.9 and README — the empty-directory exit contract:
  build/check/fmt exit 1, lint exits 2, all four emit the diagnostic even under
  --quiet; mds watch <dir> is unchanged and still starts on an empty tree.
- CHANGELOG [Unreleased] — an inline BREAKING (CLI) entry under Changed for the
  exit-code change with the migration note, a Fixed entry for the atomic build
  outputs, and an Internal entry for the documented rename contract.
- .devflow/features/mds-{fmt,lint}/KNOWLEDGE.md — atomic_write_file now takes a
  Durability parameter and is shared with build/watch; the write_funnel guard is
  recorded as a gotcha; the stale output.rs:432 line reference is corrected.

Also fixes three pre-existing rustdoc errors in mds-cli so the mds-cli rustdoc
gate passes: two unresolved intra-doc links (SanitizedReport::labels /
::source_code, now plain backticked text) and an unclosed HTML tag <output-file>
in a clap doc comment (now backticked; --help renders the backticks).

Docs and doc comments only — no production logic touched.
…the exit-0 reversal in cli_commands.rs (#204)

No other changes — the rest of the diff (build.rs/fmt.rs/lint.rs/main.rs eprintln
blocks, output.rs atomic_write_file/Durability, new test files) was already clean
of unused imports, dead code, and redundant comments, so no optional clarity edits
were made.
…cates in place and both exists()/write follow a symlink at the target (#227)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant