Skip to content

Commit 0ae1bd7

Browse files
Martin TailleferCopilot
andcommitted
fix(cargo-gamma): stabilize builds and baseline diagnostics
- Keep whole-workspace Cargo roots stable across staged convergence while physically withdrawing deferred-stage mutants. This preserves dependency feature unification and prevents unrelated mutants from affecting each stage. - Bound Cargo output safely without reserving the full ceiling, and normalize leading ANSI erase sequences so redirected progress remains readable. - Remove the noisy cgroup-containment warning while retaining best-effort process-group cleanup on hosts without delegated cgroups. - Make baseline failures self-diagnosing. Record termination, timing, memory, package, target, runner, working directory, and bounded control-encoded stdout/stderr tails in baseline-failure.json. - Write baseline-failure.json and gamma-diagnostics.json before early cleanup, and point the console diagnostic at both files instead of replaying captured test output. Artifact failures remain secondary and inherited environment values stay private. - Restrict failure-output retention and complete harness draining to baseline measurement, leaving successful observations and mutation/confirmation hot paths unchanged. - Preserve an existing `cargo-gamma-rt` dependency's `features`/`default-features` when redirecting it to the vendored runtime, for both direct and workspace-inherited dependencies. - Write vendored runtime dependency paths with portable (`/`) separators so the rewritten manifest parses as valid TOML on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6a1863fa-92ca-4fd6-9e0c-12ae4ac61826
1 parent 7a5daf3 commit 0ae1bd7

25 files changed

Lines changed: 1216 additions & 234 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ bytes = { version = "1.12.1", default-features = false }
3434
camino = { version = "1.2.5", default-features = false }
3535
# local dependencies
3636
cargo-aprz-lib = { path = "crates/cargo-aprz-lib", default-features = false, version = "1.1.2" }
37-
cargo-gamma-attrs-impl = { path = "crates/cargo-gamma-attrs-impl", default-features = false, version = "0.2.0" }
38-
cargo-gamma-engine = { path = "crates/cargo-gamma-engine", default-features = false, version = "0.2.0" }
39-
cargo-gamma-lib = { path = "crates/cargo-gamma-lib", default-features = false, version = "0.2.0" }
40-
cargo-gamma-process = { path = "crates/cargo-gamma-process", default-features = false, version = "0.2.0" }
41-
cargo-gamma-rt = { path = "crates/cargo-gamma-rt", default-features = false, version = "0.2.0" }
42-
cargo-gamma-unsafe = { path = "crates/cargo-gamma-unsafe", default-features = false, version = "0.2.0" }
37+
cargo-gamma-attrs-impl = { path = "crates/cargo-gamma-attrs-impl", default-features = false, version = "0.2.1" }
38+
cargo-gamma-engine = { path = "crates/cargo-gamma-engine", default-features = false, version = "0.2.1" }
39+
cargo-gamma-lib = { path = "crates/cargo-gamma-lib", default-features = false, version = "0.2.1" }
40+
cargo-gamma-process = { path = "crates/cargo-gamma-process", default-features = false, version = "0.2.1" }
41+
cargo-gamma-rt = { path = "crates/cargo-gamma-rt", default-features = false, version = "0.2.1" }
42+
cargo-gamma-unsafe = { path = "crates/cargo-gamma-unsafe", default-features = false, version = "0.2.1" }
4343
cargo-heather = { path = "crates/cargo-heather", default-features = false, version = "0.2.1" }
4444
# external dependencies
4545
cargo-platform = { version = "0.3.3", default-features = false }

crates/cargo-gamma-attrs-impl/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[package]
55
name = "cargo-gamma-attrs-impl"
66
description = "Implementation of the inert attribute macros exposed by cargo-gamma-attrs"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
readme = "README.md"
99
keywords = ["cargo", "mutation-testing", "testing"]
1010
categories = ["development-tools::testing"]

crates/cargo-gamma-attrs/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[package]
55
name = "cargo-gamma-attrs"
66
description = "Inert attribute macros for suppressing cargo-gamma mutations"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
readme = "README.md"
99
keywords = ["cargo", "mutation-testing", "testing"]
1010
categories = ["development-tools::testing"]

