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) |