diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06817f2f..936cbbdd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
+- **BREAKING (CLI): `mds build ` and `mds check ` now exit 1 when every
+ `.mds` file in the tree is a `_`-prefixed partial (#387).** The walker collects
+ partials (they are inputs for `watch`, `fmt` and `lint`) but `build`/`check` never
+ compile them, so a partials-only tree used to end `0 built, 0 failed` with exit 0 —
+ the same silent green pass #204 closed for the empty tree. It now prints ` .mds
+ file(s) found in but all are _-prefixed partials; nothing was built`
+ (`…checked`) on stderr, even under `--quiet`, and exits 1. `mds fmt ` and
+ `mds lint ` are unchanged: they format and lint partials, so a partials-only
+ library is real work for them. `mds watch ` is unchanged and still starts. A
+ partials library that is only ever imported from elsewhere should not be passed to
+ `build`/`check` on its own.
- **`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
diff --git a/README.md b/README.md
index d39a84d8..fcc12c23 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) 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.
+**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.) A directory whose `.mds` files are all `_`-prefixed partials is treated the same way — `build`/`check` exit `1` with ` .mds file(s) found in but all are _-prefixed partials; nothing was built` (`…checked`), even under `--quiet`, while `mds fmt` and `mds lint` are unaffected since they format and lint partials. (Changed in v0.4.3; previously `0 built, 0 failed`, exit 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.
diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs
index 61fc684f..2c5fd985 100644
--- a/crates/mds-cli/src/build.rs
+++ b/crates/mds-cli/src/build.rs
@@ -1577,13 +1577,19 @@ 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.
+/// **Nothing to build is an error (#204, #387):** when the walk yields no files to
+/// compile the run exits 1 with a one-line stderr diagnostic that bypasses `--quiet`.
+/// Three shapes, in the order they are checked: the all-excluded count diagnostic
+/// (every candidate sits under a default-excluded directory), `no .mds files found
+/// in ; nothing was built` (a genuinely empty tree), and the partials-only
+/// count diagnostic (#387 — the tree is non-empty but every candidate is a
+/// `_`-prefixed partial). All three 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` mirrors all three with exit 1; `mds fmt` (exit 1) and `mds lint`
+/// (exit 2) mirror only the first two — they iterate every file including partials,
+/// so a partials-only tree is real work for them, not "nothing to do".
+/// `mds watch ` deliberately does NOT error on any of the three.
///
/// **Documented limitation (AC-Q05):** two warning writers reachable from this
/// function do not accept a `quiet` parameter — `output.rs::collect_mds_files_inner`
@@ -1665,6 +1671,21 @@ fn run_build_directory(
std::process::exit(1);
}
+ // #387: a tree whose only .mds files are partials is "nothing to build" too. The
+ // walker collects partials (watch/fmt/lint need them) but this loop skips them, so
+ // without this arm the run ends `0 built, 0 failed`, exit 0 — the silent green pass
+ // #204 closed for the empty tree. Same shape as the all-excluded arm: count-carrying,
+ // emitted even under --quiet, exit 1. fmt and lint operate on partials and keep their
+ // behaviour; `mds watch ` still starts.
+ if let Some(partials_only_count) = crate::output::partials_only(&files) {
+ eprintln!(
+ "{partials_only_count} .mds file(s) found in {} but all are _-prefixed partials; \
+ nothing was built",
+ crate::output::safe_path(dir)
+ );
+ std::process::exit(1);
+ }
+
let mut ok_count: usize = 0;
let mut fail_count: usize = 0;
// R5: successful compilations whose written artifact is ZERO bytes (strict
diff --git a/crates/mds-cli/src/main.rs b/crates/mds-cli/src/main.rs
index 7e7ae28d..5aac4dae 100644
--- a/crates/mds-cli/src/main.rs
+++ b/crates/mds-cli/src/main.rs
@@ -352,6 +352,21 @@ fn run_check_directory(
std::process::exit(1);
}
+ // #387: a tree whose only .mds files are partials is "nothing to check" too. The
+ // walker collects partials (watch/fmt/lint need them) but this loop skips them, so
+ // without this arm the run ends `0 passed, 0 failed`, exit 0 — the silent green pass
+ // #204 closed for the empty tree. Same shape as the all-excluded arm: count-carrying,
+ // emitted even under --quiet, exit 1. fmt and lint operate on partials and keep their
+ // behaviour; `mds watch ` still starts.
+ if let Some(partials_only_count) = output::partials_only(&files) {
+ eprintln!(
+ "{partials_only_count} .mds file(s) found in {} but all are _-prefixed partials; \
+ nothing was checked",
+ output::safe_path(dir)
+ );
+ std::process::exit(1);
+ }
+
let mut ok_count: usize = 0;
let mut fail_count: usize = 0;
diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs
index 1215c024..72c12b44 100644
--- a/crates/mds-cli/src/output.rs
+++ b/crates/mds-cli/src/output.rs
@@ -578,6 +578,17 @@ pub(crate) fn is_partial(path: &Path) -> bool {
.unwrap_or(false)
}
+/// `Some(n)` when `files` is non-empty and every entry is a `_`-prefixed partial —
+/// the "nothing to build/check" case #387 closes; `None` for an empty list (the
+/// empty-tree arm owns that) or when any non-partial entry exists. One predicate for
+/// `run_build_directory` and `run_check_directory` so the two arms cannot drift.
+pub(crate) fn partials_only(files: &[PathBuf]) -> Option {
+ if files.is_empty() {
+ return None;
+ }
+ files.iter().all(|f| is_partial(f)).then_some(files.len())
+}
+
// ── Stale-output cleanup ──────────────────────────────────────────────────────
/// Probe for BOTH possible output siblings and unlink the one that does NOT match `kind`.
@@ -1692,6 +1703,20 @@ mod tests {
assert!(!is_partial(Path::new("/dir/not_partial.mds")));
}
+ #[test]
+ fn partials_only_answers() {
+ assert_eq!(partials_only(&[]), None);
+ assert_eq!(partials_only(&[PathBuf::from("_a.mds")]), Some(1));
+ assert_eq!(
+ partials_only(&[PathBuf::from("_a.mds"), PathBuf::from("b.mds")]),
+ None
+ );
+ assert_eq!(
+ partials_only(&[PathBuf::from("_a.mds"), PathBuf::from("_b.mds")]),
+ Some(2)
+ );
+ }
+
// ── is_default_excluded_dir ───────────────────────────────────────────────
#[test]
diff --git a/crates/mds-cli/tests/dir_build.rs b/crates/mds-cli/tests/dir_build.rs
index 03cf4e52..776b5ae0 100644
--- a/crates/mds-cli/tests/dir_build.rs
+++ b/crates/mds-cli/tests/dir_build.rs
@@ -465,6 +465,50 @@ fn dir_check_empty_dir_quiet_still_emits_diagnostic() {
);
}
+/// #387: `mds check` mirrors `mds build` — a tree whose only `.mds` files are
+/// partials is "nothing to check". Positive control in the same test: adding a
+/// non-partial file flips the tree back to a normal successful check.
+#[test]
+fn dir_check_partials_only_exits_one() {
+ let src = tempfile::tempdir().unwrap();
+
+ create_plain_mds(src.path(), "_only.mds");
+
+ let output = check_dir(src.path(), &[]);
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert_eq!(
+ output.status.code(),
+ Some(1),
+ "check on a partials-only dir must exit 1; stderr: {stderr}"
+ );
+ assert!(
+ stderr.contains("nothing was checked"),
+ "stderr must say nothing was checked; got: {stderr:?}"
+ );
+ assert!(
+ stderr.contains("all are _-prefixed partials"),
+ "stderr must say all are _-prefixed partials; got: {stderr:?}"
+ );
+
+ // Positive control: a non-partial file in the same tree flips this back to a
+ // normal successful check.
+ create_plain_mds(src.path(), "real.mds");
+
+ let output2 = check_dir(src.path(), &[]);
+
+ let stderr2 = String::from_utf8_lossy(&output2.stderr);
+ assert_eq!(
+ output2.status.code(),
+ Some(0),
+ "check with a real file present must succeed; stderr: {stderr2}"
+ );
+ assert!(
+ stderr2.contains("1 passed, 0 failed"),
+ "stderr must show the check summary; got: {stderr2:?}"
+ );
+}
+
/// #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
@@ -611,6 +655,94 @@ fn dir_build_empty_dir_quiet_still_emits_diagnostic() {
);
}
+// ── #387: partials-only directory is "nothing to build" ──────────────────────
+
+/// #387: a tree whose only `.mds` files are `_`-prefixed partials is "nothing to
+/// build" too — the loop skips every partial (T-CLI-13), so without this arm the
+/// run silently ends `0 built, 0 failed`, exit 0: the same silent green pass #204
+/// closed for the empty tree. Positive control in the same test: adding a
+/// non-partial file to the tree flips the run back to a normal successful build.
+#[test]
+fn dir_build_partials_only_exits_one() {
+ let src = tempfile::tempdir().unwrap();
+ let out = tempfile::tempdir().unwrap();
+
+ create_plain_mds(src.path(), "_only.mds");
+
+ let output = build_dir(src.path(), &["--out-dir", out.path().to_str().unwrap()]);
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert_eq!(
+ output.status.code(),
+ Some(1),
+ "build on a partials-only dir must exit 1; stderr: {stderr}"
+ );
+ assert!(
+ stderr.contains("1 .mds file(s) found in"),
+ "stderr must carry the partials-only count diagnostic; got: {stderr:?}"
+ );
+ assert!(
+ stderr.contains("all are _-prefixed partials"),
+ "stderr must say all are _-prefixed partials; got: {stderr:?}"
+ );
+ assert!(
+ stderr.contains("nothing was built"),
+ "stderr must say nothing was built; got: {stderr:?}"
+ );
+ assert!(
+ !out.path().join("_only.md").exists(),
+ "a partial must never produce output"
+ );
+
+ // Positive control: a non-partial file in the same tree flips this back to a
+ // normal successful build — proves the exit-1 arm fires on "all partials",
+ // not on "any partial present".
+ create_plain_mds(src.path(), "real.mds");
+
+ let output2 = build_dir(src.path(), &["--out-dir", out.path().to_str().unwrap()]);
+
+ assert_eq!(
+ output2.status.code(),
+ Some(0),
+ "build with a real file present must succeed; stderr: {}",
+ String::from_utf8_lossy(&output2.stderr)
+ );
+ assert!(
+ out.path().join("real.md").exists(),
+ "real.md should be created"
+ );
+ assert!(
+ !out.path().join("_only.md").exists(),
+ "_only.md must not be created (partials are skipped)"
+ );
+}
+
+/// #387: `--quiet` must not suppress the partials-only diagnostic — mirrors the
+/// empty-tree and all-excluded `--quiet` twins above.
+#[test]
+fn dir_build_partials_only_quiet_still_emits_diagnostic() {
+ let src = tempfile::tempdir().unwrap();
+
+ create_plain_mds(src.path(), "_only.mds");
+
+ 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 a partials-only dir must exit 1; stderr: {stderr}"
+ );
+ assert!(
+ !stderr.is_empty(),
+ "stderr must not be empty under --quiet on a partials-only tree"
+ );
+ assert!(
+ stderr.contains("nothing was built"),
+ "stderr must say nothing was built under --quiet; got: {stderr:?}"
+ );
+}
+
// ── Additional test helpers for lint/fmt subcommands ─────────────────────────
fn lint_dir(dir: &Path, extra_args: &[&str]) -> std::process::Output {
@@ -1004,6 +1136,62 @@ fn dir_fmt_empty_dir_check_flag_exits_one() {
);
}
+// ── #387 pins: fmt/lint are unaffected — they operate on partials ────────────
+
+/// #387 pin: `mds fmt` iterates every file including partials (T-CLI-13 does not
+/// apply to fmt/lint), so a partials-only tree is real formatting work, not
+/// "nothing to do". Must NOT regress into the build/check nothing-to-do wording.
+#[test]
+fn dir_fmt_partials_only_still_formats() {
+ let src = tempfile::tempdir().unwrap();
+
+ create_plain_mds(src.path(), "_only.mds");
+
+ let output = fmt_dir(src.path(), &[]);
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert_eq!(
+ output.status.code(),
+ Some(0),
+ "fmt on a partials-only dir must still succeed; stderr: {stderr}"
+ );
+ assert!(
+ stderr.contains("1 unchanged"),
+ "stderr must show the fmt summary; got: {stderr:?}"
+ );
+ assert!(
+ !stderr.contains("nothing was formatted"),
+ "fmt must not treat a partials-only tree as nothing-to-do; got: {stderr:?}"
+ );
+}
+
+/// #387 pin: `mds lint` iterates every file including partials, so a
+/// partials-only tree is real lint work, not "nothing to do". Must NOT regress
+/// into the build/check nothing-to-do wording.
+#[test]
+fn dir_lint_partials_only_still_lints() {
+ let src = tempfile::tempdir().unwrap();
+
+ create_plain_mds(src.path(), "_only.mds");
+
+ let output = lint_dir(src.path(), &[]);
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert_eq!(
+ output.status.code(),
+ Some(0),
+ "lint on a partials-only dir must still succeed; stderr: {stderr}"
+ );
+ assert!(
+ stderr.contains("1 clean"),
+ "stderr must show the lint summary; got: {stderr:?}"
+ );
+ assert!(
+ !stderr.contains("nothing was linted"),
+ "lint must not treat a partials-only tree as nothing-to-do; 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,
diff --git a/crates/mds-cli/tests/print_discipline.rs b/crates/mds-cli/tests/print_discipline.rs
index f253e33c..25ea1abf 100644
--- a/crates/mds-cli/tests/print_discipline.rs
+++ b/crates/mds-cli/tests/print_discipline.rs
@@ -259,6 +259,13 @@ const ALLOWED_UNSANITIZED: &[(&str, &str, &str)] = &[
bytes, in the `mds build ` summary line (R5). An integer counter \
cannot carry a control byte.",
),
+ (
+ "build.rs",
+ "partials_only_count",
+ "`usize` count of `.mds` files found in a partials-only tree, bound from \
+ `output::partials_only`'s `Some(n)` in the #387 nothing-to-build diagnostic. \
+ An integer counter cannot carry a control byte.",
+ ),
(
"fmt.rs",
"walk.excluded_by_default",
@@ -338,6 +345,13 @@ const ALLOWED_UNSANITIZED: &[(&str, &str, &str)] = &[
"fail_count",
"`usize` tally of files that failed `mds check `, in its summary line.",
),
+ (
+ "main.rs",
+ "partials_only_count",
+ "`usize` count of `.mds` files found in a partials-only tree, bound from \
+ `output::partials_only`'s `Some(n)` in the #387 nothing-to-check diagnostic. \
+ An integer counter cannot carry a control byte.",
+ ),
(
"output.rs",
"max_depth",
diff --git a/spec.md b/spec.md
index e790d517..288e8527 100644
--- a/spec.md
+++ b/spec.md
@@ -897,6 +897,7 @@ mds build src/ --out-dir dist # Mirror subtree: src/a/b.mds → dis
- `-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 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.
+- When the directory contains `.mds` files but every one of them is a `_`-prefixed partial, exits 1 with ` .mds file(s) found in but all are _-prefixed partials; nothing was built` on stderr — emitted even under `--quiet`, the same bypass as the two diagnostics above. (Changed in v0.4.3; previously `0 built, 0 failed`, exit 0.) `mds fmt ` and `mds lint ` are unaffected: they format and lint partials, so a partials-only tree is real work for them. `mds watch ` is unaffected: it still starts.
- **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).
@@ -938,7 +939,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, 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.
+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 three nothing-to-process exits — empty tree, all-excluded, and partials-only — 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`
@@ -1318,7 +1319,7 @@ Maximum config file size: 1 MB.
| Code | Meaning |
|------|---------|
| `0` | Success |
-| `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) |
+| `1` | Template error (syntax, undefined variable, arity mismatch, recursion, etc.); in directory mode, also "nothing to process" (no `.mds` files, all under default-excluded directories, or — `build`/`check` only — nothing but `_`-prefixed partials) |
| `2` | I/O or file-system error (file not found, not an MDS file, I/O failure, a path that is not valid UTF-8) |
| `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) |