crates/cargo-gamma-engine/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[package]
55
name = "cargo-gamma-engine"
66
description = "Internal Rust source mutation engine for cargo-gamma"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
readme = "README.md"
99
keywords = ["cargo", "mutation-testing", "testing"]
1010
categories = ["development-tools::testing"]

crates/cargo-gamma-lib/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[package]
55
name = "cargo-gamma-lib"
66
description = "Internal library for cargo-gamma"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
readme = "README.md"
99
keywords = ["cargo", "mutation-testing", "testing"]
1010
categories = ["development-tools::testing"]

crates/cargo-gamma-lib/docs/design/README.md renamed to crates/cargo-gamma-lib/docs/DESIGN.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,38 @@ includes both failure to acquire the startup environment and a guard reached
6161
before the runtime constructor installed its selection; either fixed marker
6262
disqualifies the process as mutation-score evidence.
6363

64+
Interactive build progress reuses Cargo's progress text while cargo-gamma owns
65+
the terminal redraw. Cargo's leading erase control is consumed rather than
66+
rendered visibly, and its color styling remains intact.
67+
68+
Each Cargo output stream is retained up to 256 MiB for artifact and diagnostic
69+
processing, and each logical line is bounded at 1 MiB. The buffers grow with
70+
observed output rather than reserving those ceilings for every invocation.
71+
72+
Each failed baseline observation retains a bounded 64 KiB, 200-line tail from
73+
stdout and stderr, along with the process exit code or signal when available.
74+
The baseline error identifies the package, target, runner, executable, working
75+
directory, elapsed time, resource failure, and failing or last-observed test.
76+
It points to the generated diagnostics instead of printing captured test output
77+
to the console. Successful observations retain none of this output. Before
78+
early failure cleanup, the command writes the structured record, including
79+
safely encoded output tails, to `baseline-failure.json` and writes the ordinary
80+
`gamma-diagnostics.json` bundle; only environment values cargo-gamma explicitly
81+
controls are eligible for diagnostic records, never the inherited process
82+
environment.
83+
84+
When Cargo's resolved package selection covers the whole workspace, every stage
85+
checks mutation viability with that constant Cargo root set. Packages with no
86+
mutable files may be omitted from preflight, but do not narrow the staged
87+
checks. This keeps dependency feature unification identical across stages
88+
instead of compiling a new dependency variant for each downstream package
89+
selection. Only the current stage's mutants are instrumented; mutants belonging
90+
to other stages are restored before each ordinary, probe, or isolation build.
91+
Diagnostic blame, isolation, and withdrawal therefore remain limited to the
92+
current stage even though Cargo checks the wider graph. The final test-target
93+
build retains its reachability-based package selection; runs whose original
94+
Cargo selection is a package subset retain their narrowed graph throughout.
95+
6496
Diff paths are resolved to the workspace-relative Rust files discovered by the
6597
survey. Absolute or rooted paths inside the workspace are normalized to those
6698
candidates; one from another checkout is normalized only when its suffix

crates/cargo-gamma-lib/src/commands/run.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::discover::Plan;
1919
use crate::error::{Error, error};
2020
use crate::exec;
2121
use crate::model::{Mutant, Outcome};
22-
use crate::report::{Listings, Progress, Styler, quantity};
22+
use crate::report::{Listings, Progress, Styler, encode_controls, quantity};
2323
#[cfg(any(test, feature = "internals"))]
2424
use crate::testing::pause_after_cache_adoption;
2525

@@ -110,7 +110,7 @@ fn run_info(args: &RunArgs, tests: Option<usize>, dropped: &[String]) -> crate::
110110

