Skip to content
Merged
5 changes: 3 additions & 2 deletions .devflow/features/mds-fmt/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions .devflow/features/mds-lint/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --fix --check --format json` called `process::exit(1)` before `emit_result`, emitting zero stdout bytes (AC-F-14 held for `<dir>` but not `<file>`); (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`)

Expand Down Expand Up @@ -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<serde_json::Value>` does not implement `IntoPy`. Use the custom `#[getter]` which calls `value_to_py`. Stored internally as `Option<Vec<serde_json::Value>>`.

Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<dir>; 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 <dir>` (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 <dir>` 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Exit codes:
3 Resource limit exceeded
```

**Directory mode** (`mds build <dir>` / `mds check <dir>`): 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 <dir>` / `mds check <dir>`): 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 <dir>; 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 <dir>` 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 <dir>` 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.

Expand All @@ -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.
Expand Down Expand Up @@ -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 <dir>; 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.

Expand All @@ -236,6 +242,9 @@ mds lint --quiet . # directory lint: silent on clean/warn-only; sum
Directory mode (`mds lint <dir>`) 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 <dir>; 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
Expand Down
Loading
Loading