From d335244a00794eb578019dde39ec980a52960502 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 28 Sep 2026 04:07:14 +0000 Subject: [PATCH 1/2] fix(tasks): make installed-batten-visible a three-field mutant row The sweep splits a #MUTANT row on `|`, and the sed carried the body's `||`, so the row read as five fields and was reported malformed-row: the mutation never ran. The pipes are now bracket expressions, and `MUTANT_TASKS=mise.toml MUTANT_GATES=task-test:cargo batten mutate sweep` reports it caught. Refs: CLOUD-1951 --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index ed868e837..6f4f08aee 100644 --- a/mise.toml +++ b/mise.toml @@ -1277,7 +1277,7 @@ run = 'cargo nextest run --workspace --no-fail-fast "$@"' [tasks."test:cargo"] #MUTANT-SUITE crates/batten/tests/it/test_cargo_path.rs -#MUTANT installed-batten-visible|s@\[ "[$][{]f##[*]/[}]" = batten \] || @@|the_mask_hides_every_batten_and_keeps_the_rest +#MUTANT installed-batten-visible|s@\[ "[$][{]f##[*]/[}]" = batten \] [[:punct:]][[:punct:]] @@|the_mask_hides_every_batten_and_keeps_the_rest description = "Run the Rust workspace test suite" # Routed through the per-step receipt (CLOUD-424): same sources, same command, # same toolchain ⇒ same verdict, so a hit answers from the receipt instead of From ed09e2b047f101981454227c503f6a86d9a1f9d0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 28 Sep 2026 04:49:30 +0000 Subject: [PATCH 2/2] fix(tasks): refuse a suite run that changes a tracked file in the checkout On #928, bench_tokens rewrote the committed RESULTS.md from a verb whose cwd was the real checkout, and nothing noticed until a later lap's diff. A line predicate over test sources cannot see that (measured: 30 of 30 matches were copies INTO scratch), so test:cargo now compares tracked state before and after the suite and exits 1 naming the paths when they differ. test_cargo_path.rs runs the marked blocks in a scratch repo, rewritten and untouched, and declares suite-tree-write-admitted. Both of the gate's mutations are caught by the sweep. Closes: CLOUD-1953 --- crates/batten/tests/it/test_cargo_path.rs | 85 +++++++++++++++++++++-- mise.toml | 20 ++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/crates/batten/tests/it/test_cargo_path.rs b/crates/batten/tests/it/test_cargo_path.rs index 5ebcf72af..d5d92e6b1 100644 --- a/crates/batten/tests/it/test_cargo_path.rs +++ b/crates/batten/tests/it/test_cargo_path.rs @@ -14,20 +14,26 @@ use crate::common; use std::fs; use std::path::{Path, PathBuf}; -const OPEN: &str = "# >>> no-batten-path"; -const CLOSE: &str = "# <<< no-batten-path"; - -/// The committed block between the markers. -fn mask() -> String { +/// The committed block of `test:cargo` between `# >>> ` and `# <<< `. +fn block(name: &str) -> String { let text = fs::read_to_string(common::at_root("mise.toml")).expect("read mise.toml"); - let start = text.find(OPEN).expect("the opening marker is in mise.toml") + OPEN.len(); + let open = format!("# >>> {name}"); + let close = format!("# <<< {name}"); + let start = text + .find(&open) + .expect("the opening marker is in mise.toml") + + open.len(); let end = text[start..] - .find(CLOSE) + .find(&close) .expect("the closing marker follows it") + start; text[start..end].to_owned() } +fn mask() -> String { + block("no-batten-path") +} + fn stub(dir: &Path, name: &str) { fs::create_dir_all(dir).expect("fixture dir"); let path = dir.join(name); @@ -128,3 +134,68 @@ fn a_path_without_batten_is_untouched() { Some(other.join("tool").to_str().expect("utf-8 path")) ); } + +/// Run the tree guard's two blocks around `between`, in `repo`. +#[expect( + clippy::disallowed_types, + reason = "stays, and test-only: the guard is shell the task runs under `sh`, so running it under `sh` is the only reading of the shipped bytes" +)] +fn guard(repo: &Path, between: &str) -> std::process::Output { + std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "{}\n{between}\n{}", + block("tree-before"), + block("tree-after") + )) + .current_dir(repo) + .output() + .expect("run the guard under sh") +} + +fn tracked_repo(name: &str) -> PathBuf { + let root = common::scratch(name); + common::init_repo(&root); + fs::write(root.join("RESULTS.md"), "committed\n").expect("seed a tracked file"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + root +} + +/// `#MUTANT suite-tree-write-admitted` reddens here. The shape is #928's: +/// a case rewrote the committed `RESULTS.md` from a verb run in this checkout. +#[test] +fn a_suite_that_rewrites_a_tracked_file_is_refused() { + if !cfg!(unix) { + // The guard is a mise task body run under `sh`, as above. + return; + } + let repo = tracked_repo("test-cargo-tree-written"); + let out = guard(&repo, "echo rewritten > RESULTS.md"); + assert_eq!( + out.status.code(), + Some(1), + "a changed tracked file is refused: {out:?}" + ); + let stderr = String::from_utf8(out.stderr).expect("utf-8"); + assert!( + stderr.contains("RESULTS.md"), + "the refusal names the path: {stderr}" + ); +} + +/// The pass side: without it the refusal above is satisfied by a guard that +/// refuses every run. +#[test] +fn a_suite_that_leaves_the_tree_alone_passes() { + if !cfg!(unix) { + // As above. + return; + } + let repo = tracked_repo("test-cargo-tree-clean"); + let out = guard(&repo, "mkdir -p target && echo scratch > target/x"); + assert!( + out.status.success(), + "untracked output is not a tracked change: {out:?}" + ); +} diff --git a/mise.toml b/mise.toml index 6f4f08aee..e23473ec0 100644 --- a/mise.toml +++ b/mise.toml @@ -1278,6 +1278,7 @@ run = 'cargo nextest run --workspace --no-fail-fast "$@"' [tasks."test:cargo"] #MUTANT-SUITE crates/batten/tests/it/test_cargo_path.rs #MUTANT installed-batten-visible|s@\[ "[$][{]f##[*]/[}]" = batten \] [[:punct:]][[:punct:]] @@|the_mask_hides_every_batten_and_keeps_the_rest +#MUTANT suite-tree-write-admitted|s@\[ "[$]tracked_before" != "[$]tracked_after" \]@false@|a_suite_that_rewrites_a_tracked_file_is_refused description = "Run the Rust workspace test suite" # Routed through the per-step receipt (CLOUD-424): same sources, same command, # same toolchain ⇒ same verdict, so a hit answers from the receipt instead of @@ -1324,7 +1325,26 @@ done IFS=$old_ifs PATH="$masked"; export PATH # <<< no-batten-path +# +# THE SUITE LEAVES THE REAL CHECKOUT AS IT FOUND IT (CLOUD-1953). On #928 +# `bench_tokens` rewrote the committed `RESULTS.md` from a verb whose cwd was +# this checkout, and nothing noticed until a later lap's diff. A line predicate +# over test sources cannot see that -- the write is a verb's, at run time -- so +# the check is the tree itself: tracked state before and after, compared, and a +# difference is a refusal naming the paths. Untracked files are out of scope: +# `target/` and the scratch parent are expected to move. +# >>> tree-before +tracked_before=$(git status --porcelain --untracked-files=no) +# <<< tree-before if ! cargo nextest run --workspace; then exit 1; fi +# >>> tree-after +tracked_after=$(git status --porcelain --untracked-files=no) +if [ "$tracked_before" != "$tracked_after" ]; then + echo "::error:: test:cargo: the suite changed tracked files in this checkout -- a case wrote outside its scratch directory:" >&2 + printf '%s\n' "$tracked_after" | cut -c4- >&2 + exit 1 +fi +# <<< tree-after mise run -q step-receipt record test:cargo || true '''