111111
/// Where a run's documents go, once the defaults and any overrides have been settled.
112112
///
113-
/// Every run writes all five — HTML, JSON and SARIF reports, Markdown advice and a diagnostics bundle.
113+
/// Every completed run writes all five — HTML, JSON and SARIF reports, Markdown advice and a diagnostics bundle.
114114
/// The reason they are produced by default rather than on request is
115115
/// that the flags were a trap: a run that takes an hour and answers a question about your test
116116
/// suite is exactly the run you do not want to repeat because you forgot to ask for the artifact
@@ -806,7 +806,7 @@ fn measured<H: Host>(host: &mut H, args: &RunArgs, progress_when: When, styler:
806806
if let Some(lock_identity) = cache_lock_identity {
807807
pause_after_cache_adoption(&survey.root, lock_identity);
808808
}
809-
let outcome = exec::run_with_locks(&survey, &selection, &config, &mut events, cache_locks);
809+
let mut outcome = exec::run_with_locks(&survey, &selection, &config, &mut events, cache_locks);
810810

811811
// A phase that failed never got to say what it found, so the line it opened is still waiting
812812
// for an ending. Close it before the error is printed, or the error arrives as the rest of
@@ -817,6 +817,10 @@ fn measured<H: Host>(host: &mut H, args: &RunArgs, progress_when: When, styler:
817817

818818
let log_result = events.finish_verdict_log();
819819

820+
if let Err(failure) = &mut outcome {
821+
emit_failure_artifacts(&mut events, args, &survey.skeleton(), failure, &artifact_dir, started, styler);
822+
}
823+
820824
let exec::Measured {
821825
plan,
822826
built,
@@ -928,6 +932,66 @@ fn measured<H: Host>(host: &mut H, args: &RunArgs, progress_when: When, styler:
928932
Ok(Executed { plan: Some(plan), stuck })
929933
}
930934

935+
fn emit_failure_artifacts<H: Host>(
936+
events: &mut ConsoleEvents<'_, H>,
937+
args: &RunArgs,
938+
plan: &Plan,
939+
failure: &mut Error,
940+
artifact_dir: &Utf8Path,
941+
started: Instant,
942+
styler: Styler,
943+
) {
944+
let Some(artifact) = failure.artifact() else {
945+
return;
946+
};
947+
948+
let path = artifact_dir.join(artifact.file_name);
949+
let diagnostics = Documents::resolve(args, &plan.root).diag;
950+
let written = serde_json::to_string_pretty(&artifact.value)
951+
.map_err(|cause| error!("could not encode `{path}`").caused_by(cause))
952+
.and_then(|contents| crate::elements::write(&path, &contents));
953+
954+
let baseline_written = match written {
955+
Ok(()) => {
956+
let _announced = writeln!(events.host.error(), "{} {path}", styler.verb("Wrote"));
957+
true
958+
}
959+
Err(artifact_failure) => {
960+
crate::exec::Events::warn(
961+
events,
962+
&format!("could not preserve the baseline failure record: {artifact_failure}"),
963+
);
964+
false
965+
}
966+
};
967+
968+
let diagnostics_written = match emit_diag(events.host, args, plan, None, started, styler) {
969+
Ok(()) => true,
970+
Err(diag_failure) => {
971+
crate::exec::Events::warn(
972+
events,
973+
&format!("could not preserve the diagnostics bundle after the baseline failure: {diag_failure}"),
974+
);
975+
false
976+
}
977+
};
978+
979+
match (baseline_written, diagnostics_written) {
980+
(true, true) => failure.append_message(&format!(
981+
"\nDiagnostics: {}\n {}",
982+
encode_controls(path.as_str()),
983+
encode_controls(diagnostics.as_str()),
984+
)),
985+
(true, false) => {
986+
failure.append_message(&format!("\nDiagnostics: {}", encode_controls(path.as_str())));
987+
}
988+
(false, true) => {
989+
failure.append_message(&format!("\nDiagnostics: {}", encode_controls(diagnostics.as_str())));
990+
}
991+
(false, false) => {}
992+
}
993+
}
994+
931995
fn warn_auxiliary<H: Host>(host: &mut H, failure: Option<&Error>, styler: Styler) {
932996
if let Some(failure) = failure {
933997
let _ = writeln!(host.error(), "{} {failure}", styler.warning());
@@ -1739,6 +1803,45 @@ mod tests {
17391803
assert!(String::from_utf8(host.err).expect("utf-8").contains(path.as_str()));
17401804
}
17411805

1806+
#[test]
1807+
fn a_baseline_error_writes_its_record_and_the_diagnostics_bundle() {
1808+
let dir = workdir("run-baseline-failure-artifacts-");
1809+
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf8");
1810+
let args = RunArgs {
1811+
artifact_dir: Some(root.clone()),
1812+
..Default::default()
1813+
};
1814+
let plan = plan();
1815+
let mut failure = Error::new("baseline failed").with_artifact(
1816+
"baseline-failure.json",
1817+
serde_json::json!({
1818+
"schemaVersion": 1,
1819+
"kind": "testFailure",
1820+
"stderrTail": "assertion failed",
1821+
}),
1822+
);
1823+
let mut host = Sink::default();
1824+
let mut events = ConsoleEvents {
1825+
host: &mut host,
1826+
progress: Progress::new(false, Styler::new(false), Some(80)),
1827+
styler: Styler::new(false),
1828+
estimate: false,
1829+
show_build: false,
1830+
verdict_log: VerdictLog::default(),
1831+
};
1832+
1833+
emit_failure_artifacts(&mut events, &args, &plan, &mut failure, &root, Instant::now(), Styler::new(false));
1834+
1835+
let baseline = fs::read_to_string(root.join("baseline-failure.json")).expect("baseline record");
1836+
let diagnostics = fs::read_to_string(root.join("gamma-diagnostics.json")).expect("diagnostics bundle");
1837+
1838+
assert!(baseline.contains("assertion failed"), "{baseline}");
1839+
assert!(diagnostics.contains("\"schemaVersion\": \"3\""), "{diagnostics}");
1840+
assert!(failure.to_string().contains("Diagnostics:"), "{failure}");
1841+
assert!(failure.to_string().contains("baseline-failure.json"), "{failure}");
1842+
assert!(failure.to_string().contains("gamma-diagnostics.json"), "{failure}");
1843+
}
1844+
17421845
/// A run that censused says so in its bundle: the phase carries its own elapsed time, the tests
17431846
/// it walked, and the binaries it examined, so the census's cost is legible on its own rather
17441847
/// than folded inside the build's total.

crates/cargo-gamma-lib/src/error.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ use std::backtrace::Backtrace;
99
use std::io;
1010

1111
use cargo_gamma_engine::{Error as EngineError, Parts as EngineParts};
12+
use serde_json::Value;
13+
14+
/// A machine-readable diagnostic that should be persisted before an error unwinds its run state.
15+
#[derive(Debug)]
16+
pub(crate) struct Artifact {
17+
pub(crate) file_name: &'static str,
18+
pub(crate) value: Value,
19+
}
1220

1321
/// An error carrying a human-readable message and an optional cause.
1422
///
@@ -20,6 +28,7 @@ pub struct Error {
2028
cause: Option<Box<dyn StdError + Send + Sync>>,
2129
usage: bool,
2230
skippable: bool,
31+
artifact: Option<Box<Artifact>>,
2332

2433
/// Captured at construction, unconditionally.
2534
///
@@ -39,6 +48,7 @@ impl Error {
3948
cause: None,
4049
usage: false,
4150
skippable: false,
51+
artifact: None,
4252
backtrace: Backtrace::capture(),
4353
}
4454
}
@@ -81,6 +91,23 @@ impl Error {
8191
self.skippable
8292
}
8393

94+
/// Attaches a machine-readable diagnostic for the command layer to persist.
95+
#[must_use]
96+
pub(crate) fn with_artifact(mut self, file_name: &'static str, value: Value) -> Self {
97+
self.artifact = Some(Box::new(Artifact { file_name, value }));
98+
self
99+
}
100+
101+
/// Returns the diagnostic attached to this failure, when it has one.
102+
pub(crate) fn artifact(&self) -> Option<&Artifact> {
103+
self.artifact.as_deref()
104+
}
105+
106+
/// Appends command-layer context that was not available where the error originated.
107+
pub(crate) fn append_message(&mut self, suffix: &str) {
108+
self.message.push_str(suffix);
109+
}
110+
84111
/// Attaches an underlying cause.
85112
#[must_use]
86113
pub fn caused_by(mut self, cause: impl StdError + Send + Sync + 'static) -> Self {
@@ -148,6 +175,7 @@ impl From<EngineError> for Error {
148175
cause,
149176
usage,
150177
skippable,
178+
artifact: None,
151179
backtrace,
152180
}
153181
}

0 commit comments

Comments
 (0)