Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
- **BREAKING (CLI): `mds build <dir>` and `mds check <dir>` 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 `<n> .mds
file(s) found in <dir> but all are _-prefixed partials; nothing was built`
(`…checked`) on stderr, even under `--quiet`, and exits 1. `mds fmt <dir>` and
`mds lint <dir>` are unchanged: they format and lint partials, so a partials-only
library is real work for them. `mds watch <dir>` 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
Expand Down
2 changes: 1 addition & 1 deletion 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) 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.
**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.) A directory whose `.mds` files are all `_`-prefixed partials is treated the same way — `build`/`check` exit `1` with `<n> .mds file(s) found in <dir> 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 <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 Down
35 changes: 28 additions & 7 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>; 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 <dir>` 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 <dir>; 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 <dir>` 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`
Expand Down Expand Up @@ -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 <dir>` 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
Expand Down
15 changes: 15 additions & 0 deletions crates/mds-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` 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;

Expand Down
25 changes: 25 additions & 0 deletions crates/mds-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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`.
Expand Down Expand Up @@ -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]
Expand Down
188 changes: 188 additions & 0 deletions crates/mds-cli/tests/dir_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions crates/mds-cli/tests/print_discipline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,13 @@ const ALLOWED_UNSANITIZED: &[(&str, &str, &str)] = &[
bytes, in the `mds build <dir>` 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",
Expand Down Expand Up @@ -338,6 +345,13 @@ const ALLOWED_UNSANITIZED: &[(&str, &str, &str)] = &[
"fail_count",
"`usize` tally of files that failed `mds check <dir>`, 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",
Expand Down
Loading
Loading