From 21bb977bb7659e54ccb678f304c96ec7844a43b7 Mon Sep 17 00:00:00 2001 From: Heyoub Date: Sat, 15 Aug 2026 21:04:52 -0400 Subject: [PATCH 1/2] Make mutation evidence interruption-honest --- .cargo/mutants.toml | 33 ++- .github/workflows/mutation.yml | 133 +++++++---- xtask/src/main.rs | 2 + xtask/src/mutation_campaign.rs | 188 ++++++++++++++++ xtask/src/mutation_report.rs | 58 +++++ xtask/src/repository/snapshot.rs | 376 ++++++++++++++++++++++++++++++- 6 files changed, 719 insertions(+), 71 deletions(-) create mode 100644 xtask/src/mutation_campaign.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 1c36f94..9ff7c2f 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -57,16 +57,24 @@ # every file here is xtask's. # # THE ONE THING THIS LINE CANNOT SAY is which PACKAGES a run examines — the -# configuration has no key for it, so the selection is an argument. MEASURED: -# `cargo mutants` with no selection takes the root package alone, this glob -# matches nothing inside it, and the run finds no mutant and exits 0. It does say -# so, on a WARN line — and a warning that fails nothing is what a job reads as -# success, which is the whole difference between a diagnostic and a refusal. -# `cargo mutants --workspace` is therefore the invocation, on a working machine -# exactly as in the hosted job, and the job COUNTS what it examined rather than -# trusting that it examined something. +# configuration has no key for it. `cargo xtask mutation-campaign` owns the one +# admitted invocation and supplies `--workspace`; it accepts no pass-through +# arguments. MEASURED: a raw `cargo mutants` with no package selection takes the +# root package alone, this glob matches nothing inside it, and the run finds no +# mutant and exits 0. It does say so, on a WARN line — and a warning that fails +# nothing is what a job reads as success, which is the whole difference between +# a diagnostic and a refusal. The fixed launcher also binds scratch-compiled +# tests to the exact clean commit and tree they judge, so neither workflow shell +# nor cargo-mutants' current directory becomes repository identity. examine_globs = ["xtask/**/*.rs"] +# CHOSEN: the changed code is compiled in cargo-mutants' VCS-free scratch copy. +# Copying `.git` would make the mutant a tracked difference, so committed- +# snapshot tests would catch every mutation for the same unrelated reason. The +# launcher supplies a separate exact clean subject root, commit, and tree to +# tests instead; no mutation is made in that subject checkout. +copy_vcs = false + # CHOSEN: cap the lints, and this is the setting that decides what the run # MEASURES. `.cargo/config.toml` makes a surviving warning fatal, so without # this a mutant that merely trips `unused_variables` or `clippy::let_and_return` @@ -78,15 +86,6 @@ examine_globs = ["xtask/**/*.rs"] # a scratch directory and no build of this tree. cap_lints = true -# CHOSEN: write the report inside `target`, which is already ignored. The -# default puts `mutants.out` beside `Cargo.toml`, and the qualification road -# ends by refusing a checkout that does not match what is committed — so the -# default would make a working machine's mutation run fail the next `cargo xtask -# qualify` for a reason that has nothing to do with the tree. A tool that writes -# into the checkout is a tool that has to be remembered; one that writes into -# `target` does not. -output = "target" - # NOT SET, ON THE RECORD: `test_tool`. nextest would run these suites faster, # and `.config/nextest.toml` configures it two directories away. It is left at # cargo deliberately: a mutation result must not depend on whether the second diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index ed9cc2e..8853d3e 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -7,12 +7,13 @@ # run reads. This file is the caller. # # WHAT A RED HERE MEANS. Nothing about a change in flight, because no change can -# trigger this workflow. A red means either that the alarm did not operate or -# that a timed-out mutant left its outcome uncertain. SURVIVING MUTANTS DO NOT -# TURN IT RED. They are the finding, they are printed in full, and they are -# evidence debt owed at the home of the source that carries them; a job that -# went red on the first survivor would be red from the day it landed, and an -# alarm that is always red is an alarm nobody reads. +# trigger this workflow. A red means that the alarm did not operate, that a +# timed-out mutant left its outcome uncertain, or that external interruption +# prevented a final observation. SURVIVING MUTANTS DO NOT TURN IT RED. They are +# the finding, they are printed in full, and they are evidence debt owed at the +# home of the source that carries them; a job that went red on the first +# survivor would be red from the day it landed, and an alarm that is always red +# is an alarm nobody reads. name: mutation on: @@ -34,7 +35,68 @@ concurrency: permissions: contents: read +env: + CARGO_MUTANTS_VERSION: "27.0.0" + jobs: + # THE PLANTED REVERSAL. This is a sibling of the campaign, with no `needs` + # edge in either direction. A campaign runner that disappears therefore + # cannot erase the cheap observation that the empty scope is still empty. + # Whole-workflow cancellation may stop both jobs; no workflow topology can + # make a running job survive cancellation of its own run. + empty-scope-reversal: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + # The reversal builds no baseline after its deliberately empty selection. + # This is an operational hang ceiling, not a population claim. + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install the pinned toolchain + run: | + rustup toolchain install + rustup show + + - name: Install cargo-mutants + run: cargo install cargo-mutants --version "$CARGO_MUTANTS_VERSION" --locked + + - name: Record the exact reversal basis + run: | + echo "repository SHA: $(git rev-parse HEAD)" + echo "repository tree: $(git rev-parse HEAD^{tree})" + cargo mutants --version + + # The real campaign's positive control cannot establish that its empty- + # scope guard bites: a run that silently selects nothing also exits zero. + # Excluding every source selected by `.cargo/mutants.toml` plants that + # exact defect. cargo-mutants v27 writes an empty inventory and empty + # rosters but no outcomes document, and the typed adapter admits exactly + # that producer posture. + - name: The planted reversal must examine nothing + run: | + mkdir -p target + set +e + cargo mutants --workspace -e 'xtask/**' --output target/mutation-reversal + code=$? + set -e + cargo xtask mutation-report reversal "$code" target/mutation-reversal/mutants.out + + # This says only that the reversal's checkout remained clean. It is not + # evidence about the independent campaign runner or its checkout. + - name: The reversal checkout remains clean + if: always() + run: | + dirty=$(git status --porcelain=v2 --untracked-files=all) + if [ -n "$dirty" ]; then + echo "::error::the empty-scope reversal changed Git-visible checkout bytes" >&2 + printf '%s\n' "$dirty" >&2 + exit 1 + fi + echo "the empty-scope reversal left its Git-visible checkout clean" + xtask: # NAMED BY WHAT IT RUNS ON, never by what it skips, and the triggers above # already allow nothing else — so this condition is redundant TODAY and is @@ -64,7 +126,7 @@ jobs: # decision deferred to whoever resolves next. This is the version MEASURED # on a working machine when `.cargo/mutants.toml` was written. - name: Install cargo-mutants - run: cargo install cargo-mutants --version 27.0.0 --locked + run: cargo install cargo-mutants --version "$CARGO_MUTANTS_VERSION" --locked - name: Record the exact evidence basis run: | @@ -82,56 +144,27 @@ jobs: # this step counts what was examined instead of trusting that something # was. # - # The typed report adapter owns the producer's exit-code and report - # contract. In particular, survivors are evidence debt and keep the alarm - # green after validation, while a timeout prints its full receipt and then - # leaves this step red because the outcome is uncertain. + # The fixed xtask launcher first validates this clean checkout as the + # campaign's exact subject, then passes its absolute root, commit, and tree + # to tests compiled in cargo-mutants' VCS-free scratch copy. It owns the + # producer exit and invokes the typed report adapter afterward. Survivors + # are evidence debt and keep the alarm green after validation, while a + # timeout prints its full receipt and then leaves this step red because + # the outcome is uncertain. # - # `-j 2` builds in two scratch directories at once. More would trade the - # runner's disk for a speed this job does not need, since nothing waits on - # it. - name: The mutation run - run: | - mkdir -p target - set +e - cargo mutants --workspace -j 2 --output target/mutation-run - code=$? - set -e - cargo xtask mutation-report run "$code" target/mutation-run/mutants.out - - # THE PLANTED REVERSAL. The step above establishes that the alarm worked - # today. It cannot establish that its own guard bites, because a guard that - # quietly stopped counting prints the same numbers as one that counted - # everything — and what it guards against costs nothing by MEASUREMENT: a - # scope matching no source finds no mutant, warns, writes empty rosters, - # and exits 0. - # - # So the same run is made deliberately wrong, by excluding every source the - # scope selects, and this step fails the job when that run comes back with - # a mutant. It writes to its own report directory so the finding above is - # not overwritten by a run that found nothing. MEASURED: it finishes in - # seconds, because a run with nothing to test builds no baseline. - # - # WHAT THIS COVERS: that an empty scope really is silent, and that a run - # over one is distinguishable from a run over the real one by the typed - # report contract both roads consume. - - name: The planted reversal must examine nothing - if: always() - run: | - mkdir -p target - set +e - cargo mutants --workspace -e 'xtask/**' --output target/mutation-reversal - code=$? - set -e - cargo xtask mutation-report reversal "$code" target/mutation-reversal/mutants.out + run: cargo xtask mutation-campaign - - name: The checkout remains clean + # This can run after an ordinary command refusal. It cannot run after the + # hosted runner itself disappears, so cleanliness is claimed only when + # this step has an executed record. + - name: The campaign checkout remains clean if: always() run: | dirty=$(git status --porcelain=v2 --untracked-files=all) if [ -n "$dirty" ]; then - echo "::error::the mutation alarm changed Git-visible checkout bytes" >&2 + echo "::error::the mutation campaign changed Git-visible checkout bytes" >&2 printf '%s\n' "$dirty" >&2 exit 1 fi - echo "the mutation alarm left the Git-visible checkout clean" + echo "the mutation campaign left its Git-visible checkout clean" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ec35478..513ac57 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -27,6 +27,7 @@ //! checks themselves. mod checks; +mod mutation_campaign; mod mutation_report; mod repository; mod qualification; @@ -62,6 +63,7 @@ fn main() -> Result<(), Box> { }; match command.as_str() { "check" => run_checks(&root), + "mutation-campaign" => mutation_campaign::run(&root, std::env::args().skip(2)), "mutation-report" => mutation_report::run(&root, std::env::args().skip(2)), "qualify" => qualification::qualify(&root, run_checks), other => Err(format!("unknown xtask command: {other}").into()), diff --git a/xtask/src/mutation_campaign.rs b/xtask/src/mutation_campaign.rs new file mode 100644 index 0000000..4ccb949 --- /dev/null +++ b/xtask/src/mutation_campaign.rs @@ -0,0 +1,188 @@ +//! The fixed cross-platform launcher for the hosted mutation campaign. +//! +//! cargo-mutants compiles changed code in a VCS-free scratch copy. The +//! repository-law tests that code executes must still judge one clean committed +//! subject, or the unmutated baseline refuses for having no Git authority. This +//! launcher reads and validates the ordinary production repository snapshot +//! first, then supplies its absolute root, commit, and tree as one test-only +//! subject basis inherited by the scratch test processes. +//! +//! The command shape is closed here: workspace scope, concurrency, output +//! directory, and report admission cannot be changed by workflow arguments. A +//! copied VCS directory or in-place mutation would make the mutation itself look +//! like tracked dirt and counterfeit a caught result, so neither road exists. + +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::mutation_report; +use crate::repository::snapshot::{ + MUTATION_SUBJECT_COMMIT, MUTATION_SUBJECT_ROOT, MUTATION_SUBJECT_TREE, RepositorySnapshot, + cargo_binary, +}; + +/// The campaign's ignored output coordinate beneath the repository root. +const OUTPUT_DIRECTORY: &str = "target/mutation-run"; + +/// Derives the typed report adapter's input from the campaign output owner. +fn report_path() -> PathBuf { + Path::new(OUTPUT_DIRECTORY).join("mutants.out") +} + +/// Runs the one admitted mutation campaign and validates its finalized report. +pub(crate) fn run( + repository_root: &Path, + arguments: impl Iterator, +) -> Result<(), Box> { + require_no_arguments(arguments)?; + if !repository_root.is_absolute() { + return Err(format!( + "mutation-campaign requires an absolute repository root; got {}", + repository_root.display() + ) + .into()); + } + + let subject = RepositorySnapshot::read(repository_root)?; + let output_directory = repository_root.join(OUTPUT_DIRECTORY); + let output_parent = output_directory.parent().ok_or_else(|| { + format!( + "mutation output coordinate has no parent: {}", + output_directory.display() + ) + })?; + fs::create_dir_all(output_parent).map_err(|source| { + format!( + "cannot create mutation output parent {}: {source}", + output_parent.display() + ) + })?; + let mut campaign = command( + repository_root, + subject.committed().commit(), + subject.committed().tree(), + ); + let status = campaign + .status() + .map_err(|source| format!("cannot start cargo-mutants campaign: {source}"))?; + let exit_code = status.code().ok_or_else(|| { + String::from("cargo-mutants campaign ended by a signal before it could report an outcome") + })?; + let report_path = report_path(); + let report_path = report_path + .to_str() + .ok_or_else(|| { + format!( + "mutation report path is not Unicode: {}", + report_path.display() + ) + })? + .to_owned(); + mutation_report::run( + repository_root, + [String::from("run"), exit_code.to_string(), report_path].into_iter(), + ) +} + +/// Refuses a second command surface beside the one fixed below. +fn require_no_arguments(mut arguments: impl Iterator) -> Result<(), String> { + match arguments.next() { + None => Ok(()), + Some(argument) => Err(format!( + "mutation-campaign accepts no arguments; unexpected {argument:?}" + )), + } +} + +/// Constructs the exact cargo-mutants command and its test-only subject basis. +fn command(repository_root: &Path, commit: &str, tree: &str) -> Command { + let mut command = Command::new(cargo_binary()); + command + .current_dir(repository_root) + .args([ + OsStr::new("mutants"), + OsStr::new("--workspace"), + OsStr::new("-j"), + OsStr::new("2"), + OsStr::new("--output"), + OsStr::new(OUTPUT_DIRECTORY), + ]) + .env(MUTATION_SUBJECT_ROOT, repository_root) + .env(MUTATION_SUBJECT_COMMIT, commit) + .env(MUTATION_SUBJECT_TREE, tree); + command +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::ffi::{OsStr, OsString}; + use std::path::Path; + + use super::{OUTPUT_DIRECTORY, command, report_path, require_no_arguments}; + use crate::repository::snapshot::{ + MUTATION_SUBJECT_COMMIT, MUTATION_SUBJECT_ROOT, MUTATION_SUBJECT_TREE, + }; + + /// Positive control: the launcher owns one exact command and one complete + /// subject triple, with no copied-VCS, in-place, filtering, or baseline-skip + /// road hidden among its arguments. + #[test] + fn the_campaign_command_shape_is_closed() -> Result<(), String> { + let root = std::env::temp_dir().join("threadpak-mutation-command-subject"); + let campaign = command(&root, "expected-commit", "expected-tree"); + assert_eq!(campaign.get_current_dir(), Some(root.as_path())); + assert_eq!(report_path().parent(), Some(Path::new(OUTPUT_DIRECTORY))); + assert_eq!(report_path().file_name(), Some(OsStr::new("mutants.out"))); + let arguments = campaign.get_args().map(OsString::from).collect::>(); + assert_eq!( + arguments, + [ + OsString::from("mutants"), + OsString::from("--workspace"), + OsString::from("-j"), + OsString::from("2"), + OsString::from("--output"), + OsString::from(OUTPUT_DIRECTORY), + ] + ); + let environment = campaign + .get_envs() + .map(|(name, value)| { + let value = value.ok_or_else(|| { + format!( + "campaign removed environment input {}", + name.to_string_lossy() + ) + })?; + Ok((OsString::from(name), OsString::from(value))) + }) + .collect::, String>>()?; + assert_eq!(environment.len(), 3); + assert_eq!( + environment.get(OsStr::new(MUTATION_SUBJECT_ROOT)), + Some(&root.into_os_string()) + ); + assert_eq!( + environment.get(OsStr::new(MUTATION_SUBJECT_COMMIT)), + Some(&OsString::from("expected-commit")) + ); + assert_eq!( + environment.get(OsStr::new(MUTATION_SUBJECT_TREE)), + Some(&OsString::from("expected-tree")) + ); + Ok(()) + } + + /// Planted reversal: workflow text cannot add a second campaign grammar. + #[test] + fn campaign_arguments_are_not_a_pass_through() { + assert!( + require_no_arguments([String::from("--baseline=skip")].into_iter()) + .is_err_and(|reason| reason.contains("accepts no arguments")) + ); + } +} diff --git a/xtask/src/mutation_report.rs b/xtask/src/mutation_report.rs index 82b7837..d632482 100644 --- a/xtask/src/mutation_report.rs +++ b/xtask/src/mutation_report.rs @@ -782,6 +782,21 @@ mod tests { inspection.finish() } + /// Positive control for cargo-mutants' zero exit: a finalized campaign in + /// which every candidate was caught is accepted rather than mistaken for a + /// report/exit disagreement. + #[test] + fn an_all_caught_run_with_exit_zero_is_accepted() -> Result<(), String> { + let all_caught = FixtureReport { + total: 2, + caught: vec!["caught one", "caught two"], + ..FixtureReport::reversal() + }; + let (_fixture, inspection) = inspected("all-caught-run", ReportMode::Run, 0, &all_caught)?; + assert_eq!(inspection.disposition, ReportDisposition::Accepted); + inspection.finish() + } + #[test] fn a_missing_baseline_refuses() -> Result<(), String> { let fixture = Fixture::new("missing-baseline")?; @@ -885,6 +900,44 @@ mod tests { inspection.finish() } + /// Planted reversal: producer failure alone invalidates an otherwise-empty + /// reversal report. + #[test] + fn a_nonzero_reversal_exit_refuses_independently() -> Result<(), String> { + let fixture = Fixture::new("reversal-nonzero-exit")?; + fixture.write_report(&FixtureReport::reversal())?; + fs::remove_file(fixture.root.join("outcomes.json")) + .map_err(|source| format!("cannot remove fixture outcome: {source}"))?; + assert!(refusal(inspect(ReportMode::Reversal, 2, &fixture.root))?.contains("exited 2")); + Ok(()) + } + + /// Planted reversal: one generated candidate invalidates the reversal even + /// when the producer exit and every outcome roster remain empty. + #[test] + fn a_nonempty_reversal_inventory_refuses_independently() -> Result<(), String> { + let fixture = Fixture::new("reversal-nonempty-inventory")?; + fixture.write_report(&FixtureReport::reversal())?; + fs::remove_file(fixture.root.join("outcomes.json")) + .map_err(|source| format!("cannot remove fixture outcome: {source}"))?; + fixture.write("mutants.json", br#"[{"name":"unexpected mutant"}]"#)?; + assert!(refusal(inspect(ReportMode::Reversal, 0, &fixture.root))?.contains("listed=1")); + Ok(()) + } + + /// Planted reversal: one classified record invalidates the reversal even + /// when the producer exit and generated-candidate inventory remain empty. + #[test] + fn a_nonempty_reversal_roster_refuses_independently() -> Result<(), String> { + let fixture = Fixture::new("reversal-nonempty-roster")?; + fixture.write_report(&FixtureReport::reversal())?; + fs::remove_file(fixture.root.join("outcomes.json")) + .map_err(|source| format!("cannot remove fixture outcome: {source}"))?; + fixture.write("caught.txt", b"unexpected caught mutant")?; + assert!(refusal(inspect(ReportMode::Reversal, 0, &fixture.root))?.contains("rostered=1")); + Ok(()) + } + #[test] fn an_unterminated_final_record_is_counted() -> Result<(), String> { let (fixture, inspection) = inspected( @@ -1122,6 +1175,11 @@ mod tests { fn report_bytes_are_bounded_and_strict_utf8() -> Result<(), String> { let fixture = Fixture::new("bounded-utf8")?; fixture.write("tiny", b"four")?; + assert_eq!( + read_utf8_bounded(&fixture.root.join("tiny"), 4)?, + "four", + "an artifact exactly at its admitted byte limit must remain readable" + ); assert!(read_utf8_bounded(&fixture.root.join("tiny"), 3).is_err()); fixture.write("tiny", [0xff_u8])?; assert!(read_utf8_bounded(&fixture.root.join("tiny"), 3).is_err()); diff --git a/xtask/src/repository/snapshot.rs b/xtask/src/repository/snapshot.rs index 84dbc39..95cd353 100644 --- a/xtask/src/repository/snapshot.rs +++ b/xtask/src/repository/snapshot.rs @@ -557,6 +557,18 @@ pub(crate) struct CommittedTree { tree: TreeId, } +impl CommittedTree { + /// The exact commit identity carried by this aggregate fact. + pub(crate) fn commit(&self) -> &str { + &self.commit.0 + } + + /// The exact tree identity carried by this aggregate fact. + pub(crate) fn tree(&self) -> &str { + &self.tree.0 + } +} + impl fmt::Display for CommittedTree { fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { write!(out, "commit {} (tree {})", self.commit, self.tree) @@ -738,6 +750,122 @@ pub(crate) fn repo_root() -> Result> { Ok(parent.to_path_buf()) } +/// The explicit clean repository subject supplied to tests compiled inside a +/// cargo-mutants scratch copy. +/// +/// cargo-mutants deliberately compiles changed code outside the Git checkout. +/// Repository-law tests still need one committed repository to judge, but that +/// subject must not be inferred from the scratch process's current directory or +/// from a copied `.git` directory whose tracked bytes the mutant changed. The +/// mutation workflow therefore supplies this all-or-none basis triple. It is a +/// `cfg(test)` operational input only: production root discovery remains the +/// compile-time [`repo_root`] above. +#[cfg(test)] +struct MutationSubject { + root: PathBuf, + expected_commit: String, + expected_tree: String, +} + +pub(crate) const MUTATION_SUBJECT_ROOT: &str = "THREADPAK_MUTATION_SUBJECT_ROOT"; + +pub(crate) const MUTATION_SUBJECT_COMMIT: &str = "THREADPAK_MUTATION_SUBJECT_COMMIT"; + +pub(crate) const MUTATION_SUBJECT_TREE: &str = "THREADPAK_MUTATION_SUBJECT_TREE"; + +/// Which repository the test-only real-tree population reads. +/// +/// Absence preserves ordinary test behavior exactly. A mutation subject is +/// admitted only when its absolute coordinate and both expected Git identities +/// arrive together; the snapshot later proves those expectations against the +/// commit and tree it actually read. +#[cfg(test)] +enum TestRepositorySubject { + Ordinary(PathBuf), + Mutation(MutationSubject), +} + +#[cfg(test)] +impl TestRepositorySubject { + fn read() -> Result { + let root = std::env::var_os(MUTATION_SUBJECT_ROOT); + let commit = std::env::var_os(MUTATION_SUBJECT_COMMIT); + let tree = std::env::var_os(MUTATION_SUBJECT_TREE); + match (root, commit, tree) { + (None, None, None) => repo_root() + .map(Self::Ordinary) + .map_err(|error| error.to_string()), + (Some(root), Some(commit), Some(tree)) => { + let root = PathBuf::from(root); + if !root.is_absolute() { + return Err(format!( + "{MUTATION_SUBJECT_ROOT} must name an absolute repository root; got {}", + root.display() + )); + } + Ok(Self::Mutation(MutationSubject { + root, + expected_commit: mutation_subject_identity(MUTATION_SUBJECT_COMMIT, commit)?, + expected_tree: mutation_subject_identity(MUTATION_SUBJECT_TREE, tree)?, + })) + } + _ => Err(format!( + "{MUTATION_SUBJECT_ROOT}, {MUTATION_SUBJECT_COMMIT}, and {MUTATION_SUBJECT_TREE} must be supplied together or all remain absent" + )), + } + } + + fn root(&self) -> &Path { + match self { + Self::Ordinary(root) => root, + Self::Mutation(subject) => &subject.root, + } + } + + fn require_expected_basis(&self, actual: &CommittedTree) -> Result<(), String> { + let Self::Mutation(expected) = self else { + return Ok(()); + }; + if actual.commit() == expected.expected_commit && actual.tree() == expected.expected_tree { + return Ok(()); + } + Err(format!( + "mutation subject {} was expected at commit {} (tree {}) but read {actual}", + expected.root.display(), + expected.expected_commit, + expected.expected_tree + )) + } +} + +/// Reads the mutation subject basis once for the whole test process. +/// +/// Environment is process-global. Caching before any thread-local snapshot is +/// built means two test threads cannot observe different subject triples even +/// if an external harness changes its environment incorrectly while they run. +#[cfg(test)] +fn test_repository_subject() -> Result<&'static TestRepositorySubject, String> { + use std::sync::OnceLock; + + static SUBJECT: OnceLock> = OnceLock::new(); + match SUBJECT.get_or_init(TestRepositorySubject::read) { + Ok(subject) => Ok(subject), + Err(refusal) => Err(refusal.clone()), + } +} + +#[cfg(test)] +fn mutation_subject_identity(role: &str, value: OsString) -> Result { + let identity = value + .into_string() + .map_err(|_| format!("{role} is not Unicode"))?; + if identity.is_empty() { + Err(format!("{role} is empty")) + } else { + Ok(identity) + } +} + /// The cargo binary a spawned stage or reading is given. /// /// Cargo sets `CARGO` for every process it starts, so a nested invocation @@ -770,9 +898,10 @@ pub(crate) fn repository_snapshot() -> Result<&'static RepositorySnapshot, Strin if let Some(already) = *held.borrow() { return Ok(already); } - let root = repo_root().map_err(|error| error.to_string())?; - let built: &'static RepositorySnapshot = - Box::leak(Box::new(RepositorySnapshot::read(&root)?)); + let subject = test_repository_subject()?; + let built = RepositorySnapshot::read(subject.root())?; + subject.require_expected_basis(built.committed())?; + let built: &'static RepositorySnapshot = Box::leak(Box::new(built)); *held.borrow_mut() = Some(built); Ok(built) }) @@ -788,10 +917,38 @@ mod tests { use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; - use super::{GIT_STORAGE, RepositorySnapshot, parse_tracked_blobs}; + use super::{ + GIT_STORAGE, MUTATION_SUBJECT_COMMIT, MUTATION_SUBJECT_ROOT, MUTATION_SUBJECT_TREE, + RepositorySnapshot, TestRepositorySubject, parse_tracked_blobs, + }; use crate::checks::hygiene::check_lf_and_no_symlinks; use crate::repository::types::{LinkState, Read}; + /// One isolated directory carrying no version-control storage. + struct PlainDirectory { + root: PathBuf, + } + + impl PlainDirectory { + fn named(name: &str) -> Result { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let ordinal = NEXT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "threadpak-plain-directory-{}-{ordinal}-{name}", + std::process::id() + )); + let _removed = fs::remove_dir_all(&root); + fs::create_dir_all(&root).map_err(|error| format!("{}: {error}", root.display()))?; + Ok(Self { root }) + } + } + + impl Drop for PlainDirectory { + fn drop(&mut self) { + let _removed = fs::remove_dir_all(&self.root); + } + } + /// One isolated Git repository for a committed-snapshot control. struct GitFixture { root: PathBuf, @@ -950,6 +1107,217 @@ mod tests { .collect() } + /// Selects one child-only mutation-subject control without mutating the + /// parent test process's environment. + const MUTATION_SUBJECT_CHILD_CASE: &str = "THREADPAK_MUTATION_SUBJECT_CHILD_CASE"; + + fn mutation_subject_child(case: &str, current_dir: &Path) -> Result { + let child = std::env::current_exe() + .map_err(|error| format!("current xtask test executable: {error}"))?; + let mut command = Command::new(child); + command + .arg("mutation_subject_basis_is_explicit_and_bound") + .arg("--test-threads=1") + .current_dir(current_dir) + .env(MUTATION_SUBJECT_CHILD_CASE, case) + .env_remove(MUTATION_SUBJECT_ROOT) + .env_remove(MUTATION_SUBJECT_COMMIT) + .env_remove(MUTATION_SUBJECT_TREE) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) + } + + fn require_subject_child(mut command: Command, case: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("mutation-subject {case} child test: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + if output.status.success() && stdout.contains("1 passed") { + Ok(()) + } else { + Err(format!( + "mutation-subject {case} child did not establish its claim:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + )) + } + } + + fn clean_subject_child() -> Result<(), String> { + let current = std::env::current_dir() + .map_err(|error| format!("mutation child current directory: {error}"))?; + let discovery = super::git(¤t) + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("detached-directory Git probe: {error}"))?; + assert!( + !discovery.status.success(), + "the clean-subject child current directory unexpectedly discovered Git" + ); + let _snapshot = super::repository_snapshot()?; + Ok(()) + } + + fn require_snapshot_refusal(description: &str, fragments: &[&str]) -> Result<(), String> { + let refusal = super::repository_snapshot() + .err() + .ok_or_else(|| format!("{description} was accepted"))?; + for fragment in fragments { + assert!(refusal.contains(fragment), "{refusal}"); + } + Ok(()) + } + + fn require_subject_selection_refusal(description: &str, fragment: &str) -> Result<(), String> { + let refusal = super::test_repository_subject() + .err() + .ok_or_else(|| format!("{description} was accepted"))?; + assert!(refusal.contains(fragment), "{refusal}"); + Ok(()) + } + + fn ordinary_subject_child() -> Result<(), String> { + match super::test_repository_subject()? { + TestRepositorySubject::Ordinary(root) => { + let ordinary = super::repo_root().map_err(|error| error.to_string())?; + assert_eq!(root, &ordinary); + Ok(()) + } + TestRepositorySubject::Mutation(_) => Err(String::from( + "absent mutation subject inputs selected the mutation road", + )), + } + } + + fn execute_mutation_subject_child(case: &str) -> Result<(), String> { + match case { + "clean" => clean_subject_child(), + "non-git" => { + require_snapshot_refusal("a non-Git mutation subject", &["is not a Git checkout"]) + } + "dirty" => require_snapshot_refusal( + "a dirty mutation subject", + &["tracked.txt", "differs from the bytes"], + ), + "partial" => require_subject_selection_refusal( + "a partial mutation subject", + "must be supplied together", + ), + "relative" => require_subject_selection_refusal( + "a relative mutation subject", + "must name an absolute", + ), + "empty-commit" => require_subject_selection_refusal( + "an empty expected mutation commit", + "THREADPAK_MUTATION_SUBJECT_COMMIT is empty", + ), + "empty-tree" => require_subject_selection_refusal( + "an empty expected mutation tree", + "THREADPAK_MUTATION_SUBJECT_TREE is empty", + ), + "wrong-commit" => require_snapshot_refusal( + "a wrong expected commit", + &["was expected at commit wrong-commit"], + ), + "wrong-tree" => require_snapshot_refusal("a wrong expected tree", &["tree wrong-tree"]), + "absent" => ordinary_subject_child(), + other => Err(format!("unknown mutation-subject child case {other}")), + } + } + + /// The mutation-only subject is an all-or-none exact basis, and it is the + /// sole exception to ordinary tests reading their compile-time repository + /// root. Every environment variant runs in a child so parallel tests never + /// observe a process-global input changing underneath them. + #[test] + fn mutation_subject_basis_is_explicit_and_bound() -> Result<(), String> { + if let Ok(case) = std::env::var(MUTATION_SUBJECT_CHILD_CASE) { + return execute_mutation_subject_child(&case); + } + + let clean = GitFixture::named("mutation-subject-clean")?; + clean.write("tracked.txt", b"committed mutation subject\n")?; + clean.commit()?; + let clean_basis = super::committed_tree(&clean.root)?; + let clean_commit = clean_basis.commit.to_string(); + let clean_tree = clean_basis.tree.to_string(); + + let dirty = GitFixture::named("mutation-subject-dirty")?; + dirty.write("tracked.txt", b"committed\n")?; + dirty.commit()?; + let dirty_basis = super::committed_tree(&dirty.root)?; + let dirty_commit = dirty_basis.commit.to_string(); + let dirty_tree = dirty_basis.tree.to_string(); + dirty.write("tracked.txt", b"dirty\n")?; + + let detached = PlainDirectory::named("mutation-subject-current-directory")?; + let non_git = PlainDirectory::named("mutation-subject-non-git")?; + + let mut clean_child = mutation_subject_child("clean", &detached.root)?; + clean_child + .env(MUTATION_SUBJECT_ROOT, &clean.root) + .env(MUTATION_SUBJECT_COMMIT, &clean_commit) + .env(MUTATION_SUBJECT_TREE, &clean_tree); + require_subject_child(clean_child, "clean")?; + + let mut non_git_child = mutation_subject_child("non-git", &detached.root)?; + non_git_child + .env(MUTATION_SUBJECT_ROOT, &non_git.root) + .env(MUTATION_SUBJECT_COMMIT, &clean_commit) + .env(MUTATION_SUBJECT_TREE, &clean_tree); + require_subject_child(non_git_child, "non-git")?; + + let mut dirty_child = mutation_subject_child("dirty", &detached.root)?; + dirty_child + .env(MUTATION_SUBJECT_ROOT, &dirty.root) + .env(MUTATION_SUBJECT_COMMIT, &dirty_commit) + .env(MUTATION_SUBJECT_TREE, &dirty_tree); + require_subject_child(dirty_child, "dirty")?; + + let mut partial_child = mutation_subject_child("partial", &detached.root)?; + partial_child.env(MUTATION_SUBJECT_ROOT, &clean.root); + require_subject_child(partial_child, "partial")?; + + let mut relative_child = mutation_subject_child("relative", &detached.root)?; + relative_child + .env(MUTATION_SUBJECT_ROOT, "relative-subject") + .env(MUTATION_SUBJECT_COMMIT, &clean_commit) + .env(MUTATION_SUBJECT_TREE, &clean_tree); + require_subject_child(relative_child, "relative")?; + + let mut empty_commit_child = mutation_subject_child("empty-commit", &detached.root)?; + empty_commit_child + .env(MUTATION_SUBJECT_ROOT, &clean.root) + .env(MUTATION_SUBJECT_COMMIT, "") + .env(MUTATION_SUBJECT_TREE, &clean_tree); + require_subject_child(empty_commit_child, "empty-commit")?; + + let mut empty_tree_child = mutation_subject_child("empty-tree", &detached.root)?; + empty_tree_child + .env(MUTATION_SUBJECT_ROOT, &clean.root) + .env(MUTATION_SUBJECT_COMMIT, &clean_commit) + .env(MUTATION_SUBJECT_TREE, ""); + require_subject_child(empty_tree_child, "empty-tree")?; + + let mut wrong_commit_child = mutation_subject_child("wrong-commit", &detached.root)?; + wrong_commit_child + .env(MUTATION_SUBJECT_ROOT, &clean.root) + .env(MUTATION_SUBJECT_COMMIT, "wrong-commit") + .env(MUTATION_SUBJECT_TREE, &clean_tree); + require_subject_child(wrong_commit_child, "wrong-commit")?; + + let mut wrong_tree_child = mutation_subject_child("wrong-tree", &detached.root)?; + wrong_tree_child + .env(MUTATION_SUBJECT_ROOT, &clean.root) + .env(MUTATION_SUBJECT_COMMIT, &clean_commit) + .env(MUTATION_SUBJECT_TREE, "wrong-tree"); + require_subject_child(wrong_tree_child, "wrong-tree")?; + + require_subject_child(mutation_subject_child("absent", &detached.root)?, "absent") + } + /// Positive control: committed files are read from their Git blobs. #[test] fn a_clean_committed_tree_is_read_from_git() -> Result<(), String> { From 8234bc94745c6e7165fde23a3b380a5259748afe Mon Sep 17 00:00:00 2001 From: Heyoub Date: Sat, 15 Aug 2026 21:12:55 -0400 Subject: [PATCH 2/2] Clarify mutation invocation authority --- .cargo/mutants.toml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 9ff7c2f..4ae6deb 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -58,14 +58,17 @@ # # THE ONE THING THIS LINE CANNOT SAY is which PACKAGES a run examines — the # configuration has no key for it. `cargo xtask mutation-campaign` owns the one -# admitted invocation and supplies `--workspace`; it accepts no pass-through -# arguments. MEASURED: a raw `cargo mutants` with no package selection takes the -# root package alone, this glob matches nothing inside it, and the run finds no -# mutant and exits 0. It does say so, on a WARN line — and a warning that fails -# nothing is what a job reads as success, which is the whole difference between -# a diagnostic and a refusal. The fixed launcher also binds scratch-compiled -# tests to the exact clean commit and tree they judge, so neither workflow shell -# nor cargo-mutants' current directory becomes repository identity. +# admitted positive campaign invocation and supplies `--workspace`; it accepts +# no pass-through arguments. The sole raw invocation is the workflow's planted +# empty-scope reversal, whose deliberately excluded source population challenges +# the report guard rather than producing campaign evidence. MEASURED: a raw +# `cargo mutants` with no package selection takes the root package alone, this +# glob matches nothing inside it, and the run finds no mutant and exits 0. It +# does say so, on a WARN line — and a warning that fails nothing is what a job +# reads as success, which is the whole difference between a diagnostic and a +# refusal. The fixed launcher also binds scratch-compiled tests to the exact +# clean commit and tree they judge, so neither workflow shell nor cargo-mutants' +# current directory becomes repository identity. examine_globs = ["xtask/**/*.rs"] # CHOSEN: the changed code is compiled in cargo-mutants' VCS-free scratch copy.