From ce4c5bef984097957ba63e2745358822f5078ae0 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Fri, 24 Jul 2026 14:10:48 -0400 Subject: [PATCH 01/14] feat(mvn): add rtk mvnd support for Maven Daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3184. mvnd is a separate binary, not a mvn wrapper, so it gets its own `rtk mvnd` entry point rather than folding into `rtk mvn`: a user who typed `mvnd` must get the daemon, never `mvn` or a `./mvnw` wrapper that happens to sit in the working directory. Everything downstream is shared — phase detection and all four filters are reused unchanged. mvnd's rolling/parallel console UI only engages on a TTY, so the output rtk captures is plain Maven output; daemon status chatter carries no [INFO]/[ERROR] prefix and is already dropped by the existing keep-lists. - src/main.rs: `Commands::Mvnd` -> `mvn_cmd::run_daemon` - src/cmds/jvm/mvn_cmd.rs: thread a `daemon` flag through binary selection; `run`/`run_daemon` share `run_tool` - src/discover/rules.rs: rewrite rule so the hook maps `mvnd ` to `rtk mvnd ` (the `mvn\b` pattern never matched `mvnd`) Co-Authored-By: Claude Opus 5 --- src/cmds/jvm/README.md | 5 ++-- src/cmds/jvm/mvn_cmd.rs | 51 ++++++++++++++++++++++++++++++++-------- src/discover/registry.rs | 21 +++++++++++++++++ src/discover/rules.rs | 12 ++++++++++ src/main.rs | 11 +++++++++ 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 0fe2e0d40b..8ccfa8ac03 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -5,7 +5,7 @@ Filters for JVM-based build tools. | Module | Tool(s) | Modes | |------------------|--------------------------------------|----------------------------------------------------------------------------------------| | `gradlew_cmd.rs` | `./gradlew`, `gradlew.bat`, `gradle` | Build / Test / ConnectedTest / Lint / Dependencies — streaming line filter + passthrough | -| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase | +| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd`, `mvnd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase | ## Maven (`mvn_cmd.rs`) @@ -28,6 +28,7 @@ Key behaviours: - **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). - **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. +- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. Its rolling/parallel console UI only engages on a TTY, so the captured output rtk filters is plain Maven output. Daemon status chatter (`Connecting to daemon`, …) carries no `[INFO]`/`[ERROR]` prefix and is dropped by the existing keep-lists. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). - **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. @@ -35,7 +36,7 @@ Token-savings tests run inline as part of `cargo test --all` and verify ≥90% s ### Integrity-check whitelist -`Commands::Mvn` is intentionally omitted from `is_operational_command` in `src/main.rs`, matching the gradle precedent (`Commands::Gradlew` also omitted). The whitelist guards SHA-256 hook-integrity verification; filter modules invoked through an already-verified hook do not need a second check on their own dispatch path. Per the comment above the function, the whitelist is opt-in by design and a forgotten command fails open rather than creating false confidence about what's protected. +`Commands::Mvn` and `Commands::Mvnd` are intentionally omitted from `is_operational_command` in `src/main.rs`, matching the gradle precedent (`Commands::Gradlew` also omitted). The whitelist guards SHA-256 hook-integrity verification; filter modules invoked through an already-verified hook do not need a second check on their own dispatch path. Per the comment above the function, the whitelist is opt-in by design and a forgotten command fails open rather than creating false confidence about what's protected. ## Gradle (`gradlew_cmd.rs`) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 3ec2c5e08c..c31f36e93a 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -871,8 +871,12 @@ pub fn filter_quiet(raw: &str) -> String { // ── Wrapper detection ─────────────────────────────────────────────────────── -fn mvn_binary() -> &'static str { - if cfg!(windows) { +/// Maven Daemon (`mvnd`) has no project-local wrapper of its own, so it is +/// never substituted by `./mvnw`: the user asked for the daemon explicitly. +fn mvn_binary(daemon: bool) -> &'static str { + if daemon { + "mvnd" + } else if cfg!(windows) { if Path::new(".\\mvnw.cmd").exists() { ".\\mvnw.cmd" } else { @@ -885,8 +889,10 @@ fn mvn_binary() -> &'static str { } } -fn new_mvn_command(args: &[String]) -> Command { - let mut cmd = if cfg!(windows) { +fn new_mvn_command(args: &[String], daemon: bool) -> Command { + let mut cmd = if daemon { + resolved_command("mvnd") + } else if cfg!(windows) { if Path::new(".\\mvnw.cmd").exists() { Command::new(".\\mvnw.cmd") } else { @@ -904,16 +910,27 @@ fn new_mvn_command(args: &[String]) -> Command { // ── Entry point ───────────────────────────────────────────────────────────── pub fn run(args: &[String], verbose: u8) -> Result { + run_tool(args, false, verbose) +} + +/// `rtk mvnd` — Maven Daemon. Non-interactive `mvnd` output is plain Maven +/// output (the rolling console UI only engages on a TTY), so the same phase +/// detection and filters apply; only the executed binary differs. +pub fn run_daemon(args: &[String], verbose: u8) -> Result { + run_tool(args, true, verbose) +} + +fn run_tool(args: &[String], daemon: bool, verbose: u8) -> Result { // Verbose flags bypass filtering — user wants full output. if args .iter() .any(|a| matches!(a.as_str(), "-X" | "--debug" | "-e" | "--errors")) { let osargs: Vec = args.iter().map(OsString::from).collect(); - return runner::run_passthrough(mvn_binary(), &osargs, verbose); + return runner::run_passthrough(mvn_binary(daemon), &osargs, verbose); } - let tool = mvn_binary(); + let tool = mvn_binary(daemon); let args_display = args.join(" "); // Quiet mode: standard footer guard can't fire (no `BUILD SUCCESS` line @@ -926,7 +943,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { return runner::run_passthrough(tool, &osargs, verbose); } return runner::run_filtered( - new_mvn_command(args), + new_mvn_command(args, daemon), tool, &args_display, filter_quiet, @@ -938,21 +955,21 @@ pub fn run(args: &[String], verbose: u8) -> Result { match phase { MvnPhase::Test => runner::run_filtered( - new_mvn_command(args), + new_mvn_command(args, daemon), tool, &args_display, filter_surefire, RunOptions::with_tee("mvn_test"), ), MvnPhase::Compile => runner::run_filtered( - new_mvn_command(args), + new_mvn_command(args, daemon), tool, &args_display, filter_compile, RunOptions::with_tee("mvn_compile"), ), MvnPhase::Package => runner::run_filtered( - new_mvn_command(args), + new_mvn_command(args, daemon), tool, &args_display, filter_package, @@ -1071,6 +1088,20 @@ mod tests { assert_eq!(detect_phase(&s(["--help"])), MvnPhase::Passthrough); } + // ── Binary selection ───────────────────────────────────────────────────── + + /// rtk-ai/rtk#3184 — the daemon is never swapped for `mvn`/`./mvnw`, + /// whatever wrapper happens to sit in the working directory. + #[test] + fn mvnd_binary_is_never_the_wrapper() { + assert_eq!(mvn_binary(true), "mvnd"); + } + + #[test] + fn mvn_binary_without_daemon_is_not_mvnd() { + assert_ne!(mvn_binary(false), "mvnd"); + } + // ── Surefire filter ────────────────────────────────────────────────────── #[test] diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7f39899ee4..b43bcf6109 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3862,6 +3862,27 @@ mod tests { ); } + /// rtk-ai/rtk#3184 — `mvnd` must route to `rtk mvnd`, never `rtk mvn`, + /// so the daemon binary is the one that actually runs. + #[test] + fn test_rewrite_mvnd_clean_install() { + assert_eq!( + rewrite_command_no_prefixes("mvnd clean install", &[]), + Some("rtk mvnd clean install".into()) + ); + } + + #[test] + fn test_classify_mvnd_test() { + assert!(matches!( + classify_command("mvnd test"), + Classification::Supported { + rtk_equivalent: "rtk mvnd", + .. + } + )); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 49c0ff740a..10e58b84d3 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -780,6 +780,18 @@ pub const RULES: &[RtkRule] = &[ savings_pct: 82.0, ..RtkRule::DEFAULT }, + RtkRule { + // `mvnd` is a separate binary, not a `mvn` wrapper — it must keep its + // own rtk_cmd so the daemon is what actually runs. + pattern: r"^mvnd\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", + rtk_cmd: "rtk mvnd", + pipeline_final_safe: false, + rewrite_prefixes: &["mvnd"], + category: "Build", + savings_pct: 82.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^ping\b", rtk_cmd: "rtk ping", diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..6cafde07e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -825,6 +825,14 @@ enum Commands { args: Vec, }, + /// Maven Daemon (mvnd) with compact output — same filters as `rtk mvn` + #[command(name = "mvnd")] + Mvnd { + /// Maven goals and arguments (e.g., clean install, -DskipTests test, -X) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1) #[command(name = "hook-audit")] HookAudit { @@ -2412,6 +2420,8 @@ fn run_cli() -> Result { Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?, + Commands::Mvnd { args } => mvn_cmd::run_daemon(&args, cli.verbose)?, + Commands::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 @@ -3144,6 +3154,7 @@ mod tests { "golangci-lint", "gradlew", "mvn", + "mvnd", "sbt", "php", "phpunit", From 5dcd8be6a5dc2db5e8182dac3358d26f88764caa Mon Sep 17 00:00:00 2001 From: kylehgc Date: Fri, 24 Jul 2026 14:16:08 -0400 Subject: [PATCH 02/14] docs(mvn): correct mvnd limits; drop tautological test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review found the README overclaimed. Two corrections: - `filter_quiet` has no keep-list, only a deliberate "keep anything else" safety net, so daemon chatter is NOT dropped there — only the three keep-list filters drop it. Documented as a known limit. - mvnd builds multi-module reactors in parallel regardless of TTY, so interleaved Surefire blocks degrade savings toward passthrough. Also documented rather than claimed away. Both need a real captured mvnd fixture to fix honestly, and both already apply to `mvn -T`, so neither is a regression. Also drops `mvn_binary_without_daemon_is_not_mvnd`: no non-daemon branch can return "mvnd", so it was asserting a literal against itself. Co-Authored-By: Claude Opus 5 --- src/cmds/jvm/README.md | 4 +++- src/cmds/jvm/mvn_cmd.rs | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 8ccfa8ac03..40519a5c5f 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -28,7 +28,9 @@ Key behaviours: - **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). - **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. -- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. Its rolling/parallel console UI only engages on a TTY, so the captured output rtk filters is plain Maven output. Daemon status chatter (`Connecting to daemon`, …) carries no `[INFO]`/`[ERROR]` prefix and is dropped by the existing keep-lists. +- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. The daemon's rolling console UI only engages on a TTY, so what rtk captures is ordinary Maven log output and the existing filters apply unchanged. Two known limits, neither a regression — both already apply to `mvn -T`, and both need a real captured `mvnd` fixture before they can be fixed honestly: + - Daemon status chatter (`Connecting to the daemon`, …) carries no `[INFO]`/`[ERROR]` prefix, so the keep-list filters (`filter_surefire`, `filter_compile`, `filter_package`) drop it — but `filter_quiet` has no keep-list, only a deliberate "keep anything else" safety net, so `rtk mvnd -q` passes that chatter through where `rtk mvn -q` emits nothing. + - `mvnd` builds multi-module reactors in parallel by default. Interleaved `[INFO] Running …` lines from concurrent modules make `SurefireBlock` flush open blocks verbatim, so savings degrade toward passthrough on large reactors. Degrades gracefully (keeps signal, loses compression) per the fallback rule. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). - **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index c31f36e93a..f72139861a 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -1097,11 +1097,6 @@ mod tests { assert_eq!(mvn_binary(true), "mvnd"); } - #[test] - fn mvn_binary_without_daemon_is_not_mvnd() { - assert_ne!(mvn_binary(false), "mvnd"); - } - // ── Surefire filter ────────────────────────────────────────────────────── #[test] From b7bb5bc23ab54e3b38a0d5d5894417cdbbc39453 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Wed, 12 Aug 2026 12:17:51 -0400 Subject: [PATCH 03/14] fix(mvn): terminate Surefire failure trail on mvnd's [INFO]-prefixed blanks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for #3199: real captured mvnd 1.0.6 fixtures plus regression tests, as requested. The fixtures surfaced a daemon-specific bug: mvnd routes all output through the daemon logger, which prefixes even blank lines with `[INFO] `. The Surefire failure-trail terminator keyed on a truly empty line, so under mvnd the trail never closed and everything after the last failing class passed through verbatim, help boilerplate included (35% savings instead of the >=60% floor). is_blank_separator now treats `[INFO]`-only lines as blank in the trail and re-arm states. - mvnd_reactor_pass_raw.txt: warmed `mvnd clean install` on the multi-module skeleton — SmartBuilder parallel reactor with `[module] [INFO]` prefixes and daemon chatter - mvnd_test_fail_raw.txt: warmed failing `mvnd test` (exit 1) on the multifail skeleton - mvnd_compile_error_raw.txt: `mvnd compile` syntax error - multifail-skeleton: six passing test classes so the failing-run capture is representative of real projects (an all-failures run is all signal and makes any savings floor meaningless) - six regression tests: reactor summary survives, failure names, messages and user stack frames survive, compile diagnostics survive, daemon chatter and module-prefixed noise dropped, >=60% token savings asserted on the success and failure runs - README: `rtk mvnd` added to the root command list Co-Authored-By: Claude Fable 5 --- README.md | 1 + src/cmds/jvm/mvn_cmd.rs | 98 ++++++++++++++++- .../java/com/example/rtk/PassFiveTest.java | 19 ++++ .../java/com/example/rtk/PassFourTest.java | 19 ++++ .../java/com/example/rtk/PassOneTest.java | 19 ++++ .../java/com/example/rtk/PassSixTest.java | 19 ++++ .../java/com/example/rtk/PassThreeTest.java | 19 ++++ .../java/com/example/rtk/PassTwoTest.java | 19 ++++ tests/fixtures/mvnd_compile_error_raw.txt | 38 +++++++ tests/fixtures/mvnd_reactor_pass_raw.txt | 101 ++++++++++++++++++ tests/fixtures/mvnd_test_fail_raw.txt | 96 +++++++++++++++++ 11 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFiveTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFourTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassOneTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassSixTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassThreeTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassTwoTest.java create mode 100644 tests/fixtures/mvnd_compile_error_raw.txt create mode 100644 tests/fixtures/mvnd_reactor_pass_raw.txt create mode 100644 tests/fixtures/mvnd_test_fail_raw.txt diff --git a/README.md b/README.md index e019e80e82..2f3dfd6b37 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ rtk cargo clippy # Cargo clippy (-80%) rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) rtk rubocop # Ruby linting (JSON, -60%+) +rtk mvnd verify # Maven Daemon (same filters as rtk mvn) rtk sbt test # ScalaTest output (-90%) rtk sbt compile # Compilation errors only (-75%) rtk sbt run # Strip SBT preamble noise diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index f72139861a..b00ac540bb 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -146,6 +146,15 @@ fn is_boilerplate(line: &str) -> bool { BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) || line.trim_end() == "[ERROR]" } +/// Blank separator line as emitted by both binaries: plain `mvn` writes a +/// truly empty line between a Surefire failure trail and the next section; +/// `mvnd` routes everything through the daemon logger, which prefixes even +/// blank lines with `[INFO] ` (see `mvnd_test_fail_raw.txt`). Both terminate +/// (or bridge, in the re-arm state) a failure trail. +fn is_blank_separator(line: &str) -> bool { + line.is_empty() || line.trim_end() == "[INFO]" +} + /// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). /// Distinguished from CLOSE by call position: only consulted when /// `in_block == false` (CLOSE only occurs while a block is open). A @@ -327,7 +336,7 @@ impl<'a> SurefireBlock<'a> { } if self.failure_trail { - if line.is_empty() { + if is_blank_separator(line) { if !self.drop_trail { out.push('\n'); } @@ -351,7 +360,7 @@ impl<'a> SurefireBlock<'a> { } if let Some(dropped) = self.trail_rearm { - if line.is_empty() { + if is_blank_separator(line) { // Tolerate extra blanks between per-test blocks: stay armed, // let the blank fall through (outer keep-lists drop it). return SurefireStep::Passthrough; @@ -1097,6 +1106,91 @@ mod tests { assert_eq!(mvn_binary(true), "mvnd"); } + // ── Maven Daemon fixtures ──────────────────────────────────────────────── + // + // Real output captured with Apache Maven Daemon 1.0.6 (Maven 3.9.16, + // non-TTY) on the skeleton projects under `tests/fixtures/`. Non-TTY mvnd + // output is plain Maven output plus daemon-specific lines with no + // `[INFO]`-only shape the keep-lists would retain: `Processing build on + // daemon `, `BuildTimeEventSpy is registered.`, the SmartBuilder + // thread-count line, and the concurrency stats block. Parallel reactor + // builds additionally prefix per-module log lines with `[module] `. + + /// Warmed `mvnd clean install` on the multi-module skeleton: the parallel + /// reactor case. Per-module `[module] [INFO] …` lines and daemon chatter + /// are noise; the (unprefixed) reactor summary and footer are the signal. + #[test] + fn mvnd_reactor_pass_keeps_summary_drops_daemon_noise() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); + let o = filter_package(i); + assert!(o.contains("[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:")); + assert!(o.contains("child-a ............................................ SUCCESS")); + assert!(o.contains("child-b ............................................ SUCCESS")); + assert!(o.contains("[INFO] BUILD SUCCESS")); + assert!(o.contains("[INFO] Total time:")); + // Daemon chatter and `[module] `-prefixed parallel lines are dropped. + assert!(!o.contains("Processing build on daemon")); + assert!(!o.contains("BuildTimeEventSpy")); + assert!(!o.contains("SmartBuilder")); + assert!(!o.contains("Bottleneck projects")); + assert!(!o.contains("[child-a] [INFO]")); + assert!(!o.contains("[child-b] [INFO]")); + } + + #[test] + fn mvnd_reactor_pass_savings() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); + let o = filter_package(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); + } + + /// Warmed `mvnd test` on the failing skeleton (exit code 1). Same Surefire + /// shape as `mvn`: failure names, messages, and user stack frames survive; + /// daemon chatter, framework frames, and help boilerplate do not. + #[test] + fn mvnd_test_fail_preserves_failures() { + let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("[INFO] Running com.example.rtk.BoomTest")); + assert!(o.contains("[INFO] Running com.example.rtk.CalcTest")); + assert!(o.contains("failOne: addition should equal five ==> expected: <5> but was: <4>")); + assert!(o.contains("at com.example.rtk.CalcTest.failOne(CalcTest.java:12)")); + assert!(o.contains("[ERROR] CalcTest.failOne:12")); + assert!(o.contains("[ERROR] Tests run: 16, Failures: 1, Errors: 2, Skipped: 0")); + // Passing classes are collapsed entirely. + assert!(!o.contains("PassOneTest")); + assert!(o.contains("[INFO] BUILD FAILURE")); + assert!(o.contains("[ERROR] Failed to execute goal")); + // Daemon chatter, framework frames, and boilerplate are dropped. + assert!(!o.contains("Processing build on daemon")); + assert!(!o.contains("BuildTimeEventSpy")); + assert!(!o.contains("SmartBuilder")); + assert!(!o.contains("at java.base/")); + assert!(!o.contains("Re-run Maven")); + } + + #[test] + fn mvnd_test_fail_savings() { + let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); + let o = filter_surefire(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); + } + + /// `mvnd compile` on a syntax error (exit code 1): compile diagnostics + /// (file, coordinates, message) survive the compile filter. + #[test] + fn mvnd_compile_error_preserves_diagnostics() { + let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); + let o = filter_compile(i); + assert!(o.contains("Calc.java:[5,21] ';' expected")); + assert!(o.contains("[INFO] BUILD FAILURE")); + assert!(o.contains("[ERROR] Failed to execute goal")); + assert!(!o.contains("Processing build on daemon")); + assert!(!o.contains("BuildTimeEventSpy")); + } + // ── Surefire filter ────────────────────────────────────────────────────── #[test] diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFiveTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFiveTest.java new file mode 100644 index 0000000000..9943e0a4d5 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFiveTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassFiveTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFourTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFourTest.java new file mode 100644 index 0000000000..d7e15da5de --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassFourTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassFourTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassOneTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassOneTest.java new file mode 100644 index 0000000000..9ad014db20 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassOneTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassOneTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassSixTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassSixTest.java new file mode 100644 index 0000000000..063e4eb180 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassSixTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassSixTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassThreeTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassThreeTest.java new file mode 100644 index 0000000000..e2b9ad7e22 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassThreeTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassThreeTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassTwoTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassTwoTest.java new file mode 100644 index 0000000000..b81f10ee29 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/PassTwoTest.java @@ -0,0 +1,19 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassTwoTest { + private final Calc calc = new Calc(); + + @Test + void addsCommutatively() { + assertEquals(calc.add(3, 4), calc.add(4, 3)); + } + + @Test + void addsZero() { + assertEquals(7, calc.add(7, 0)); + } +} diff --git a/tests/fixtures/mvnd_compile_error_raw.txt b/tests/fixtures/mvnd_compile_error_raw.txt new file mode 100644 index 0000000000..b4a114b8f2 --- /dev/null +++ b/tests/fixtures/mvnd_compile_error_raw.txt @@ -0,0 +1,38 @@ +[INFO] Processing build on daemon 94a4d17c +[INFO] Scanning for projects... +[INFO] BuildTimeEventSpy is registered. +[INFO] +[INFO] Using the SmartBuilder implementation with a thread count of 17 +[INFO] +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.4.0:resources (default-resources) @ multifail-skeleton --- +[INFO] skip non existing resourceDirectory C:\work\multifail-skeleton\src\main\resources +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ multifail-skeleton --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 1 source file with javac [debug target 21] to target\classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] /C:/work/multifail-skeleton/src/main/java/com/example/rtk/Calc.java:[5,21] ';' expected +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.188 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:04:59-04:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project multifail-skeleton: Compilation failure +[ERROR] /C:/work/multifail-skeleton/src/main/java/com/example/rtk/Calc.java:[5,21] ';' expected +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException diff --git a/tests/fixtures/mvnd_reactor_pass_raw.txt b/tests/fixtures/mvnd_reactor_pass_raw.txt new file mode 100644 index 0000000000..806c3ebb87 --- /dev/null +++ b/tests/fixtures/mvnd_reactor_pass_raw.txt @@ -0,0 +1,101 @@ +[INFO] Processing build on daemon 94a4d17c +[INFO] Scanning for projects... +[INFO] BuildTimeEventSpy is registered. +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] Using the SmartBuilder implementation with a thread count of 17 +[multi-module-skeleton] [INFO] +[multi-module-skeleton] [INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[multi-module-skeleton] [INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[multi-module-skeleton] [INFO] from pom.xml +[multi-module-skeleton] [INFO] --------------------------------[ pom ]--------------------------------- +[multi-module-skeleton] [INFO] +[multi-module-skeleton] [INFO] --- clean:3.2.0:clean (default-clean) @ multi-module-skeleton --- +[multi-module-skeleton] [INFO] +[multi-module-skeleton] [INFO] --- install:3.1.4:install (default-install) @ multi-module-skeleton --- +[multi-module-skeleton] [INFO] Installing C:\work\multi-module-skeleton\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\multi-module-skeleton\1.0.0-SNAPSHOT\multi-module-skeleton-1.0.0-SNAPSHOT.pom +[child-b] [INFO] +[child-b] [INFO] ----------------------< com.example.rtk:child-b >----------------------- +[child-b] [INFO] Building child-b 1.0.0-SNAPSHOT [2/3] +[child-a] [INFO] +[child-b] [INFO] from child-b\pom.xml +[child-a] [INFO] ----------------------< com.example.rtk:child-a >----------------------- +[child-b] [INFO] --------------------------------[ jar ]--------------------------------- +[child-a] [INFO] Building child-a 1.0.0-SNAPSHOT [3/3] +[child-a] [INFO] from child-a\pom.xml +[child-a] [INFO] --------------------------------[ jar ]--------------------------------- +[child-a] [INFO] +[child-b] [INFO] +[child-a] [INFO] --- clean:3.2.0:clean (default-clean) @ child-a --- +[child-b] [INFO] --- clean:3.2.0:clean (default-clean) @ child-b --- +[child-a] [INFO] Deleting C:\work\multi-module-skeleton\child-a\target +[child-b] [INFO] Deleting C:\work\multi-module-skeleton\child-b\target +[child-b] [INFO] +[child-b] [INFO] --- resources:3.4.0:resources (default-resources) @ child-b --- +[child-a] [INFO] +[child-a] [INFO] --- resources:3.4.0:resources (default-resources) @ child-a --- +[child-a] [INFO] skip non existing resourceDirectory C:\work\multi-module-skeleton\child-a\src\main\resources +[child-b] [INFO] skip non existing resourceDirectory C:\work\multi-module-skeleton\child-b\src\main\resources +[child-a] [INFO] +[child-a] [INFO] --- compiler:3.15.0:compile (default-compile) @ child-a --- +[child-b] [INFO] +[child-b] [INFO] --- compiler:3.15.0:compile (default-compile) @ child-b --- +[child-a] [INFO] Recompiling the module because of changed source code. +[child-a] [INFO] Compiling 1 source file with javac [debug target 21] to target\classes +[child-b] [INFO] Recompiling the module because of changed source code. +[child-b] [INFO] Compiling 1 source file with javac [debug target 21] to target\classes +[child-b] [INFO] +[child-b] [INFO] --- resources:3.4.0:testResources (default-testResources) @ child-b --- +[child-a] [INFO] +[child-a] [INFO] --- resources:3.4.0:testResources (default-testResources) @ child-a --- +[child-a] [INFO] skip non existing resourceDirectory C:\work\multi-module-skeleton\child-a\src\test\resources +[child-b] [INFO] skip non existing resourceDirectory C:\work\multi-module-skeleton\child-b\src\test\resources +[child-b] [INFO] +[child-a] [INFO] +[child-a] [INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ child-a --- +[child-b] [INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ child-b --- +[child-b] [INFO] No sources to compile +[child-b] [INFO] +[child-b] [INFO] --- surefire:3.5.4:test (default-test) @ child-b --- +[child-a] [INFO] No sources to compile +[child-a] [INFO] +[child-a] [INFO] --- surefire:3.5.4:test (default-test) @ child-a --- +[child-a] [INFO] No tests to run. +[child-b] [INFO] No tests to run. +[child-a] [INFO] +[child-b] [INFO] +[child-a] [INFO] --- jar:3.5.0:jar (default-jar) @ child-a --- +[child-b] [INFO] --- jar:3.5.0:jar (default-jar) @ child-b --- +[child-b] [INFO] Building jar: C:\work\multi-module-skeleton\child-b\target\child-b-1.0.0-SNAPSHOT.jar +[child-a] [INFO] Building jar: C:\work\multi-module-skeleton\child-a\target\child-a-1.0.0-SNAPSHOT.jar +[child-a] [INFO] +[child-b] [INFO] +[child-b] [INFO] --- install:3.1.4:install (default-install) @ child-b --- +[child-a] [INFO] --- install:3.1.4:install (default-install) @ child-a --- +[child-a] [INFO] Installing C:\work\multi-module-skeleton\child-a\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\child-a\1.0.0-SNAPSHOT\child-a-1.0.0-SNAPSHOT.pom +[child-b] [INFO] Installing C:\work\multi-module-skeleton\child-b\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\child-b\1.0.0-SNAPSHOT\child-b-1.0.0-SNAPSHOT.pom +[child-b] [INFO] Installing C:\work\multi-module-skeleton\child-b\target\child-b-1.0.0-SNAPSHOT.jar to C:\Users\dev\.m2\repository\com\example\rtk\child-b\1.0.0-SNAPSHOT\child-b-1.0.0-SNAPSHOT.jar +[child-a] [INFO] Installing C:\work\multi-module-skeleton\child-a\target\child-a-1.0.0-SNAPSHOT.jar to C:\Users\dev\.m2\repository\com\example\rtk\child-a\1.0.0-SNAPSHOT\child-a-1.0.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] Average project wall time: 0.00s +[INFO] Total concurrency: 11% +[INFO] Bottleneck projects that decrease concurrency: (run build with -Dsmartbuilder.profiling=true for further details) +[INFO] - com.example.rtk:multi-module-skeleton:1.0.0-SNAPSHOT +[INFO] - com.example.rtk:child-a:1.0.0-SNAPSHOT +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.070 s] +[INFO] child-a ............................................ SUCCESS [ 0.339 s] +[INFO] child-b ............................................ SUCCESS [ 0.333 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.481 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:03:52-04:00 +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvnd_test_fail_raw.txt b/tests/fixtures/mvnd_test_fail_raw.txt new file mode 100644 index 0000000000..d3c893618b --- /dev/null +++ b/tests/fixtures/mvnd_test_fail_raw.txt @@ -0,0 +1,96 @@ +[INFO] Processing build on daemon 94a4d17c +[INFO] Scanning for projects... +[INFO] BuildTimeEventSpy is registered. +[INFO] +[INFO] Using the SmartBuilder implementation with a thread count of 17 +[INFO] +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.4.0:resources (default-resources) @ multifail-skeleton --- +[INFO] skip non existing resourceDirectory C:\work\multifail-skeleton\src\main\resources +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ multifail-skeleton --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.4.0:testResources (default-testResources) @ multifail-skeleton --- +[INFO] skip non existing resourceDirectory C:\work\multifail-skeleton\src\test\resources +[INFO] +[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ multifail-skeleton --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ multifail-skeleton --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.rtk.BoomTest +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.089 s <<< FAILURE! -- in com.example.rtk.BoomTest +[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.046 s <<< ERROR! +java.lang.IllegalStateException: boom: errors-only class for fixture capture + at com.example.rtk.BoomTest.boom(BoomTest.java:8) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) +[INFO] Running com.example.rtk.CalcTest +[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.022 s <<< FAILURE! -- in com.example.rtk.CalcTest +[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: failOne: addition should equal five ==> expected: <5> but was: <4> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at com.example.rtk.CalcTest.failOne(CalcTest.java:12) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) +[ERROR] com.example.rtk.CalcTest.failTwo -- Time elapsed: 0.004 s <<< ERROR! +java.lang.IllegalStateException: failTwo: induced error for fixture capture + at com.example.rtk.CalcTest.failTwo(CalcTest.java:17) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) +[INFO] Running com.example.rtk.PassFiveTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.009 s -- in com.example.rtk.PassFiveTest +[INFO] Running com.example.rtk.PassFourTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.012 s -- in com.example.rtk.PassFourTest +[INFO] Running com.example.rtk.PassOneTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.009 s -- in com.example.rtk.PassOneTest +[INFO] Running com.example.rtk.PassSixTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in com.example.rtk.PassSixTest +[INFO] Running com.example.rtk.PassThreeTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in com.example.rtk.PassThreeTest +[INFO] Running com.example.rtk.PassTwoTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in com.example.rtk.PassTwoTest +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] CalcTest.failOne:12 failOne: addition should equal five ==> expected: <5> but was: <4> +[ERROR] Errors: +[ERROR] BoomTest.boom:8 IllegalState boom: errors-only class for fixture capture +[ERROR] CalcTest.failTwo:17 IllegalState failTwo: induced error for fixture capture +[INFO] +[ERROR] Tests run: 16, Failures: 1, Errors: 2, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 1.198 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:13:31-04:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project multifail-skeleton: There are test failures. +[ERROR] +[ERROR] See C:\work\multifail-skeleton\target\surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException From f9b107e0dfaff822061edcf3611049a4d742039c Mon Sep 17 00:00:00 2001 From: kylehgc Date: Mon, 17 Aug 2026 10:03:14 -0400 Subject: [PATCH 04/14] fix(mvn): make Surefire matching module-prefix aware for mvnd reactors mvnd builds multi-module reactors in parallel and prefixes per-module log lines with `[module] `, interleaved line-by-line, while stack traces stay raw. The Surefire matchers keyed on `[INFO]`/`[ERROR]` at column 0, so in a parallel reactor a failing class lost its diagnostics: only the reactor summary and the generic goal error survived. - `split_lane` classifies on the prefix-stripped view and emits the original line, so module identity survives in the output. - One `SurefireLane` (block machine + keep-continuation + failures-summary cap) per module, so a passing close from one module can no longer be attributed to another module's open block. Unprefixed raw lines route to the lane that most recently opened a block or failure trail. - `filter_compile` classifies on the stripped view too. Adds a real captured fixture (`mvnd clean test` on the new multi-module-fail-skeleton, exit 1, interleaved reactor): the failing class, its assertion message, the user stack frame, and the summary entry all survive at 72.1% token savings. Adds insta snapshot regression tests locking the full filtered output of all four mvnd fixtures, per docs/contributing/CODING_PRACTICES.md. Updates the jvm README: the parallel-reactor limit it documented is fixed; only the `-q` daemon-chatter limit remains. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 36 ++ Cargo.toml | 1 + src/cmds/jvm/README.md | 8 +- src/cmds/jvm/mvn_cmd.rs | 320 ++++++++++++++---- ...d__tests__mvnd_compile_error_snapshot.snap | 14 + ...__mvnd_parallel_reactor_fail_snapshot.snap | 34 ++ ...md__tests__mvnd_reactor_pass_snapshot.snap | 27 ++ ...n_cmd__tests__mvnd_test_fail_snapshot.snap | 31 ++ .../child-a/pom.xml | 11 + .../com/example/rtk/ParallelFailTest.java | 12 + .../java/com/example/rtk/PassAlphaTest.java | 17 + .../child-b/pom.xml | 11 + .../java/com/example/rtk/PassBetaTest.java | 17 + .../java/com/example/rtk/PassGammaTest.java | 12 + .../multi-module-fail-skeleton/pom.xml | 34 ++ tests/fixtures/mvnd_reactor_fail_raw.txt | 133 ++++++++ 16 files changed, 645 insertions(+), 73 deletions(-) create mode 100644 src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap create mode 100644 src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap create mode 100644 src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap create mode 100644 src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-a/pom.xml create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/ParallelFailTest.java create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/PassAlphaTest.java create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-b/pom.xml create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassBetaTest.java create mode 100644 tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassGammaTest.java create mode 100644 tests/fixtures/multi-module-fail-skeleton/pom.xml create mode 100644 tests/fixtures/mvnd_reactor_fail_raw.txt diff --git a/Cargo.lock b/Cargo.lock index b7380ea65e..7ca7caa7d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -334,6 +345,12 @@ dependencies = [ "syn", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_home" version = "0.1.0" @@ -651,6 +668,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -903,6 +932,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "ignore", + "insta", "lazy_static", "libc", "quick-xml", @@ -1077,6 +1107,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 8ff5614d17..17c3af5c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ libc = "0.2" toml = "0.8" [dev-dependencies] +insta = "1" [profile.release] opt-level = 3 diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 40519a5c5f..8094bff556 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -28,9 +28,11 @@ Key behaviours: - **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). - **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. -- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. The daemon's rolling console UI only engages on a TTY, so what rtk captures is ordinary Maven log output and the existing filters apply unchanged. Two known limits, neither a regression — both already apply to `mvn -T`, and both need a real captured `mvnd` fixture before they can be fixed honestly: - - Daemon status chatter (`Connecting to the daemon`, …) carries no `[INFO]`/`[ERROR]` prefix, so the keep-list filters (`filter_surefire`, `filter_compile`, `filter_package`) drop it — but `filter_quiet` has no keep-list, only a deliberate "keep anything else" safety net, so `rtk mvnd -q` passes that chatter through where `rtk mvn -q` emits nothing. - - `mvnd` builds multi-module reactors in parallel by default. Interleaved `[INFO] Running …` lines from concurrent modules make `SurefireBlock` flush open blocks verbatim, so savings degrade toward passthrough on large reactors. Degrades gracefully (keeps signal, loses compression) per the fallback rule. +- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. The daemon's rolling console UI only engages on a TTY, so what rtk captures is ordinary Maven log output and the existing filters apply unchanged. Two daemon-specific quirks are handled explicitly: + - **`[INFO]`-prefixed blanks** — the daemon logger prefixes even empty lines with `[INFO] `, so the Surefire failure trail's blank-line terminator uses `is_blank_separator` (a bare `[INFO]` counts as blank). Without it the trail never closes and everything after the last failing class passes through verbatim. + - **Parallel-reactor lanes** — `mvnd` builds multi-module reactors in parallel by default and prefixes per-module lines with `[module] `, interleaved line-by-line. `split_lane` classifies on the prefix-stripped view while emitting the original (module identity survives), and each module gets its own `SurefireLane` (block machine + failures-summary cap) so a passing close from one module can't be attributed to another's open block. Unprefixed raw lines (stack traces) route to the lane that most recently opened a block or trail. + + One known limit remains, not a regression — it already applies to `mvn -T`: daemon status chatter (`Connecting to the daemon`, …) carries no `[INFO]`/`[ERROR]` prefix, so the keep-list filters (`filter_surefire`, `filter_compile`, `filter_package`) drop it — but `filter_quiet` has no keep-list, only a deliberate "keep anything else" safety net, so `rtk mvnd -q` passes that chatter through where `rtk mvn -q` emits nothing. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). - **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index b00ac540bb..55d54575ff 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -155,6 +155,34 @@ fn is_blank_separator(line: &str) -> bool { line.is_empty() || line.trim_end() == "[INFO]" } +// ── Parallel-reactor lanes ────────────────────────────────────────────────── + +/// mvnd parallel reactors prefix per-module log lines with `[module] ` while +/// stack traces stay raw and reactor-level lines stay unprefixed — and lines +/// from different modules interleave freely (see +/// `mvnd_reactor_fail_raw.txt`). Classification must therefore happen on the +/// prefix-stripped view, and Surefire block state must be tracked per module. +/// +/// Returns `(Some(key), core)` for log-level lines — `key` is the module tag +/// (`""` for unprefixed reactor-level lines) and `core` is the line with the +/// module prefix stripped, for classification — or `(None, line)` for raw +/// lines (stack traces, stray stdout), which belong to whichever lane most +/// recently opened a Surefire block or failure trail. +fn split_lane(line: &str) -> (Option<&str>, &str) { + if !line.starts_with('[') { + return (None, line); + } + if let Some(end) = line.find("] ") { + let tag = &line[1..end]; + let rest = &line[end + 2..]; + if !matches!(tag, "INFO" | "ERROR" | "WARNING" | "DEBUG" | "FATAL") && rest.starts_with('[') + { + return (Some(tag), rest); + } + } + (Some(""), line) +} + /// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). /// Distinguished from CLOSE by call position: only consulted when /// `in_block == false` (CLOSE only occurs while a block is open). A @@ -293,12 +321,16 @@ impl<'a> SurefireBlock<'a> { } } - fn step(&mut self, line: &'a str, out: &mut String) -> SurefireStep<'a> { - if PLUGIN_BANNER.is_match(line) { + /// Matching is done on `core` (the module-prefix-stripped view of the + /// line — identical to `line` outside mvnd parallel reactors); `line` is + /// the original, which is what gets buffered and emitted so module + /// identity survives in the output. + fn step(&mut self, line: &'a str, core: &str, out: &mut String) -> SurefireStep<'a> { + if PLUGIN_BANNER.is_match(core) { return SurefireStep::Consumed; } - if RUNNING.is_match(line) { + if RUNNING.is_match(core) { if self.in_block { self.flush_open_block_as_keep(out); } @@ -313,7 +345,7 @@ impl<'a> SurefireBlock<'a> { } if self.in_block { - if let Some(caps) = CLOSE.captures(line) { + if let Some(caps) = CLOSE.captures(core) { let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { @@ -336,7 +368,7 @@ impl<'a> SurefireBlock<'a> { } if self.failure_trail { - if is_blank_separator(line) { + if is_blank_separator(core) { if !self.drop_trail { out.push('\n'); } @@ -347,7 +379,7 @@ impl<'a> SurefireBlock<'a> { self.drop_trail = false; return SurefireStep::Consumed; } - let t = line.trim_start(); + let t = core.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { return SurefireStep::Consumed; } @@ -360,13 +392,13 @@ impl<'a> SurefireBlock<'a> { } if let Some(dropped) = self.trail_rearm { - if is_blank_separator(line) { + if is_blank_separator(core) { // Tolerate extra blanks between per-test blocks: stay armed, // let the blank fall through (outer keep-lists drop it). return SurefireStep::Passthrough; } self.trail_rearm = None; // disarm unconditionally on non-blank (load-bearing) - if is_per_test_subline(line) { + if is_per_test_subline(core) { self.failure_trail = true; self.drop_trail = dropped; if !dropped { @@ -476,11 +508,12 @@ impl FailuresSummaryCap { } } - /// If `line` is an `[ERROR] ` entry inside the failures summary, write - /// it (or count it as dropped) and return `true` so the caller skips its - /// own keep-list. Returns `false` otherwise. - fn handle_entry(&mut self, line: &str, out: &mut String) -> bool { - if !self.in_summary || !line.starts_with("[ERROR] ") { + /// If `core` is an `[ERROR] ` entry inside the failures summary, write + /// `line` (the original, module prefix included) — or count it as + /// dropped — and return `true` so the caller skips its own keep-list. + /// Returns `false` otherwise. + fn handle_entry(&mut self, core: &str, line: &str, out: &mut String) -> bool { + if !self.in_summary || !core.starts_with("[ERROR] ") { return false; } // Per core cap policy, `0` means summary-only: no entries, tail still counts. @@ -529,6 +562,39 @@ impl FailuresSummaryCap { } } +/// Per-module filter state for parallel reactors: each module gets its own +/// Surefire block machine, continuation flag, and failures-summary cap, +/// because mvnd interleaves module output line-by-line (a `[child-b]` close +/// can land between a `[child-a]` `Running` and its close). +struct SurefireLane<'a> { + block: SurefireBlock<'a>, + keep_continuation: bool, + summary: FailuresSummaryCap, +} + +impl<'a> SurefireLane<'a> { + fn new(cap: usize) -> Self { + Self { + block: SurefireBlock::new(), + keep_continuation: false, + summary: FailuresSummaryCap::new(cap), + } + } +} + +/// Index of `key`'s lane, creating it on first sight. Insertion order is +/// preserved so end-of-stream flushes are deterministic. Linear scan: a +/// reactor has a handful of modules, not thousands. +fn lane_index<'a>(lanes: &mut Vec<(&'a str, SurefireLane<'a>)>, key: &'a str, cap: usize) -> usize { + match lanes.iter().position(|(k, _)| *k == key) { + Some(i) => i, + None => { + lanes.push((key, SurefireLane::new(cap))); + lanes.len() - 1 + } + } +} + /// Buffered single-pass filter for `mvn test` / `mvn integration-test`. /// /// Drives [`SurefireBlock`] for the inner block/trail machine; applies the @@ -549,15 +615,29 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { } let mut out = String::new(); - let mut block = SurefireBlock::new(); - let mut keep_continuation = false; + // One lane per module prefix; lane 0 (`""`) is the root lane, which is + // the only lane a plain-`mvn` (or single-module mvnd) run ever uses. + let mut lanes: Vec<(&str, SurefireLane)> = vec![("", SurefireLane::new(cap))]; + // Lane owning raw (unprefixed, non-log) lines: the one most recently + // inside a Surefire block or failure trail — mvnd emits stack traces + // without a module prefix even in parallel builds. + let mut hot: usize = 0; let mut in_reactor_summary = false; let mut emitted_failing: usize = 0; let mut dropped_failing: usize = 0; - let mut summary = FailuresSummaryCap::new(cap); for line in stripped.lines() { - match block.step(line, &mut out) { + let (key, core) = split_lane(line); + let idx = match key { + Some(k) => lane_index(&mut lanes, k, cap), + None => hot, + }; + + let step = lanes[idx].1.block.step(line, core, &mut out); + if lanes[idx].1.block.in_block || lanes[idx].1.block.failure_trail { + hot = idx; + } + match step { SurefireStep::Consumed => continue, SurefireStep::FailingClose { running, @@ -565,19 +645,22 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { close, } => { if emitted_failing < cap { - block.commit_failing(&mut out, running, &lines, close); + lanes[idx].1.block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { - block.drop_failing(); + lanes[idx].1.block.drop_failing(); dropped_failing += 1; } - keep_continuation = false; + hot = idx; + lanes[idx].1.keep_continuation = false; continue; } SurefireStep::Passthrough => {} } - if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + let lane = &mut lanes[idx].1; + + if lane.keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; @@ -586,34 +669,36 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail // before AGG. The helper consumes only summary entries — other lines // (header, AGG) fall through to the keep-list below. - if summary.handle_entry(line, &mut out) { + if lane.summary.handle_entry(core, line, &mut out) { continue; } // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. - let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); - if reactor_keep || keep_outside_block(line) { + let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); + if reactor_keep || keep_outside_block(core) { // Pre-emit the summary tail when we're about to write AGG. - summary.handle_aggregate(line, &mut out); + lane.summary.handle_aggregate(core, &mut out); // Detect summary header so subsequent `[ERROR] ` entries get capped. - summary.handle_header(line); + lane.summary.handle_header(core); out.push_str(line); out.push('\n'); - keep_continuation = line.starts_with("[ERROR]") - && !line.starts_with("[ERROR] Tests run:") - && !line.starts_with("[ERROR] Failures:") - && !line.starts_with("[ERROR] Errors:"); + lane.keep_continuation = core.starts_with("[ERROR]") + && !core.starts_with("[ERROR] Tests run:") + && !core.starts_with("[ERROR] Failures:") + && !core.starts_with("[ERROR] Errors:"); continue; } // Dropped line (e.g. help boilerplate): reset so a stale flag can't // keep an indented line that follows a dropped `[ERROR]` line. // Parity with filter_package's fall-through reset. - keep_continuation = false; + lane.keep_continuation = false; } - block.finish(&mut out); - summary.finish(&mut out); + for (_, lane) in &mut lanes { + lane.block.finish(&mut out); + lane.summary.finish(&mut out); + } if dropped_failing > 0 { out.push_str(&format!( "\n… +{} more failing test classes\n", @@ -642,17 +727,20 @@ pub fn filter_compile(raw: &str) -> String { let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { - if MODULE_BANNER.is_match(line) { + // Classify on the module-prefix-stripped view; emit the original so + // module identity survives in mvnd parallel reactors. + let core = split_lane(line).1; + if MODULE_BANNER.is_match(core) { out.push_str(line); out.push('\n'); keep_continuation = false; continue; } - if BUILD_FOOT.is_match(line) - || line.starts_with("[INFO] Building ") - || line.starts_with("[INFO] Total time:") - || line.starts_with("[INFO] Finished at:") - || line.starts_with("[INFO] Scanning ") + if BUILD_FOOT.is_match(core) + || core.starts_with("[INFO] Building ") + || core.starts_with("[INFO] Total time:") + || core.starts_with("[INFO] Finished at:") + || core.starts_with("[INFO] Scanning ") { out.push_str(line); out.push('\n'); @@ -661,23 +749,23 @@ pub fn filter_compile(raw: &str) -> String { } // Help boilerplate: drop before the `[ERROR]` catch-all (parity with // keep_outside_block / filter_quiet). - if is_boilerplate(line) { + if is_boilerplate(core) { keep_continuation = false; continue; } - if line.starts_with("[ERROR]") { + if core.starts_with("[ERROR]") { out.push_str(line); out.push('\n'); keep_continuation = true; continue; } - if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + if keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; } - if line.starts_with("[WARNING]") { - let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + if core.starts_with("[WARNING]") { + let payload = core.strip_prefix("[WARNING] ").unwrap_or(core); let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); @@ -712,16 +800,28 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { } let mut out = String::new(); - let mut block = SurefireBlock::new(); - let mut keep_continuation = false; + // Per-module lanes + raw-line routing: see filter_surefire_with_cap. + let mut lanes: Vec<(&str, SurefireLane)> = vec![("", SurefireLane::new(cap))]; + let mut hot: usize = 0; let mut in_reactor_summary = false; + // Warning dedup is deliberately global: the same warning surfacing from + // several reactor modules is still the same warning. let mut seen_warnings: HashSet = HashSet::new(); let mut emitted_failing: usize = 0; let mut dropped_failing: usize = 0; - let mut summary = FailuresSummaryCap::new(cap); for line in stripped.lines() { - match block.step(line, &mut out) { + let (key, core) = split_lane(line); + let idx = match key { + Some(k) => lane_index(&mut lanes, k, cap), + None => hot, + }; + + let step = lanes[idx].1.block.step(line, core, &mut out); + if lanes[idx].1.block.in_block || lanes[idx].1.block.failure_trail { + hot = idx; + } + match step { SurefireStep::Consumed => continue, SurefireStep::FailingClose { running, @@ -729,58 +829,63 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { close, } => { if emitted_failing < cap { - block.commit_failing(&mut out, running, &lines, close); + lanes[idx].1.block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { - block.drop_failing(); + lanes[idx].1.block.drop_failing(); dropped_failing += 1; } - keep_continuation = false; + hot = idx; + lanes[idx].1.keep_continuation = false; continue; } SurefireStep::Passthrough => {} } + let lane = &mut lanes[idx].1; + // Failures-summary cap (see filter_surefire_with_cap for details). - if summary.handle_entry(line, &mut out) { + if lane.summary.handle_entry(core, line, &mut out) { continue; } // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. - let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); + let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. - if reactor_keep || MODULE_BANNER.is_match(line) || keep_outside_block(line) { - summary.handle_aggregate(line, &mut out); - summary.handle_header(line); + if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { + lane.summary.handle_aggregate(core, &mut out); + lane.summary.handle_header(core); out.push_str(line); out.push('\n'); - keep_continuation = line.starts_with("[ERROR]") - && !line.starts_with("[ERROR] Tests run:") - && !line.starts_with("[ERROR] Failures:") - && !line.starts_with("[ERROR] Errors:"); + lane.keep_continuation = core.starts_with("[ERROR]") + && !core.starts_with("[ERROR] Tests run:") + && !core.starts_with("[ERROR] Failures:") + && !core.starts_with("[ERROR] Errors:"); continue; } - if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + if lane.keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; } - if line.starts_with("[WARNING]") { - let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + if core.starts_with("[WARNING]") { + let payload = core.strip_prefix("[WARNING] ").unwrap_or(core); let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); } - keep_continuation = false; + lane.keep_continuation = false; continue; } - keep_continuation = false; + lane.keep_continuation = false; } - block.finish(&mut out); - summary.finish(&mut out); + for (_, lane) in &mut lanes { + lane.block.finish(&mut out); + lane.summary.finish(&mut out); + } if dropped_failing > 0 { out.push_str(&format!( "\n… +{} more failing test classes\n", @@ -1128,13 +1233,16 @@ mod tests { assert!(o.contains("child-b ............................................ SUCCESS")); assert!(o.contains("[INFO] BUILD SUCCESS")); assert!(o.contains("[INFO] Total time:")); - // Daemon chatter and `[module] `-prefixed parallel lines are dropped. + // Module identity survives on keeper lines (parity with plain-mvn + // reactors, whose banners/artifact lines are kept). + assert!(o.contains("[child-b] [INFO] Building jar:")); + // Daemon chatter and per-module noise are dropped. assert!(!o.contains("Processing build on daemon")); assert!(!o.contains("BuildTimeEventSpy")); assert!(!o.contains("SmartBuilder")); assert!(!o.contains("Bottleneck projects")); - assert!(!o.contains("[child-a] [INFO]")); - assert!(!o.contains("[child-b] [INFO]")); + assert!(!o.contains("skip non existing resourceDirectory")); + assert!(!o.contains("[INFO] Deleting")); } #[test] @@ -1178,6 +1286,78 @@ mod tests { assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } + /// Failing test inside a parallel reactor (`mvnd clean test` on the + /// multi-module-fail-skeleton, exit code 1): module output interleaves + /// line-by-line and per-module lines carry a `[module] ` prefix, while + /// the assertion message and stack frames arrive raw (unprefixed). The + /// failing class, its message, user frame, and summary entry must all + /// survive — with module identity — and the interleaved passing classes + /// from the other module must not bleed into the failing block. + #[test] + fn mvnd_parallel_reactor_fail_preserves_diagnostics() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("[child-a] [INFO] Running com.example.rtk.ParallelFailTest")); + assert!(o.contains( + "[child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed:" + )); + assert!(o.contains("parallel reactor diagnostic ==> expected: <1> but was: <2>")); + assert!(o.contains("at com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)")); + assert!(o.contains("[child-a] [ERROR] ParallelFailTest.reactorDiagnostic:10")); + assert!(o.contains("[child-a] [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0")); + // Reactor summary keeps the per-module verdicts. + assert!(o.contains("child-a ............................................ FAILURE")); + assert!(o.contains("[INFO] BUILD FAILURE")); + assert!(o.contains("[ERROR] Failed to execute goal")); + // Interleaved passing classes are collapsed; a passing close from the + // other module must never be attributed to the failing block. + assert!(!o.contains("PassBetaTest")); + assert!(!o.contains("PassGammaTest")); + assert!(!o.contains("PassAlphaTest")); + // Framework frames, daemon chatter, and boilerplate are dropped. + assert!(!o.contains("at org.junit.jupiter")); + assert!(!o.contains("at java.base/")); + assert!(!o.contains("Processing build on daemon")); + assert!(!o.contains("Re-run Maven")); + } + + #[test] + fn mvnd_parallel_reactor_fail_savings() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); + let o = filter_surefire(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); + } + + // Snapshot regression tests locking the full filtered output of every + // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the + // substring assertions above document intent; the snapshots catch + // everything else. + + #[test] + fn mvnd_reactor_pass_snapshot() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); + insta::assert_snapshot!(filter_package(i)); + } + + #[test] + fn mvnd_test_fail_snapshot() { + let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); + insta::assert_snapshot!(filter_surefire(i)); + } + + #[test] + fn mvnd_parallel_reactor_fail_snapshot() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); + insta::assert_snapshot!(filter_surefire(i)); + } + + #[test] + fn mvnd_compile_error_snapshot() { + let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); + insta::assert_snapshot!(filter_compile(i)); + } + /// `mvnd compile` on a syntax error (exit code 1): compile diagnostics /// (file, coordinates, message) survive the compile filter. #[test] diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap new file mode 100644 index 0000000000..c994023770 --- /dev/null +++ b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: src/cmds/jvm/mvn_cmd.rs +expression: filter_compile(i) +--- +[INFO] Scanning for projects... +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[ERROR] COMPILATION ERROR : +[ERROR] /C:/work/multifail-skeleton/src/main/java/com/example/rtk/Calc.java:[5,21] ';' expected +[INFO] BUILD FAILURE +[INFO] Total time: 0.188 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:04:59-04:00 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project multifail-skeleton: Compilation failure +[ERROR] /C:/work/multifail-skeleton/src/main/java/com/example/rtk/Calc.java:[5,21] ';' expected diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap new file mode 100644 index 0000000000..1409f39a69 --- /dev/null +++ b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap @@ -0,0 +1,34 @@ +--- +source: src/cmds/jvm/mvn_cmd.rs +expression: filter_surefire(i) +--- +[INFO] Scanning for projects... +[multi-module-fail-skeleton] [INFO] -------------< com.example.rtk:multi-module-fail-skeleton >------------- +[multi-module-fail-skeleton] [INFO] Building multi-module-fail-skeleton 1.0.0-SNAPSHOT [1/3] +[child-b] [INFO] ----------------------< com.example.rtk:child-b >----------------------- +[child-b] [INFO] Building child-b 1.0.0-SNAPSHOT [2/3] +[child-a] [INFO] ----------------------< com.example.rtk:child-a >----------------------- +[child-a] [INFO] Building child-a 1.0.0-SNAPSHOT [3/3] +[child-a] [INFO] Running com.example.rtk.ParallelFailTest +[child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.153 s <<< FAILURE! -- in com.example.rtk.ParallelFailTest +[child-a] [ERROR] com.example.rtk.ParallelFailTest.reactorDiagnostic -- Time elapsed: 0.098 s <<< FAILURE! +org.opentest4j.AssertionFailedError: parallel reactor diagnostic ==> expected: <1> but was: <2> + at com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10) +[child-a] [INFO] Results: +[child-b] [INFO] Results: +[child-a] [ERROR] Failures: +[child-b] [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[child-a] [ERROR] ParallelFailTest.reactorDiagnostic:10 parallel reactor diagnostic ==> expected: <1> but was: <2> +[child-a] [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 +[INFO] Reactor Summary for multi-module-fail-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-fail-skeleton ......................... SUCCESS [ 0.030 s] +[INFO] child-a ............................................ FAILURE [ 3.230 s] +[INFO] child-b ............................................ SUCCESS [ 3.230 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] Total time: 3.363 s (Wall Clock) +[INFO] Finished at: 2026-08-13T09:18:03-04:00 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project child-a: There are test failures. +[ERROR] After correcting the problems, you can resume the build with the command +[ERROR] mvn -r diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap new file mode 100644 index 0000000000..ba2ddd65d6 --- /dev/null +++ b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap @@ -0,0 +1,27 @@ +--- +source: src/cmds/jvm/mvn_cmd.rs +expression: filter_package(i) +--- +[INFO] Scanning for projects... +[multi-module-skeleton] [INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[multi-module-skeleton] [INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[multi-module-skeleton] [INFO] Installing C:\work\multi-module-skeleton\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\multi-module-skeleton\1.0.0-SNAPSHOT\multi-module-skeleton-1.0.0-SNAPSHOT.pom +[child-b] [INFO] ----------------------< com.example.rtk:child-b >----------------------- +[child-b] [INFO] Building child-b 1.0.0-SNAPSHOT [2/3] +[child-a] [INFO] ----------------------< com.example.rtk:child-a >----------------------- +[child-a] [INFO] Building child-a 1.0.0-SNAPSHOT [3/3] +[child-b] [INFO] Building jar: C:\work\multi-module-skeleton\child-b\target\child-b-1.0.0-SNAPSHOT.jar +[child-a] [INFO] Building jar: C:\work\multi-module-skeleton\child-a\target\child-a-1.0.0-SNAPSHOT.jar +[child-a] [INFO] Installing C:\work\multi-module-skeleton\child-a\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\child-a\1.0.0-SNAPSHOT\child-a-1.0.0-SNAPSHOT.pom +[child-b] [INFO] Installing C:\work\multi-module-skeleton\child-b\pom.xml to C:\Users\dev\.m2\repository\com\example\rtk\child-b\1.0.0-SNAPSHOT\child-b-1.0.0-SNAPSHOT.pom +[child-b] [INFO] Installing C:\work\multi-module-skeleton\child-b\target\child-b-1.0.0-SNAPSHOT.jar to C:\Users\dev\.m2\repository\com\example\rtk\child-b\1.0.0-SNAPSHOT\child-b-1.0.0-SNAPSHOT.jar +[child-a] [INFO] Installing C:\work\multi-module-skeleton\child-a\target\child-a-1.0.0-SNAPSHOT.jar to C:\Users\dev\.m2\repository\com\example\rtk\child-a\1.0.0-SNAPSHOT\child-a-1.0.0-SNAPSHOT.jar +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.070 s] +[INFO] child-a ............................................ SUCCESS [ 0.339 s] +[INFO] child-b ............................................ SUCCESS [ 0.333 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] Total time: 0.481 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:03:52-04:00 diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap new file mode 100644 index 0000000000..daaa491db2 --- /dev/null +++ b/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap @@ -0,0 +1,31 @@ +--- +source: src/cmds/jvm/mvn_cmd.rs +expression: filter_surefire(i) +--- +[INFO] Scanning for projects... +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] Running com.example.rtk.BoomTest +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.089 s <<< FAILURE! -- in com.example.rtk.BoomTest +[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.046 s <<< ERROR! +java.lang.IllegalStateException: boom: errors-only class for fixture capture + at com.example.rtk.BoomTest.boom(BoomTest.java:8) +[INFO] Running com.example.rtk.CalcTest +[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.022 s <<< FAILURE! -- in com.example.rtk.CalcTest +[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: failOne: addition should equal five ==> expected: <5> but was: <4> + at com.example.rtk.CalcTest.failOne(CalcTest.java:12) +[ERROR] com.example.rtk.CalcTest.failTwo -- Time elapsed: 0.004 s <<< ERROR! +java.lang.IllegalStateException: failTwo: induced error for fixture capture + at com.example.rtk.CalcTest.failTwo(CalcTest.java:17) +[INFO] Results: +[ERROR] Failures: +[ERROR] CalcTest.failOne:12 failOne: addition should equal five ==> expected: <5> but was: <4> +[ERROR] Errors: +[ERROR] BoomTest.boom:8 IllegalState boom: errors-only class for fixture capture +[ERROR] CalcTest.failTwo:17 IllegalState failTwo: induced error for fixture capture +[ERROR] Tests run: 16, Failures: 1, Errors: 2, Skipped: 0 +[INFO] BUILD FAILURE +[INFO] Total time: 1.198 s (Wall Clock) +[INFO] Finished at: 2026-08-12T12:13:31-04:00 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project multifail-skeleton: There are test failures. diff --git a/tests/fixtures/multi-module-fail-skeleton/child-a/pom.xml b/tests/fixtures/multi-module-fail-skeleton/child-a/pom.xml new file mode 100644 index 0000000000..7484aac482 --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-a/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-fail-skeleton + 1.0.0-SNAPSHOT + + child-a + jar + diff --git a/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/ParallelFailTest.java b/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/ParallelFailTest.java new file mode 100644 index 0000000000..f0404596ec --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/ParallelFailTest.java @@ -0,0 +1,12 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class ParallelFailTest { + @Test + void reactorDiagnostic() { + assertEquals(1, 1 + 1, "parallel reactor diagnostic"); + } +} diff --git a/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/PassAlphaTest.java b/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/PassAlphaTest.java new file mode 100644 index 0000000000..21e8bed7f3 --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-a/src/test/java/com/example/rtk/PassAlphaTest.java @@ -0,0 +1,17 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassAlphaTest { + @Test + void sums() { + assertEquals(4, 2 + 2); + } + + @Test + void concatenates() { + assertEquals("ab", "a" + "b"); + } +} diff --git a/tests/fixtures/multi-module-fail-skeleton/child-b/pom.xml b/tests/fixtures/multi-module-fail-skeleton/child-b/pom.xml new file mode 100644 index 0000000000..3f128585ba --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-b/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-fail-skeleton + 1.0.0-SNAPSHOT + + child-b + jar + diff --git a/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassBetaTest.java b/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassBetaTest.java new file mode 100644 index 0000000000..fc79aa93c4 --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassBetaTest.java @@ -0,0 +1,17 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PassBetaTest { + @Test + void multiplies() { + assertEquals(6, 2 * 3); + } + + @Test + void negates() { + assertEquals(-5, -(5)); + } +} diff --git a/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassGammaTest.java b/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassGammaTest.java new file mode 100644 index 0000000000..3b987c37d8 --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/child-b/src/test/java/com/example/rtk/PassGammaTest.java @@ -0,0 +1,12 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PassGammaTest { + @Test + void compares() { + assertTrue(3 > 2); + } +} diff --git a/tests/fixtures/multi-module-fail-skeleton/pom.xml b/tests/fixtures/multi-module-fail-skeleton/pom.xml new file mode 100644 index 0000000000..ba5828b8a0 --- /dev/null +++ b/tests/fixtures/multi-module-fail-skeleton/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + com.example.rtk + multi-module-fail-skeleton + 1.0.0-SNAPSHOT + pom + + child-a + child-b + + + 21 + 21 + UTF-8 + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + + + diff --git a/tests/fixtures/mvnd_reactor_fail_raw.txt b/tests/fixtures/mvnd_reactor_fail_raw.txt new file mode 100644 index 0000000000..bd4713cf9b --- /dev/null +++ b/tests/fixtures/mvnd_reactor_fail_raw.txt @@ -0,0 +1,133 @@ +[INFO] Processing build on daemon c7ac26cb +[INFO] Scanning for projects... +[INFO] BuildTimeEventSpy is registered. +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-fail-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[multi-module-fail-skeleton] [INFO] Using the SmartBuilder implementation with a thread count of 17 +[multi-module-fail-skeleton] [INFO] +[multi-module-fail-skeleton] [INFO] -------------< com.example.rtk:multi-module-fail-skeleton >------------- +[multi-module-fail-skeleton] [INFO] Building multi-module-fail-skeleton 1.0.0-SNAPSHOT [1/3] +[multi-module-fail-skeleton] [INFO] from pom.xml +[multi-module-fail-skeleton] [INFO] --------------------------------[ pom ]--------------------------------- +[multi-module-fail-skeleton] [INFO] +[multi-module-fail-skeleton] [INFO] --- clean:3.2.0:clean (default-clean) @ multi-module-fail-skeleton --- +[multi-module-fail-skeleton] [INFO] Deleting C:\work\multi-module-fail-skeleton\target +[child-b] [INFO] +[child-b] [INFO] ----------------------< com.example.rtk:child-b >----------------------- +[child-b] [INFO] Building child-b 1.0.0-SNAPSHOT [2/3] +[child-a] [INFO] +[child-a] [INFO] ----------------------< com.example.rtk:child-a >----------------------- +[child-a] [INFO] Building child-a 1.0.0-SNAPSHOT [3/3] +[child-b] [INFO] from child-b\pom.xml +[child-b] [INFO] --------------------------------[ jar ]--------------------------------- +[child-a] [INFO] from child-a\pom.xml +[child-a] [INFO] --------------------------------[ jar ]--------------------------------- +[child-a] [INFO] +[child-a] [INFO] --- clean:3.2.0:clean (default-clean) @ child-a --- +[child-b] [INFO] +[child-b] [INFO] --- clean:3.2.0:clean (default-clean) @ child-b --- +[child-a] [INFO] Deleting C:\work\multi-module-fail-skeleton\child-a\target +[child-b] [INFO] Deleting C:\work\multi-module-fail-skeleton\child-b\target +[child-a] [INFO] +[child-a] [INFO] --- resources:3.4.0:resources (default-resources) @ child-a --- +[child-b] [INFO] +[child-b] [INFO] --- resources:3.4.0:resources (default-resources) @ child-b --- +[child-a] [INFO] skip non existing resourceDirectory C:\work\multi-module-fail-skeleton\child-a\src\main\resources +[child-b] [INFO] skip non existing resourceDirectory C:\work\multi-module-fail-skeleton\child-b\src\main\resources +[child-a] [INFO] +[child-a] [INFO] --- compiler:3.15.0:compile (default-compile) @ child-a --- +[child-b] [INFO] +[child-b] [INFO] --- compiler:3.15.0:compile (default-compile) @ child-b --- +[child-a] [INFO] No sources to compile +[child-b] [INFO] No sources to compile +[child-a] [INFO] +[child-b] [INFO] +[child-a] [INFO] --- resources:3.4.0:testResources (default-testResources) @ child-a --- +[child-b] [INFO] --- resources:3.4.0:testResources (default-testResources) @ child-b --- +[child-b] [INFO] skip non existing resourceDirectory C:\work\multi-module-fail-skeleton\child-b\src\test\resources +[child-a] [INFO] skip non existing resourceDirectory C:\work\multi-module-fail-skeleton\child-a\src\test\resources +[child-a] [INFO] +[child-a] [INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ child-a --- +[child-b] [INFO] +[child-b] [INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ child-b --- +[child-b] [INFO] Recompiling the module because of changed source code. +[child-b] [INFO] Compiling 2 source files with javac [debug target 21] to target\test-classes +[child-a] [INFO] Recompiling the module because of changed source code. +[child-a] [INFO] Compiling 2 source files with javac [debug target 21] to target\test-classes +[child-a] [INFO] +[child-b] [INFO] +[child-b] [INFO] --- surefire:3.5.5:test (default-test) @ child-b --- +[child-a] [INFO] --- surefire:3.5.5:test (default-test) @ child-a --- +[child-b] [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[child-a] [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[child-b] [INFO] +[child-b] [INFO] ------------------------------------------------------- +[child-b] [INFO] T E S T S +[child-b] [INFO] ------------------------------------------------------- +[child-a] [INFO] +[child-a] [INFO] ------------------------------------------------------- +[child-a] [INFO] T E S T S +[child-a] [INFO] ------------------------------------------------------- +[child-a] [INFO] Running com.example.rtk.ParallelFailTest +[child-b] [INFO] Running com.example.rtk.PassBetaTest +[child-b] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.157 s -- in com.example.rtk.PassBetaTest +[child-b] [INFO] Running com.example.rtk.PassGammaTest +[child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.153 s <<< FAILURE! -- in com.example.rtk.ParallelFailTest +[child-a] [ERROR] com.example.rtk.ParallelFailTest.reactorDiagnostic -- Time elapsed: 0.098 s <<< FAILURE! +org.opentest4j.AssertionFailedError: parallel reactor diagnostic ==> expected: <1> but was: <2> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) +[child-a] [INFO] Running com.example.rtk.PassAlphaTest +[child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.009 s -- in com.example.rtk.PassGammaTest +[child-a] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.026 s -- in com.example.rtk.PassAlphaTest +[child-a] [INFO] +[child-b] [INFO] +[child-a] [INFO] Results: +[child-b] [INFO] Results: +[child-a] [INFO] +[child-a] [ERROR] Failures: +[child-b] [INFO] +[child-b] [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[child-a] [ERROR] ParallelFailTest.reactorDiagnostic:10 parallel reactor diagnostic ==> expected: <1> but was: <2> +[child-a] [INFO] +[child-a] [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 +[child-b] [INFO] +[child-a] [INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-fail-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-fail-skeleton ......................... SUCCESS [ 0.030 s] +[INFO] child-a ............................................ FAILURE [ 3.230 s] +[INFO] child-b ............................................ SUCCESS [ 3.230 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.363 s (Wall Clock) +[INFO] Finished at: 2026-08-13T09:18:03-04:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project child-a: There are test failures. +[ERROR] +[ERROR] See C:\work\multi-module-fail-skeleton\child-a\target\surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException +[ERROR] +[ERROR] After correcting the problems, you can resume the build with the command +[ERROR] mvn -r From afeea0f0e582977fb22cdc2558269c23f9a0c0cb Mon Sep 17 00:00:00 2001 From: kylehgc Date: Mon, 17 Aug 2026 17:43:25 -0400 Subject: [PATCH 05/14] fix(mvn): route interleaved reactor diagnostics correctly; share reactor-wide caps Three correctness fixes for mvnd parallel reactors, all driven through a single shared lane-routing path: - Raw-line ownership: an active failure trail now outranks an ordinary open block (Lanes::raw_owner), so a module opening a Surefire block between another module's failing close and its unprefixed exception trail can no longer swallow the diagnostics into a block that closes green. When ownership is genuinely ambiguous (several plain blocks open, no trail), the raw line is preserved verbatim instead of routed to a guess. - Compile continuations are lane-aware: filter_compile tracks keep_continuation per module via the same Lanes type, so an interleaved line from another module no longer disarms the flag between "[ERROR] cannot find symbol" and its raw symbol:/location: continuations. - The [ERROR] Failures: summary budget is reactor-wide: lanes keep only an in_summary flag while FailuresSummaryCap holds one shared count, so interleaved per-module summaries can no longer retain modules x cap entries. Same for the failing-class cap (FailingClassCap). filter_surefire_with_cap and filter_package_with_cap now share the routing/step/cap front half (drive_surefire_line) instead of carrying two copies, and every interleaving regression is asserted on both the surefire and package entry points. The mvnd rules.rs entry uses ..RtkRule::DEFAULT like its neighbours. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 532 +++++++++++++++++++++++++++++----------- src/discover/rules.rs | 4 +- 3 files changed, 393 insertions(+), 145 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 8094bff556..08251e30ac 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -30,7 +30,7 @@ Key behaviours: - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. The daemon's rolling console UI only engages on a TTY, so what rtk captures is ordinary Maven log output and the existing filters apply unchanged. Two daemon-specific quirks are handled explicitly: - **`[INFO]`-prefixed blanks** — the daemon logger prefixes even empty lines with `[INFO] `, so the Surefire failure trail's blank-line terminator uses `is_blank_separator` (a bare `[INFO]` counts as blank). Without it the trail never closes and everything after the last failing class passes through verbatim. - - **Parallel-reactor lanes** — `mvnd` builds multi-module reactors in parallel by default and prefixes per-module lines with `[module] `, interleaved line-by-line. `split_lane` classifies on the prefix-stripped view while emitting the original (module identity survives), and each module gets its own `SurefireLane` (block machine + failures-summary cap) so a passing close from one module can't be attributed to another's open block. Unprefixed raw lines (stack traces) route to the lane that most recently opened a block or trail. + - **Parallel-reactor lanes** — `mvnd` builds multi-module reactors in parallel by default and prefixes per-module lines with `[module] `, interleaved line-by-line. `split_lane` classifies on the prefix-stripped view while emitting the original (module identity survives), and each module gets its own `SurefireLane` (block machine + compile-continuation flag + summary-open flag) so a passing close from one module can't be attributed to another's open block. Three consequences of the interleaving are handled explicitly: unprefixed raw lines (stack traces, javac `symbol:` / `location:` continuations) go to the lane in an active **failure trail** before any lane merely holding an open block (`raw_owner`) — otherwise a module opening a block between a failing close and its exception swallows the diagnostics and drops them when it closes green — and when ownership is genuinely ambiguous (several plain blocks open, no trail) the line is preserved verbatim rather than routed to a guess. The `[ERROR] Failures:` budget stays reactor-wide (`FailuresSummaryCap`, reset only when a header opens the first summary), so interleaved per-module summaries share one cap instead of each getting `MAX_MVN_FAILING_CLASSES`. One known limit remains, not a regression — it already applies to `mvn -T`: daemon status chatter (`Connecting to the daemon`, …) carries no `[INFO]`/`[ERROR]` prefix, so the keep-list filters (`filter_surefire`, `filter_compile`, `filter_package`) drop it — but `filter_quiet` has no keep-list, only a deliberate "keep anything else" safety net, so `rtk mvnd -q` passes that chatter through where `rtk mvn -q` emits nothing. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index d3db2c73f4..b27f536c56 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -497,29 +497,36 @@ impl<'a> SurefireBlock<'a> { /// with hundreds of failures this can be quite large. Cap entries at /// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. +/// +/// The budget is **reactor-wide**, not per module: a parallel reactor emits one +/// summary block per failing module and they interleave, so each lane keeps its +/// own `in_summary` flag ([`SurefireLane::in_summary`]) while the entry count is +/// shared. Counters reset only when a header opens the *first* summary, so +/// sequential runs behave exactly as before (one summary at a time) while +/// interleaved ones can't retain `modules × cap` entries. struct FailuresSummaryCap { cap: usize, - in_summary: bool, emitted: usize, dropped: usize, + open_lanes: usize, } impl FailuresSummaryCap { fn new(cap: usize) -> Self { Self { cap, - in_summary: false, emitted: 0, dropped: 0, + open_lanes: 0, } } - /// If `core` is an `[ERROR] ` entry inside the failures summary, write - /// `line` (the original, module prefix included) — or count it as - /// dropped — and return `true` so the caller skips its own keep-list. + /// If `core` is an `[ERROR] ` entry inside the calling lane's failures + /// summary, write `line` (the original, module prefix included) — or count + /// it as dropped — and return `true` so the caller skips its own keep-list. /// Returns `false` otherwise. - fn handle_entry(&mut self, core: &str, line: &str, out: &mut String) -> bool { - if !self.in_summary || !core.starts_with("[ERROR] ") { + fn handle_entry(&mut self, in_summary: bool, core: &str, line: &str, out: &mut String) -> bool { + if !in_summary || !core.starts_with("[ERROR] ") { return false; } // Per core cap policy, `0` means summary-only: no entries, tail still counts. @@ -535,69 +542,233 @@ impl FailuresSummaryCap { /// Detect the `[ERROR] Failures:` header so subsequent `[ERROR] ` lines /// get capped. Caller is responsible for writing the header to `out`. - fn handle_header(&mut self, line: &str) { - if line.starts_with("[ERROR] Failures:") { - self.in_summary = true; + fn handle_header(&mut self, line: &str, in_summary: &mut bool) { + if !line.starts_with("[ERROR] Failures:") || *in_summary { + return; + } + if self.open_lanes == 0 { self.emitted = 0; self.dropped = 0; } + *in_summary = true; + self.open_lanes += 1; } /// Pre-emit the `… +N more failures` tail when the aggregate - /// `[ERROR] Tests run:` line is about to be written, then close the + /// `[ERROR] Tests run:` line is about to be written, then close this lane's /// summary. Caller writes the AGG line itself afterwards. - fn handle_aggregate(&mut self, line: &str, out: &mut String) { - if !self.in_summary || !AGG.is_match(line) { + fn handle_aggregate(&mut self, line: &str, out: &mut String, in_summary: &mut bool) { + if !*in_summary || !AGG.is_match(line) { return; } if self.dropped > 0 { out.push_str(&format!("\n… +{} more failures\n", self.dropped)); + self.dropped = 0; } - self.in_summary = false; - self.emitted = 0; - self.dropped = 0; + *in_summary = false; + self.open_lanes -= 1; } /// End-of-stream tail emission for cases where the AGG line never arrives /// (truncated output). Emits the tail with no trailing newline guard so /// the resulting filtered output is still well-formed. fn finish(&mut self, out: &mut String) { - if self.in_summary && self.dropped > 0 { + if self.open_lanes > 0 && self.dropped > 0 { out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } } } /// Per-module filter state for parallel reactors: each module gets its own -/// Surefire block machine, continuation flag, and failures-summary cap, -/// because mvnd interleaves module output line-by-line (a `[child-b]` close -/// can land between a `[child-a]` `Running` and its close). +/// Surefire block machine, continuation flag, and summary-open flag, because +/// mvnd interleaves module output line-by-line (a `[child-b]` close can land +/// between a `[child-a]` `Running` and its close). The failures-summary +/// *budget* is deliberately not here — see [`FailuresSummaryCap`]. struct SurefireLane<'a> { block: SurefireBlock<'a>, keep_continuation: bool, - summary: FailuresSummaryCap, + in_summary: bool, } impl<'a> SurefireLane<'a> { - fn new(cap: usize) -> Self { + fn new() -> Self { Self { block: SurefireBlock::new(), keep_continuation: false, - summary: FailuresSummaryCap::new(cap), + in_summary: false, + } + } +} + +/// The set of per-module lanes plus the "hot" lane raw lines default to. +/// Lane 0 (`""`) is the root lane, the only one a plain-`mvn` (or +/// single-module mvnd) run ever uses. Insertion order is preserved so +/// end-of-stream flushes are deterministic; lookups are a linear scan (a +/// reactor has a handful of modules, not thousands). +struct Lanes<'a> { + lanes: Vec<(&'a str, SurefireLane<'a>)>, + hot: usize, +} + +impl<'a> Lanes<'a> { + fn new() -> Self { + Self { + lanes: vec![("", SurefireLane::new())], + hot: 0, + } + } + + fn get(&mut self, idx: usize) -> &mut SurefireLane<'a> { + &mut self.lanes[idx].1 + } + + /// Lane index for a line: by module key for log-level lines (creating the + /// lane on first sight), by ownership rules for raw lines. `None` means a + /// raw line's ownership is genuinely ambiguous and the caller must + /// preserve it verbatim rather than guess a lane that may drop it. + fn route(&mut self, key: Option<&'a str>) -> Option { + match key { + Some(k) => Some(match self.lanes.iter().position(|(t, _)| *t == k) { + Some(i) => i, + None => { + self.lanes.push((k, SurefireLane::new())); + self.lanes.len() - 1 + } + }), + None => self.raw_owner(), + } + } + + /// Lane owning an unprefixed raw line (stack trace, stray stdout — mvnd + /// emits these without a module tag even in parallel builds). + /// + /// A lane inside a **failure trail** outranks one merely inside an open + /// block: the trail is where the actionable diagnostics land, and another + /// module opening a block between a failing close and its exception must + /// not swallow them (that block then closes green and the trail is + /// discarded). `None` means ownership is genuinely ambiguous — several + /// plain blocks open, no trail. + fn raw_owner(&self) -> Option { + if self.lanes[self.hot].1.block.failure_trail { + return Some(self.hot); + } + if let Some(i) = self.lanes.iter().position(|(_, l)| l.block.failure_trail) { + return Some(i); + } + let mut open = self + .lanes + .iter() + .enumerate() + .filter(|(_, (_, l))| l.block.in_block); + match (open.next(), open.next()) { + (Some((i, _)), None) => Some(i), + (Some(_), Some(_)) => None, + // Nothing open: the hot lane's outside-block keep-list decides. + _ => Some(self.hot), + } + } + + /// Update the default lane for raw lines after `idx` consumed one. A + /// failure trail claims it outright; a plain open block only claims it + /// while no trail is active. + fn note_hot(&mut self, idx: usize) { + let b = &self.lanes[idx].1.block; + if b.failure_trail || (b.in_block && !self.lanes[self.hot].1.block.failure_trail) { + self.hot = idx; + } + } + + /// End-of-stream flush of every lane's block machine, in lane order. + fn finish(&mut self, out: &mut String) { + for (_, lane) in &mut self.lanes { + lane.block.finish(out); } } } -/// Index of `key`'s lane, creating it on first sight. Insertion order is -/// preserved so end-of-stream flushes are deterministic. Linear scan: a -/// reactor has a handful of modules, not thousands. -fn lane_index<'a>(lanes: &mut Vec<(&'a str, SurefireLane<'a>)>, key: &'a str, cap: usize) -> usize { - match lanes.iter().position(|(k, _)| *k == key) { +/// Reactor-wide cap on emitted failing test classes, with the +/// `… +N more failing test classes` tail. Shared by +/// `filter_surefire_with_cap` and `filter_package_with_cap`. +struct FailingClassCap { + cap: usize, + emitted: usize, + dropped: usize, +} + +impl FailingClassCap { + fn new(cap: usize) -> Self { + Self { + cap, + emitted: 0, + dropped: 0, + } + } + + /// `true` when the next failing class still fits under the cap. + fn admit(&mut self) -> bool { + if self.emitted < self.cap { + self.emitted += 1; + true + } else { + self.dropped += 1; + false + } + } + + fn finish(&self, out: &mut String) { + if self.dropped > 0 { + out.push_str(&format!( + "\n… +{} more failing test classes\n", + self.dropped + )); + } + } +} + +/// Shared per-line front half of `filter_surefire_with_cap` and +/// `filter_package_with_cap`: route the line to its lane, drive the lane's +/// Surefire block machine, and commit/drop failing closes against the +/// reactor-wide class cap. Returns `Some((lane index, core))` when the line +/// fell through to the caller's outside-block keep-list; `None` when it was +/// consumed — or preserved verbatim on ambiguous raw-line ownership. +fn drive_surefire_line<'a>( + lanes: &mut Lanes<'a>, + line: &'a str, + classes: &mut FailingClassCap, + out: &mut String, +) -> Option<(usize, &'a str)> { + let (key, core) = split_lane(line); + let idx = match lanes.route(key) { Some(i) => i, None => { - lanes.push((key, SurefireLane::new(cap))); - lanes.len() - 1 + // Ambiguous ownership: preserve rather than risk dropping a + // failing module's diagnostics into a passing block. + out.push_str(line); + out.push('\n'); + return None; + } + }; + + let step = lanes.get(idx).block.step(line, core, out); + lanes.note_hot(idx); + match step { + SurefireStep::Consumed => None, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + if classes.admit() { + lanes.get(idx).block.commit_failing(out, running, &lines, close); + } else { + lanes.get(idx).block.drop_failing(); + } + // A failing close means a trail follows: claim raw lines outright. + lanes.hot = idx; + lanes.get(idx).keep_continuation = false; + None } + SurefireStep::Passthrough => Some((idx, core)), } } @@ -621,50 +792,17 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { } let mut out = String::new(); - // One lane per module prefix; lane 0 (`""`) is the root lane, which is - // the only lane a plain-`mvn` (or single-module mvnd) run ever uses. - let mut lanes: Vec<(&str, SurefireLane)> = vec![("", SurefireLane::new(cap))]; - // Lane owning raw (unprefixed, non-log) lines: the one most recently - // inside a Surefire block or failure trail — mvnd emits stack traces - // without a module prefix even in parallel builds. - let mut hot: usize = 0; + let mut lanes = Lanes::new(); + let mut classes = FailingClassCap::new(cap); + let mut summary = FailuresSummaryCap::new(cap); let mut in_reactor_summary = false; - let mut emitted_failing: usize = 0; - let mut dropped_failing: usize = 0; for line in stripped.lines() { - let (key, core) = split_lane(line); - let idx = match key { - Some(k) => lane_index(&mut lanes, k, cap), - None => hot, + let (idx, core) = match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { + Some(v) => v, + None => continue, }; - - let step = lanes[idx].1.block.step(line, core, &mut out); - if lanes[idx].1.block.in_block || lanes[idx].1.block.failure_trail { - hot = idx; - } - match step { - SurefireStep::Consumed => continue, - SurefireStep::FailingClose { - running, - lines, - close, - } => { - if emitted_failing < cap { - lanes[idx].1.block.commit_failing(&mut out, running, &lines, close); - emitted_failing += 1; - } else { - lanes[idx].1.block.drop_failing(); - dropped_failing += 1; - } - hot = idx; - lanes[idx].1.keep_continuation = false; - continue; - } - SurefireStep::Passthrough => {} - } - - let lane = &mut lanes[idx].1; + let lane = lanes.get(idx); if lane.keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); @@ -675,7 +813,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail // before AGG. The helper consumes only summary entries — other lines // (header, AGG) fall through to the keep-list below. - if lane.summary.handle_entry(core, line, &mut out) { + if summary.handle_entry(lane.in_summary, core, line, &mut out) { continue; } @@ -684,9 +822,9 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); if reactor_keep || keep_outside_block(core) { // Pre-emit the summary tail when we're about to write AGG. - lane.summary.handle_aggregate(core, &mut out); + summary.handle_aggregate(core, &mut out, &mut lane.in_summary); // Detect summary header so subsequent `[ERROR] ` entries get capped. - lane.summary.handle_header(core); + summary.handle_header(core, &mut lane.in_summary); out.push_str(line); out.push('\n'); lane.keep_continuation = core.starts_with("[ERROR]") @@ -701,16 +839,9 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { lane.keep_continuation = false; } - for (_, lane) in &mut lanes { - lane.block.finish(&mut out); - lane.summary.finish(&mut out); - } - if dropped_failing > 0 { - out.push_str(&format!( - "\n… +{} more failing test classes\n", - dropped_failing - )); - } + lanes.finish(&mut out); + summary.finish(&mut out); + classes.finish(&mut out); out } @@ -729,17 +860,32 @@ pub fn filter_compile(raw: &str) -> String { } let mut out = String::new(); - let mut keep_continuation = false; + // Continuation ownership is per module: javac emits `symbol:` / `location:` + // as raw indented lines *after* the `[ERROR] … cannot find symbol` line, and + // mvnd interleaves reactor modules, so a `[child-b] [INFO]` line landing in + // between must not clear the flag armed by `[child-a] [ERROR]`. Compile + // never opens Surefire blocks, so `route` sends raw lines to the hot lane — + // the one that most recently armed a continuation. + let mut lanes = Lanes::new(); let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { // Classify on the module-prefix-stripped view; emit the original so // module identity survives in mvnd parallel reactors. - let core = split_lane(line).1; + let (key, core) = split_lane(line); + let idx = match lanes.route(key) { + Some(i) => i, + // Unreachable without Surefire blocks, but preserve for parity. + None => { + out.push_str(line); + out.push('\n'); + continue; + } + }; if MODULE_BANNER.is_match(core) { out.push_str(line); out.push('\n'); - keep_continuation = false; + lanes.get(idx).keep_continuation = false; continue; } if BUILD_FOOT.is_match(core) @@ -750,22 +896,23 @@ pub fn filter_compile(raw: &str) -> String { { out.push_str(line); out.push('\n'); - keep_continuation = false; + lanes.get(idx).keep_continuation = false; continue; } // Help boilerplate: drop before the `[ERROR]` catch-all (parity with // keep_outside_block / filter_quiet). if is_boilerplate(core) { - keep_continuation = false; + lanes.get(idx).keep_continuation = false; continue; } if core.starts_with("[ERROR]") { out.push_str(line); out.push('\n'); - keep_continuation = true; + lanes.get(idx).keep_continuation = true; + lanes.hot = idx; continue; } - if keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { + if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; @@ -777,11 +924,11 @@ pub fn filter_compile(raw: &str) -> String { out.push_str(line); out.push('\n'); } - keep_continuation = false; + lanes.get(idx).keep_continuation = false; continue; } // Drop everything else - keep_continuation = false; + lanes.get(idx).keep_continuation = false; } out @@ -806,52 +953,24 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { } let mut out = String::new(); - // Per-module lanes + raw-line routing: see filter_surefire_with_cap. - let mut lanes: Vec<(&str, SurefireLane)> = vec![("", SurefireLane::new(cap))]; - let mut hot: usize = 0; + // Per-module lanes + raw-line routing: see drive_surefire_line. + let mut lanes = Lanes::new(); + let mut classes = FailingClassCap::new(cap); + let mut summary = FailuresSummaryCap::new(cap); let mut in_reactor_summary = false; // Warning dedup is deliberately global: the same warning surfacing from // several reactor modules is still the same warning. let mut seen_warnings: HashSet = HashSet::new(); - let mut emitted_failing: usize = 0; - let mut dropped_failing: usize = 0; for line in stripped.lines() { - let (key, core) = split_lane(line); - let idx = match key { - Some(k) => lane_index(&mut lanes, k, cap), - None => hot, + let (idx, core) = match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { + Some(v) => v, + None => continue, }; - - let step = lanes[idx].1.block.step(line, core, &mut out); - if lanes[idx].1.block.in_block || lanes[idx].1.block.failure_trail { - hot = idx; - } - match step { - SurefireStep::Consumed => continue, - SurefireStep::FailingClose { - running, - lines, - close, - } => { - if emitted_failing < cap { - lanes[idx].1.block.commit_failing(&mut out, running, &lines, close); - emitted_failing += 1; - } else { - lanes[idx].1.block.drop_failing(); - dropped_failing += 1; - } - hot = idx; - lanes[idx].1.keep_continuation = false; - continue; - } - SurefireStep::Passthrough => {} - } - - let lane = &mut lanes[idx].1; + let lane = lanes.get(idx); // Failures-summary cap (see filter_surefire_with_cap for details). - if lane.summary.handle_entry(core, line, &mut out) { + if summary.handle_entry(lane.in_summary, core, line, &mut out) { continue; } @@ -860,8 +979,8 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { - lane.summary.handle_aggregate(core, &mut out); - lane.summary.handle_header(core); + summary.handle_aggregate(core, &mut out, &mut lane.in_summary); + summary.handle_header(core, &mut lane.in_summary); out.push_str(line); out.push('\n'); lane.keep_continuation = core.starts_with("[ERROR]") @@ -888,16 +1007,9 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { lane.keep_continuation = false; } - for (_, lane) in &mut lanes { - lane.block.finish(&mut out); - lane.summary.finish(&mut out); - } - if dropped_failing > 0 { - out.push_str(&format!( - "\n… +{} more failing test classes\n", - dropped_failing - )); - } + lanes.finish(&mut out); + summary.finish(&mut out); + classes.finish(&mut out); out } @@ -1335,6 +1447,144 @@ mod tests { assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } + // The four tests below use inline strings rather than captures: they pin + // *interleavings* (which module's line lands between which two others), + // and a real `mvnd` run can't be made to emit a chosen interleaving on + // demand — the captured fixtures above happen to keep each failure trail + // contiguous. Line shapes are copied verbatim from + // `mvnd_reactor_fail_raw.txt` / `mvnd_compile_error_raw.txt`, only the + // ordering is arranged. + + /// Another module opening a Surefire block *between* a failing close and + /// its raw (unprefixed) exception trail must not steal the trail: routing + /// those lines into the passing block discards them when it closes green. + /// An active failure trail outranks an ordinary open block. Asserted on + /// both entry points that drive the lane machinery. + fn assert_interleaved_trail_survives(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.ParallelFailTest\n\ + [child-b] [INFO] Running com.example.rtk.PassBetaTest\n\ + [child-b] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.157 s -- in com.example.rtk.PassBetaTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.153 s <<< FAILURE! -- in com.example.rtk.ParallelFailTest\n\ + [child-a] [ERROR] com.example.rtk.ParallelFailTest.reactorDiagnostic -- Time elapsed: 0.098 s <<< FAILURE!\n\ + [child-b] [INFO] Running com.example.rtk.PassGammaTest\n\ + org.opentest4j.AssertionFailedError: parallel reactor diagnostic ==> expected: <1> but was: <2>\n\ + \tat com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.009 s -- in com.example.rtk.PassGammaTest\n\ + [child-a] [INFO] \n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + o.contains("parallel reactor diagnostic ==> expected: <1> but was: <2>"), + "assertion message survives the interleave; got:\n{o}" + ); + assert!( + o.contains( + "at com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)" + ), + "user frame survives the interleave; got:\n{o}" + ); + // The interleaving module's passing classes are still collapsed. + assert!(!o.contains("PassBetaTest"), "got:\n{o}"); + assert!(!o.contains("PassGammaTest"), "got:\n{o}"); + } + + #[test] + fn mvnd_interleaved_block_does_not_steal_failure_trail() { + assert_interleaved_trail_survives(filter_surefire); + } + + #[test] + fn mvnd_package_interleaved_block_does_not_steal_failure_trail() { + assert_interleaved_trail_survives(filter_package); + } + + /// Raw lines with no unambiguous owner — several plain blocks open, no + /// failure trail — are preserved verbatim rather than buffered into a + /// guessed lane that may drop them. + fn assert_ambiguous_raw_line_preserved(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.PassAlphaTest\n\ + [child-b] [INFO] Running com.example.rtk.PassBetaTest\n\ + java.lang.IllegalStateException: stray reactor stdout\n\ + [child-a] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.026 s -- in com.example.rtk.PassAlphaTest\n\ + [child-b] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.157 s -- in com.example.rtk.PassBetaTest\n\ + [INFO] BUILD SUCCESS\n"; + let o = filter(i); + assert!( + o.contains("stray reactor stdout"), + "ambiguous raw line preserved; got:\n{o}" + ); + } + + #[test] + fn mvnd_ambiguous_raw_line_is_preserved() { + assert_ambiguous_raw_line_preserved(filter_surefire); + } + + #[test] + fn mvnd_package_ambiguous_raw_line_is_preserved() { + assert_ambiguous_raw_line_preserved(filter_package); + } + + /// javac emits `symbol:` / `location:` as raw indented lines *after* the + /// `[ERROR] … cannot find symbol` line. A line from another reactor module + /// landing in between must not clear the continuation flag — ownership is + /// per lane. + #[test] + fn mvnd_parallel_compile_keeps_interleaved_continuations() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] /C:/work/child-a/src/main/java/com/example/rtk/A.java:[7,9] cannot find symbol\n\ + [child-b] [INFO] Compiling 1 source file with javac [debug target 21] to target\\classes\n\ + \x20 symbol: variable bar\n\ + \x20 location: class com.example.rtk.A\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_compile(i); + assert!( + o.contains("symbol: variable bar"), + "continuation survives the interleave; got:\n{o}" + ); + assert!( + o.contains("location: class com.example.rtk.A"), + "continuation survives the interleave; got:\n{o}" + ); + } + + /// The `[ERROR] Failures:` cap is reactor-wide: two modules' summaries + /// interleaving must share one budget, not get `modules × cap` entries. + fn assert_summary_cap_shared_across_lanes(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] ChildATest.one:11 boom a1\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] ChildBTest.one:11 boom b1\n\ + [child-a] [ERROR] ChildATest.two:12 boom a2\n\ + [child-b] [ERROR] ChildBTest.two:12 boom b2\n\ + [child-a] [ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0\n\ + [child-b] [ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 2); + assert_eq!( + o.matches("boom ").count(), + 2, + "cap=2 bounds the whole reactor, not each module; got:\n{o}" + ); + assert!( + o.contains("… +2 more failures"), + "tail counts both dropped entries; got:\n{o}" + ); + } + + #[test] + fn mvnd_failures_summary_cap_is_shared_across_lanes() { + assert_summary_cap_shared_across_lanes(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_failures_summary_cap_is_shared_across_lanes() { + assert_summary_cap_shared_across_lanes(filter_package_with_cap); + } + // Snapshot regression tests locking the full filtered output of every // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the // substring assertions above document intent; the snapshots catch diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 10e58b84d3..d7249edbda 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -785,12 +785,10 @@ pub const RULES: &[RtkRule] = &[ // own rtk_cmd so the daemon is what actually runs. pattern: r"^mvnd\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", rtk_cmd: "rtk mvnd", - pipeline_final_safe: false, rewrite_prefixes: &["mvnd"], category: "Build", savings_pct: 82.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ping\b", From dfb8a9498625c5d013e53b32db3b4b6699098d01 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Tue, 18 Aug 2026 11:11:22 -0400 Subject: [PATCH 06/14] fix(mvn): resolve raw-line ownership by unique claimant; make caps invocation-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the three remaining multi-lane cases from review, by replacing the raw-line routing heuristics with one literal invariant instead of patching each case: - Lanes::raw_owner routes an unprefixed raw line only to a UNIQUE owner: exactly one lane in a failure trail (trails still outrank open blocks), else exactly one lane with an open block, else the unique lane with an armed compile continuation. Any tie — several trails, several blocks, or an armed lane alongside either — preserves the line verbatim rather than routing it into a lane that may drop it. The note_hot priority heuristic is gone; `hot` has a single writer (a failing close) and is only consulted when nothing is open and nothing is armed. - The armed-continuation claim lives in the per-lane flags and is scanned across all lanes, so a failing close stealing `hot` cannot orphan another module's pending symbol:/location: continuations; entering a Surefire block retires a lane's stale armed claim. - FailuresSummaryCap's budget spans the whole invocation (open_lanes and its reset are gone): sequential per-module summaries share one budget exactly like interleaved ones, so a run keeps at most cap entries, never modules x cap. Ownership state space is closed by construction — every claimant combination either routes uniquely, preserves verbatim, or is loss-free because a dropping trail implies the class cap is already exhausted (FailingClassCap::admit is monotonic; documented on raw_owner). Coverage moves from hand-picked orderings to exhaustive order-preserving merge sweeps, all asserted on both the surefire and package entry points: failing x passing module (210 merges), two failing modules at cap=1 (924), compile-error x passing (35), compile-error x failing (84) — mvnd's scheduler controls interleaving, so any merge is a run that can happen. Plus targeted regressions: sequential summary budget, interleaved compile continuations on the test/package paths, a cap=1 three-module case where continuations land during another lane's dropping trail, and block-entry arm retirement. The 14 targeted tests fail on the parent commit with only the code changes reverted; all four real-fixture insta snapshots are byte-identical, so the normal-path output is untouched. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 564 +++++++++++++++++++++++++++++++++++----- 1 file changed, 493 insertions(+), 71 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index b27f536c56..184e321aa7 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -172,8 +172,15 @@ fn is_blank_separator(line: &str) -> bool { /// Returns `(Some(key), core)` for log-level lines — `key` is the module tag /// (`""` for unprefixed reactor-level lines) and `core` is the line with the /// module prefix stripped, for classification — or `(None, line)` for raw -/// lines (stack traces, stray stdout), which belong to whichever lane most -/// recently opened a Surefire block or failure trail. +/// lines (stack traces, stray stdout), whose owning lane is resolved by +/// [`Lanes::raw_owner`] (unique owner, or preserved verbatim). +/// +/// Residual seam: a raw line of shape `[tag] text` where `tag` is not a log +/// level and `text` doesn't start with `[` (e.g. slf4j's `[main] INFO …` on +/// test stdout) keys to the root lane rather than classifying as raw, so +/// inside a trail it bypasses trail handling and falls to the root +/// keep-list. Real Surefire trail lines start with an FQCN, `at `, +/// `Caused by:`, or whitespace, so no realistic diagnostic takes this path. fn split_lane(line: &str) -> (Option<&str>, &str) { if !line.starts_with('[') { return (None, line); @@ -499,16 +506,20 @@ impl<'a> SurefireBlock<'a> { /// before the `Tests run:` aggregate when entries were dropped. /// /// The budget is **reactor-wide**, not per module: a parallel reactor emits one -/// summary block per failing module and they interleave, so each lane keeps its -/// own `in_summary` flag ([`SurefireLane::in_summary`]) while the entry count is -/// shared. Counters reset only when a header opens the *first* summary, so -/// sequential runs behave exactly as before (one summary at a time) while -/// interleaved ones can't retain `modules × cap` entries. +/// summary block per failing module, so each lane keeps its own `in_summary` +/// flag ([`SurefireLane::in_summary`]) while the entry count is shared. The +/// budget spans the whole filter invocation and never resets — whether module +/// summaries interleave or run back-to-back, the run keeps at most `cap` +/// entries total, never `modules × cap`. `dropped` alone resets when a tail is +/// emitted, so each `… +N more` reports the drops since the previous tail. +/// This also means a `verify` run whose Surefire and Failsafe phases each emit +/// a summary shares one budget across both — the second summary can get zero +/// entries, with the tail still reporting every drop. That is the documented +/// semantics: at most `cap` entries per run. struct FailuresSummaryCap { cap: usize, emitted: usize, dropped: usize, - open_lanes: usize, } impl FailuresSummaryCap { @@ -517,7 +528,6 @@ impl FailuresSummaryCap { cap, emitted: 0, dropped: 0, - open_lanes: 0, } } @@ -546,12 +556,7 @@ impl FailuresSummaryCap { if !line.starts_with("[ERROR] Failures:") || *in_summary { return; } - if self.open_lanes == 0 { - self.emitted = 0; - self.dropped = 0; - } *in_summary = true; - self.open_lanes += 1; } /// Pre-emit the `… +N more failures` tail when the aggregate @@ -566,14 +571,13 @@ impl FailuresSummaryCap { self.dropped = 0; } *in_summary = false; - self.open_lanes -= 1; } /// End-of-stream tail emission for cases where the AGG line never arrives /// (truncated output). Emits the tail with no trailing newline guard so /// the resulting filtered output is still well-formed. fn finish(&mut self, out: &mut String) { - if self.open_lanes > 0 && self.dropped > 0 { + if self.dropped > 0 { out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } } @@ -600,11 +604,16 @@ impl<'a> SurefireLane<'a> { } } -/// The set of per-module lanes plus the "hot" lane raw lines default to. -/// Lane 0 (`""`) is the root lane, the only one a plain-`mvn` (or -/// single-module mvnd) run ever uses. Insertion order is preserved so -/// end-of-stream flushes are deterministic; lookups are a linear scan (a -/// reactor has a handful of modules, not thousands). +/// The set of per-module lanes plus the "hot" lane raw lines fall back to +/// when no block, trail, or armed continuation exists anywhere. `hot` has a +/// single writer — a failing close (stray raw lines after its trail ends +/// attribute to the failing lane's keep-list); every other ownership claim +/// (trails, open blocks, armed continuations) is resolved by +/// [`Lanes::raw_owner`] scanning per-lane state for a unique owner. Lane 0 +/// (`""`) is the root lane, the only one a plain-`mvn` (or single-module +/// mvnd) run ever uses. Insertion order is preserved so end-of-stream +/// flushes are deterministic; lookups are a linear scan (a reactor has a +/// handful of modules, not thousands). struct Lanes<'a> { lanes: Vec<(&'a str, SurefireLane<'a>)>, hot: usize, @@ -642,18 +651,57 @@ impl<'a> Lanes<'a> { /// Lane owning an unprefixed raw line (stack trace, stray stdout — mvnd /// emits these without a module tag even in parallel builds). /// - /// A lane inside a **failure trail** outranks one merely inside an open - /// block: the trail is where the actionable diagnostics land, and another - /// module opening a block between a failing close and its exception must - /// not swallow them (that block then closes green and the trail is - /// discarded). `None` means ownership is genuinely ambiguous — several - /// plain blocks open, no trail. + /// One rule, applied literally: a raw line is routed only when its owner + /// is **unique** — exactly one lane in a failure trail (trails outrank + /// open blocks: that's where actionable diagnostics land), else exactly + /// one lane with an open block. Any tie is genuine ambiguity → `None`, + /// and the caller preserves the line verbatim rather than guessing a lane + /// that may drop it. With nothing open at all there is no block or trail + /// to misroute into, so the hot lane's outside-block keep-list decides. + /// + /// An armed compile continuation is itself a competing claim, wherever + /// its lane sits — the arming lane need not be `hot`, since a failing + /// close elsewhere steals `hot` unconditionally. Its raw `symbol:` / + /// `location:` lines must be neither buffered into another lane's open + /// block (destroyed on a green close) nor consumed by a trail (silently, + /// if that trail is dropping), so any block or trail open alongside an + /// armed lane is a tie too, and several armed lanes are a tie on their + /// own. With nothing open, a unique armed lane outranks the hot lane — + /// its continuations are the only raw lines anyone is expecting. + /// + /// Deliberate over-keep, never loss: when a tie preserves verbatim, a + /// capped class's framework frames (or a passing block's stack chatter) + /// can leak into the output for the duration of the tie — and an armed + /// claim persists until its lane's next keyed line, so the tie window + /// can outlive the continuations themselves. That trades a few noise + /// lines for the guarantee that actionable diagnostics are never routed + /// into a lane that discards them. + /// + /// A unique *dropping* trail consuming raw lines silently is loss-free + /// even with blocks open concurrently: a dropping trail exists only once + /// the class cap is exhausted ([`FailingClassCap::admit`] is monotonic), + /// so a concurrent block's failing close would be capped and dropped + /// too, and a green close discards its buffer by definition. fn raw_owner(&self) -> Option { - if self.lanes[self.hot].1.block.failure_trail { - return Some(self.hot); - } - if let Some(i) = self.lanes.iter().position(|(_, l)| l.block.failure_trail) { - return Some(i); + let mut armed_lanes = self + .lanes + .iter() + .enumerate() + .filter(|(_, (_, l))| l.keep_continuation); + let armed = match (armed_lanes.next(), armed_lanes.next()) { + (Some((i, _)), None) => Some(i), + (Some(_), Some(_)) => return None, + _ => None, + }; + let mut trails = self + .lanes + .iter() + .enumerate() + .filter(|(_, (_, l))| l.block.failure_trail); + match (trails.next(), trails.next()) { + (Some((i, _)), None) if armed.is_none() => return Some(i), + (Some(_), _) => return None, + _ => {} } let mut open = self .lanes @@ -661,20 +709,11 @@ impl<'a> Lanes<'a> { .enumerate() .filter(|(_, (_, l))| l.block.in_block); match (open.next(), open.next()) { - (Some((i, _)), None) => Some(i), - (Some(_), Some(_)) => None, - // Nothing open: the hot lane's outside-block keep-list decides. - _ => Some(self.hot), - } - } - - /// Update the default lane for raw lines after `idx` consumed one. A - /// failure trail claims it outright; a plain open block only claims it - /// while no trail is active. - fn note_hot(&mut self, idx: usize) { - let b = &self.lanes[idx].1.block; - if b.failure_trail || (b.in_block && !self.lanes[self.hot].1.block.failure_trail) { - self.hot = idx; + (Some((i, _)), None) if armed.is_none() => Some(i), + (Some(_), _) => None, + // Nothing open: the unique armed lane's continuation handling, + // else the hot lane's outside-block keep-list, decides. + _ => Some(armed.unwrap_or(self.hot)), } } @@ -750,7 +789,12 @@ fn drive_surefire_line<'a>( }; let step = lanes.get(idx).block.step(line, core, out); - lanes.note_hot(idx); + // A lane inside a Surefire block has no pending javac continuations: + // entering a block retires any stale armed claim, so a single lane can't + // hold a permanent armed-vs-block tie against raw-line routing. + if lanes.get(idx).block.in_block { + lanes.get(idx).keep_continuation = false; + } match step { SurefireStep::Consumed => None, SurefireStep::FailingClose { @@ -763,7 +807,9 @@ fn drive_surefire_line<'a>( } else { lanes.get(idx).block.drop_failing(); } - // A failing close means a trail follows: claim raw lines outright. + // While the trail is active, raw_owner routes by trail uniqueness; + // `hot` claims only the nothing-open fallback for stray raw lines + // after the trail ends. lanes.hot = idx; lanes.get(idx).keep_continuation = false; None @@ -802,9 +848,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { Some(v) => v, None => continue, }; - let lane = lanes.get(idx); - - if lane.keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { + if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; @@ -813,7 +857,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail // before AGG. The helper consumes only summary entries — other lines // (header, AGG) fall through to the keep-list below. - if summary.handle_entry(lane.in_summary, core, line, &mut out) { + if summary.handle_entry(lanes.get(idx).in_summary, core, line, &mut out) { continue; } @@ -822,12 +866,15 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); if reactor_keep || keep_outside_block(core) { // Pre-emit the summary tail when we're about to write AGG. - summary.handle_aggregate(core, &mut out, &mut lane.in_summary); + summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); // Detect summary header so subsequent `[ERROR] ` entries get capped. - summary.handle_header(core, &mut lane.in_summary); + summary.handle_header(core, &mut lanes.get(idx).in_summary); out.push_str(line); out.push('\n'); - lane.keep_continuation = core.starts_with("[ERROR]") + // The armed per-lane flag is an owner claim in its own right: + // raw_owner routes (or verbatim-preserves) the raw indented + // continuations by scanning all lanes for it. + lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") && !core.starts_with("[ERROR] Tests run:") && !core.starts_with("[ERROR] Failures:") && !core.starts_with("[ERROR] Errors:"); @@ -835,8 +882,14 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { } // Dropped line (e.g. help boilerplate): reset so a stale flag can't // keep an indented line that follows a dropped `[ERROR]` line. - // Parity with filter_package's fall-through reset. - lane.keep_continuation = false; + // Parity with filter_package's fall-through reset. Known limit: a raw + // non-indented stray line (another module's stdout) landing in the + // one-line window between arm and `symbol:` also resets — with + // nothing open it routes to the armed lane and falls through here. + // Sequentially this reset is load-bearing; the interleaved stray is + // rare and costs the remaining continuations, never the `[ERROR]` + // diagnostic line itself. + lanes.get(idx).keep_continuation = false; } lanes.finish(&mut out); @@ -864,8 +917,8 @@ pub fn filter_compile(raw: &str) -> String { // as raw indented lines *after* the `[ERROR] … cannot find symbol` line, and // mvnd interleaves reactor modules, so a `[child-b] [INFO]` line landing in // between must not clear the flag armed by `[child-a] [ERROR]`. Compile - // never opens Surefire blocks, so `route` sends raw lines to the hot lane — - // the one that most recently armed a continuation. + // never opens Surefire blocks, so `route` resolves raw lines to the unique + // armed lane (or preserves them verbatim when several lanes are armed). let mut lanes = Lanes::new(); let mut seen_warnings: HashSet = HashSet::new(); @@ -875,7 +928,8 @@ pub fn filter_compile(raw: &str) -> String { let (key, core) = split_lane(line); let idx = match lanes.route(key) { Some(i) => i, - // Unreachable without Surefire blocks, but preserve for parity. + // Reachable when two modules are armed concurrently (a tie): + // preserve the raw line verbatim rather than guess an owner. None => { out.push_str(line); out.push('\n'); @@ -908,8 +962,9 @@ pub fn filter_compile(raw: &str) -> String { if core.starts_with("[ERROR]") { out.push_str(line); out.push('\n'); + // Armed flag is an owner claim scanned by raw_owner — no `hot` + // bookkeeping needed. lanes.get(idx).keep_continuation = true; - lanes.hot = idx; continue; } if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { @@ -967,10 +1022,8 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { Some(v) => v, None => continue, }; - let lane = lanes.get(idx); - // Failures-summary cap (see filter_surefire_with_cap for details). - if summary.handle_entry(lane.in_summary, core, line, &mut out) { + if summary.handle_entry(lanes.get(idx).in_summary, core, line, &mut out) { continue; } @@ -979,17 +1032,19 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { - summary.handle_aggregate(core, &mut out, &mut lane.in_summary); - summary.handle_header(core, &mut lane.in_summary); + summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); + summary.handle_header(core, &mut lanes.get(idx).in_summary); out.push_str(line); out.push('\n'); - lane.keep_continuation = core.starts_with("[ERROR]") + // Armed flag is an owner claim scanned by raw_owner — see + // filter_surefire_with_cap. + lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") && !core.starts_with("[ERROR] Tests run:") && !core.starts_with("[ERROR] Failures:") && !core.starts_with("[ERROR] Errors:"); continue; } - if lane.keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { + if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); continue; @@ -1001,10 +1056,10 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { out.push_str(line); out.push('\n'); } - lane.keep_continuation = false; + lanes.get(idx).keep_continuation = false; continue; } - lane.keep_continuation = false; + lanes.get(idx).keep_continuation = false; } lanes.finish(&mut out); @@ -1447,7 +1502,7 @@ mod tests { assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } - // The four tests below use inline strings rather than captures: they pin + // The tests below use inline strings rather than captures: they pin // *interleavings* (which module's line lands between which two others), // and a real `mvnd` run can't be made to emit a chosen interleaving on // demand — the captured fixtures above happen to keep each failure trail @@ -1585,6 +1640,373 @@ mod tests { assert_summary_cap_shared_across_lanes(filter_package_with_cap); } + /// The failures-summary budget spans the whole invocation: module + /// summaries that run back-to-back (child A opens and closes its summary + /// before child B opens) share one budget too — sequential lanes must not + /// each get a fresh `cap`. + fn assert_summary_cap_spans_sequential_lanes(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] ChildATest.one:11 boom a1\n\ + [child-a] [ERROR] ChildATest.two:12 boom a2\n\ + [child-a] [ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] ChildBTest.one:11 boom b1\n\ + [child-b] [ERROR] ChildBTest.two:12 boom b2\n\ + [child-b] [ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 2); + assert_eq!( + o.matches("boom ").count(), + 2, + "cap=2 bounds the whole run, sequential summaries included; got:\n{o}" + ); + assert!( + o.contains("… +2 more failures"), + "tail reports the dropped second-module entries; got:\n{o}" + ); + } + + #[test] + fn mvnd_failures_summary_cap_spans_sequential_lanes() { + assert_summary_cap_spans_sequential_lanes(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_failures_summary_cap_spans_sequential_lanes() { + assert_summary_cap_spans_sequential_lanes(filter_package_with_cap); + } + + /// A compile error surfacing inside a test/package run: the raw indented + /// `symbol:` / `location:` continuations must route back to the module + /// that armed them even when another module's line lands in between — + /// arming claims raw-line ownership on these paths too, not just in + /// `filter_compile`. + fn assert_interleaved_compile_continuation_survives(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] /C:/work/child-a/src/main/java/com/example/rtk/A.java:[7,9] cannot find symbol\n\ + [child-b] [INFO] Compiling 1 source file with javac [debug target 21] to target\\classes\n\ + \x20 symbol: variable bar\n\ + \x20 location: class com.example.rtk.A\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + o.contains("symbol: variable bar"), + "continuation survives the interleave; got:\n{o}" + ); + assert!( + o.contains("location: class com.example.rtk.A"), + "continuation survives the interleave; got:\n{o}" + ); + } + + #[test] + fn mvnd_surefire_interleaved_compile_continuation_survives() { + assert_interleaved_compile_continuation_survives(filter_surefire); + } + + #[test] + fn mvnd_package_interleaved_compile_continuation_survives() { + assert_interleaved_compile_continuation_survives(filter_package); + } + + // ── Exhaustive interleaving sweeps ────────────────────────────────────── + // + // mvnd's scheduler controls interleaving, not us: any order-preserving + // merge of two modules' output is a run that can really happen. Rather + // than pinning hand-picked orderings one review round at a time, sweep + // every merge and assert the failure signal survives all of them — + // routed into its lane or preserved verbatim, never dropped. + + /// All order-preserving merges of `a` and `b` (each module's own lines + /// keep their order; the interleaving varies). + fn merges<'a>(a: &[&'a str], b: &[&'a str]) -> Vec> { + fn rec<'a>( + a: &[&'a str], + b: &[&'a str], + prefix: &mut Vec<&'a str>, + out: &mut Vec>, + ) { + if a.is_empty() && b.is_empty() { + out.push(prefix.clone()); + return; + } + if let Some((&h, t)) = a.split_first() { + prefix.push(h); + rec(t, b, prefix, out); + prefix.pop(); + } + if let Some((&h, t)) = b.split_first() { + prefix.push(h); + rec(a, t, prefix, out); + prefix.pop(); + } + } + let mut out = Vec::new(); + rec(a, b, &mut Vec::new(), &mut out); + out + } + + fn sweep_input(merge: &[&str]) -> String { + format!( + "[INFO] Scanning for projects...\n{}\n[INFO] BUILD FAILURE\n", + merge.join("\n") + ) + } + + /// child-a: one failing class — Running, failing close, per-test subline, + /// raw exception message, raw user frame, blank trail terminator. Line + /// shapes copied from `mvnd_reactor_fail_raw.txt`. + const SWEEP_FAIL_A: [&str; 6] = [ + "[child-a] [INFO] Running com.example.rtk.ParallelFailTest", + "[child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.153 s <<< FAILURE! -- in com.example.rtk.ParallelFailTest", + "[child-a] [ERROR] com.example.rtk.ParallelFailTest.reactorDiagnostic -- Time elapsed: 0.098 s <<< FAILURE!", + "org.opentest4j.AssertionFailedError: parallel reactor diagnostic ==> expected: <1> but was: <2>", + "\tat com.example.rtk.ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)", + "[child-a] [INFO] ", + ]; + + /// child-b: two passing classes (open/close, open/close). + const SWEEP_PASS_B: [&str; 4] = [ + "[child-b] [INFO] Running com.example.rtk.PassBetaTest", + "[child-b] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.157 s -- in com.example.rtk.PassBetaTest", + "[child-b] [INFO] Running com.example.rtk.PassGammaTest", + "[child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.009 s -- in com.example.rtk.PassGammaTest", + ]; + + /// child-a variant: a compile error with raw indented continuations — + /// arming a continuation must survive racing another module's open block. + const SWEEP_COMPILE_A: [&str; 3] = [ + "[child-a] [ERROR] /C:/work/child-a/src/main/java/com/example/rtk/A.java:[7,9] cannot find symbol", + " symbol: variable bar", + " location: class com.example.rtk.A", + ]; + + /// child-b variant: one failing class of its own, for the capped sweep. + const SWEEP_FAIL_B: [&str; 6] = [ + "[child-b] [INFO] Running com.example.rtk.OtherFailTest", + "[child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.120 s <<< FAILURE! -- in com.example.rtk.OtherFailTest", + "[child-b] [ERROR] com.example.rtk.OtherFailTest.otherDiagnostic -- Time elapsed: 0.080 s <<< FAILURE!", + "org.opentest4j.AssertionFailedError: other reactor diagnostic ==> expected: <3> but was: <4>", + "\tat com.example.rtk.OtherFailTest.otherDiagnostic(OtherFailTest.java:8)", + "[child-b] [INFO] ", + ]; + + /// Failing module × passing module, all 210 merges: the failure's close + /// line, assertion message, and user frame survive every interleaving, + /// and the passing module's classes stay collapsed in every one. + fn assert_every_interleaving_keeps_diagnostics(filter: fn(&str) -> String) { + for (n, m) in merges(&SWEEP_FAIL_A, &SWEEP_PASS_B).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("expected: <1> but was: <2>") + && o.contains("ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)") + && o.contains("<<< FAILURE! -- in com.example.rtk.ParallelFailTest"), + "merge #{n} lost failure signal;\ninput:\n{i}\noutput:\n{o}" + ); + assert!( + !o.contains("PassBetaTest") && !o.contains("PassGammaTest"), + "merge #{n} leaked passing classes;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_every_interleaving_keeps_diagnostics() { + assert_every_interleaving_keeps_diagnostics(filter_surefire); + } + + #[test] + fn mvnd_package_every_interleaving_keeps_diagnostics() { + assert_every_interleaving_keeps_diagnostics(filter_package); + } + + /// Two failing modules under `cap = 1`, all 924 merges: whichever class + /// the cap admits keeps its full diagnostics in every interleaving — a + /// capped (dropping) trail in one lane must never swallow the admitted + /// lane's raw exception or frames — and the `+1 more` tail always + /// reports the capped class. + fn assert_every_interleaving_keeps_admitted_class(filter: fn(&str, usize) -> String) { + for (n, m) in merges(&SWEEP_FAIL_A, &SWEEP_FAIL_B).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i, 1); + let a = o.contains("<<< FAILURE! -- in com.example.rtk.ParallelFailTest"); + let b = o.contains("<<< FAILURE! -- in com.example.rtk.OtherFailTest"); + assert!( + a != b, + "merge #{n}: exactly one class admitted under cap=1;\ninput:\n{i}\noutput:\n{o}" + ); + let (msg, frame) = if a { + ( + "expected: <1> but was: <2>", + "ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)", + ) + } else { + ( + "expected: <3> but was: <4>", + "OtherFailTest.otherDiagnostic(OtherFailTest.java:8)", + ) + }; + assert!( + o.contains(msg) && o.contains(frame), + "merge #{n}: admitted class lost its diagnostics;\ninput:\n{i}\noutput:\n{o}" + ); + assert!( + o.contains("+1 more failing test classes"), + "merge #{n}: cap tail missing;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_every_interleaving_keeps_admitted_class() { + assert_every_interleaving_keeps_admitted_class(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_every_interleaving_keeps_admitted_class() { + assert_every_interleaving_keeps_admitted_class(filter_package_with_cap); + } + + /// Compile-error module × passing test module, all 35 merges: the raw + /// `symbol:` / `location:` continuations survive every interleaving — + /// in particular when they race another module's open Surefire block, + /// which must not buffer them into a green close that discards them. + fn assert_every_interleaving_keeps_compile_continuation(filter: fn(&str) -> String) { + for (n, m) in merges(&SWEEP_COMPILE_A, &SWEEP_PASS_B).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "merge #{n} lost compile continuation;\ninput:\n{i}\noutput:\n{o}" + ); + assert!( + !o.contains("PassBetaTest") && !o.contains("PassGammaTest"), + "merge #{n} leaked passing classes;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_every_interleaving_keeps_compile_continuation() { + assert_every_interleaving_keeps_compile_continuation(filter_surefire); + } + + #[test] + fn mvnd_package_every_interleaving_keeps_compile_continuation() { + assert_every_interleaving_keeps_compile_continuation(filter_package); + } + + /// Compile-error module × *failing* test module, all 84 merges: the armed + /// continuation claim must survive a failing close stealing `hot` — the + /// admitted class's diagnostics and the compile continuations both + /// survive every interleaving. + fn assert_every_interleaving_keeps_continuation_and_failure(filter: fn(&str) -> String) { + for (n, m) in merges(&SWEEP_COMPILE_A, &SWEEP_FAIL_B).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "merge #{n} lost compile continuation;\ninput:\n{i}\noutput:\n{o}" + ); + assert!( + o.contains("expected: <3> but was: <4>") + && o.contains("OtherFailTest.otherDiagnostic(OtherFailTest.java:8)") + && o.contains("<<< FAILURE! -- in com.example.rtk.OtherFailTest"), + "merge #{n} lost failure signal;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_every_interleaving_keeps_continuation_and_failure() { + assert_every_interleaving_keeps_continuation_and_failure(filter_surefire); + } + + #[test] + fn mvnd_package_every_interleaving_keeps_continuation_and_failure() { + assert_every_interleaving_keeps_continuation_and_failure(filter_package); + } + + /// Dropping-trail variant of the orphaned-continuation case, cap=1: A's + /// class is admitted, B's is capped (its trail is consuming raw lines + /// silently), and C arms a compile continuation. The continuations land + /// while B's dropping trail is active — an armed lane alongside a trail + /// is a tie, so they must be preserved verbatim, not swallowed by the + /// dropping trail. + fn assert_continuation_survives_dropping_trail(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.ParallelFailTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.153 s <<< FAILURE! -- in com.example.rtk.ParallelFailTest\n\ + org.opentest4j.AssertionFailedError: parallel reactor diagnostic ==> expected: <1> but was: <2>\n\ + [child-a] [INFO] \n\ + [child-b] [INFO] Running com.example.rtk.OtherFailTest\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.120 s <<< FAILURE! -- in com.example.rtk.OtherFailTest\n\ + [child-c] [ERROR] /C:/work/child-c/src/main/java/com/example/rtk/C.java:[7,9] cannot find symbol\n\ + \x20 symbol: variable bar\n\ + \x20 location: class com.example.rtk.C\n\ + [child-b] [INFO] \n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 1); + assert!( + o.contains("symbol: variable bar") && o.contains("location: class com.example.rtk.C"), + "continuations survive a concurrent dropping trail; got:\n{o}" + ); + assert!( + o.contains("expected: <1> but was: <2>"), + "admitted class keeps its diagnostics; got:\n{o}" + ); + assert!( + o.contains("+1 more failing test classes"), + "capped class reported in the tail; got:\n{o}" + ); + } + + #[test] + fn mvnd_continuation_survives_dropping_trail() { + assert_continuation_survives_dropping_trail(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_continuation_survives_dropping_trail() { + assert_continuation_survives_dropping_trail(filter_package_with_cap); + } + + /// Entering a Surefire block retires a lane's stale armed claim: a lane + /// that armed a continuation and then opened its own block must not hold + /// a permanent armed-vs-block tie that leaks its in-block stdout + /// verbatim past a green close. + fn assert_block_entry_retires_armed_claim(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] /C:/work/child-a/src/main/java/com/example/rtk/A.java:[7,9] cannot find symbol\n\ + [child-a] [INFO] Running com.example.rtk.PassAlphaTest\n\ + stray in-block stdout line\n\ + [child-a] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.026 s -- in com.example.rtk.PassAlphaTest\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + !o.contains("stray in-block stdout line"), + "green-closing block's stdout stays collapsed after arm retires; got:\n{o}" + ); + assert!( + o.contains("cannot find symbol"), + "the [ERROR] diagnostic line itself survives; got:\n{o}" + ); + } + + #[test] + fn mvnd_block_entry_retires_armed_claim() { + assert_block_entry_retires_armed_claim(filter_surefire); + } + + #[test] + fn mvnd_package_block_entry_retires_armed_claim() { + assert_block_entry_retires_armed_claim(filter_package); + } + // Snapshot regression tests locking the full filtered output of every // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the // substring assertions above document intent; the snapshots catch From d602a3bbe3adb806c33d9db424a80f8587130f8a Mon Sep 17 00:00:00 2001 From: kylehgc Date: Wed, 19 Aug 2026 09:06:06 -0400 Subject: [PATCH 07/14] fix(mvn): raw fall-through lines no longer disarm a pending continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keep-list fall-through reset of keep_continuation exists to stop a stale flag after a lane's own dropped [ERROR] line — but a raw line only reaches a lane *because of* its armed claim, so letting it clear that claim was self-defeating: another module's unprefixed stray stdout landing between "[ERROR] cannot find symbol" and its raw symbol:/location: continuations disarmed the claim and dropped the diagnostics. drive_surefire_line now reports whether the line was keyed (module prefix, routed by key) or raw (routed by ownership claim), and all three filter paths only rewrite keep_continuation on a lane's own keyed lines. The sequential stale-flag protection is unchanged; kept raw lines could never arm anyway (an [ERROR]-initial line would have been keyed). Regression: the raw stray swept through every position of the compile-error sequence, on filter_surefire, filter_package, and filter_compile (mvnd_[package_|compile_]raw_stray_does_not_disarm_ continuation). All three fail on the parent commit with only the code changes reverted; the four real-fixture insta snapshots stay byte-identical. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 136 ++++++++++++++++++++++++++++------------ 1 file changed, 97 insertions(+), 39 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 184e321aa7..3182de2a78 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -767,15 +767,19 @@ impl FailingClassCap { /// Shared per-line front half of `filter_surefire_with_cap` and /// `filter_package_with_cap`: route the line to its lane, drive the lane's /// Surefire block machine, and commit/drop failing closes against the -/// reactor-wide class cap. Returns `Some((lane index, core))` when the line -/// fell through to the caller's outside-block keep-list; `None` when it was -/// consumed — or preserved verbatim on ambiguous raw-line ownership. +/// reactor-wide class cap. Returns `Some((lane index, core, keyed))` when +/// the line fell through to the caller's outside-block keep-list — `keyed` +/// is whether the line carried a module prefix (routed by key) or was raw +/// (routed by ownership claim); callers must only disarm a lane's armed +/// continuation on that lane's own keyed lines, since a raw line reached +/// the lane *because of* the claim. `None` when the line was consumed — or +/// preserved verbatim on ambiguous raw-line ownership. fn drive_surefire_line<'a>( lanes: &mut Lanes<'a>, line: &'a str, classes: &mut FailingClassCap, out: &mut String, -) -> Option<(usize, &'a str)> { +) -> Option<(usize, &'a str, bool)> { let (key, core) = split_lane(line); let idx = match lanes.route(key) { Some(i) => i, @@ -814,7 +818,7 @@ fn drive_surefire_line<'a>( lanes.get(idx).keep_continuation = false; None } - SurefireStep::Passthrough => Some((idx, core)), + SurefireStep::Passthrough => Some((idx, core, key.is_some())), } } @@ -844,10 +848,11 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let mut in_reactor_summary = false; for line in stripped.lines() { - let (idx, core) = match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { - Some(v) => v, - None => continue, - }; + let (idx, core, keyed) = + match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { + Some(v) => v, + None => continue, + }; if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); @@ -873,23 +878,27 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { out.push('\n'); // The armed per-lane flag is an owner claim in its own right: // raw_owner routes (or verbatim-preserves) the raw indented - // continuations by scanning all lanes for it. - lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") - && !core.starts_with("[ERROR] Tests run:") - && !core.starts_with("[ERROR] Failures:") - && !core.starts_with("[ERROR] Errors:"); + // continuations by scanning all lanes for it. Only this lane's + // own keyed lines may rewrite the claim (a kept raw line can't + // arm anyway — starting with `[ERROR]` would have keyed it). + if keyed { + lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") + && !core.starts_with("[ERROR] Tests run:") + && !core.starts_with("[ERROR] Failures:") + && !core.starts_with("[ERROR] Errors:"); + } continue; } - // Dropped line (e.g. help boilerplate): reset so a stale flag can't - // keep an indented line that follows a dropped `[ERROR]` line. - // Parity with filter_package's fall-through reset. Known limit: a raw - // non-indented stray line (another module's stdout) landing in the - // one-line window between arm and `symbol:` also resets — with - // nothing open it routes to the armed lane and falls through here. - // Sequentially this reset is load-bearing; the interleaved stray is - // rare and costs the remaining continuations, never the `[ERROR]` - // diagnostic line itself. - lanes.get(idx).keep_continuation = false; + // Dropped keyed line (e.g. help boilerplate): reset so a stale flag + // can't keep an indented line that follows a dropped `[ERROR]` line. + // Parity with filter_package's fall-through reset. Raw fall-through + // lines never disarm: a raw line only routed here *because of* the + // armed claim, so letting it clear that claim would drop the + // `symbol:` / `location:` continuations whenever another module's + // stray stdout lands in the arm window. + if keyed { + lanes.get(idx).keep_continuation = false; + } } lanes.finish(&mut out); @@ -926,6 +935,7 @@ pub fn filter_compile(raw: &str) -> String { // Classify on the module-prefix-stripped view; emit the original so // module identity survives in mvnd parallel reactors. let (key, core) = split_lane(line); + let keyed = key.is_some(); let idx = match lanes.route(key) { Some(i) => i, // Reachable when two modules are armed concurrently (a tie): @@ -954,9 +964,12 @@ pub fn filter_compile(raw: &str) -> String { continue; } // Help boilerplate: drop before the `[ERROR]` catch-all (parity with - // keep_outside_block / filter_quiet). + // keep_outside_block / filter_quiet). Raw boilerplate must not + // disarm the claim that routed it here — see the fall-through reset. if is_boilerplate(core) { - lanes.get(idx).keep_continuation = false; + if keyed { + lanes.get(idx).keep_continuation = false; + } continue; } if core.starts_with("[ERROR]") { @@ -982,8 +995,12 @@ pub fn filter_compile(raw: &str) -> String { lanes.get(idx).keep_continuation = false; continue; } - // Drop everything else - lanes.get(idx).keep_continuation = false; + // Drop everything else. Only keyed lines disarm: a raw stray only + // routed to this lane because of its armed claim, and clearing it + // would drop the `symbol:` / `location:` continuations that follow. + if keyed { + lanes.get(idx).keep_continuation = false; + } } out @@ -1018,10 +1035,11 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { - let (idx, core) = match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { - Some(v) => v, - None => continue, - }; + let (idx, core, keyed) = + match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { + Some(v) => v, + None => continue, + }; // Failures-summary cap (see filter_surefire_with_cap for details). if summary.handle_entry(lanes.get(idx).in_summary, core, line, &mut out) { continue; @@ -1036,12 +1054,14 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { summary.handle_header(core, &mut lanes.get(idx).in_summary); out.push_str(line); out.push('\n'); - // Armed flag is an owner claim scanned by raw_owner — see - // filter_surefire_with_cap. - lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") - && !core.starts_with("[ERROR] Tests run:") - && !core.starts_with("[ERROR] Failures:") - && !core.starts_with("[ERROR] Errors:"); + // Armed flag is an owner claim scanned by raw_owner; only keyed + // lines rewrite it — see filter_surefire_with_cap. + if keyed { + lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") + && !core.starts_with("[ERROR] Tests run:") + && !core.starts_with("[ERROR] Failures:") + && !core.starts_with("[ERROR] Errors:"); + } continue; } if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { @@ -1050,6 +1070,8 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { continue; } if core.starts_with("[WARNING]") { + // `[WARNING]`-prefixed lines are always keyed (split_lane keys + // any `[`-initial log line), so this reset never hits a claim. let payload = core.strip_prefix("[WARNING] ").unwrap_or(core); let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { @@ -1059,7 +1081,10 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { lanes.get(idx).keep_continuation = false; continue; } - lanes.get(idx).keep_continuation = false; + // Raw fall-through lines never disarm — see filter_surefire_with_cap. + if keyed { + lanes.get(idx).keep_continuation = false; + } } lanes.finish(&mut out); @@ -2007,6 +2032,39 @@ mod tests { assert_block_entry_retires_armed_claim(filter_package); } + /// An unrelated raw stdout line (another module's, unprefixed) must not + /// disarm a pending continuation claim: raw fall-through lines never + /// reset `keep_continuation` — only a lane's own keyed lines do. Swept + /// through every position of the compile-error sequence, on all three + /// filter paths. + fn assert_raw_stray_does_not_disarm_continuation(filter: fn(&str) -> String) { + const STRAY: [&str; 1] = ["stray stdout from another reactor module"]; + for (n, m) in merges(&SWEEP_COMPILE_A, &STRAY).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "stray at position #{n} disarmed the continuation;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_raw_stray_does_not_disarm_continuation() { + assert_raw_stray_does_not_disarm_continuation(filter_surefire); + } + + #[test] + fn mvnd_package_raw_stray_does_not_disarm_continuation() { + assert_raw_stray_does_not_disarm_continuation(filter_package); + } + + #[test] + fn mvnd_compile_raw_stray_does_not_disarm_continuation() { + assert_raw_stray_does_not_disarm_continuation(filter_compile); + } + // Snapshot regression tests locking the full filtered output of every // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the // substring assertions above document intent; the snapshots catch From a69c66925e8255f7ad20e3df90202dfcf97d2c46 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Wed, 19 Aug 2026 10:01:50 -0400 Subject: [PATCH 08/14] fix(mvn): raw lines no longer disarm trail_rearm between detail blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same principle as the previous commit, applied to the other armed claim in the machine: trail_rearm carries a multi-failure class's keep/drop decision across its blank-separated per-test detail blocks, and step() disarmed it on any non-blank line — including a raw stray routed to the lane by ownership fallback. A stray landing between two detail blocks made the next per-test subline miss its trail re-entry, silently dropping its exception message (kept class) or leaking the block that should have been dropped (capped class). step() now takes the keyed flag and only disarms trail_rearm on the lane's own keyed lines. Per-test sublines are always keyed (they start with "[ERROR] "), so a raw line can neither be the re-entry line nor prove the trail is over. The sequential disarm paths are unchanged: "[INFO] Results:", RUNNING, and commit_failing/drop_failing all still clear it (surefire_rearm_disarms_at_results_boundary passes untouched). Regression: the multi-failure sequence x raw stray swept through all 10 positions on both the surefire and package entry points, plus the capped drop-side variant (the stray must not leak a capped class's second block through the [ERROR] catch-all). All four fail on the parent commit with only the code changes reverted; the four real-fixture insta snapshots stay byte-identical. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 115 +++++++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 13 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 3182de2a78..c0d177e25a 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -302,8 +302,10 @@ struct SurefireBlock<'a> { /// Set when a trail ends at a blank line; holds the `drop_trail` value so /// the next per-test subline of the same class re-enters the trail with /// the same keep/drop decision (a capped class must drop **all** its - /// per-test blocks, not just the first). Cleared by any non-blank - /// non-subline line, by `RUNNING`, and by `commit_failing`/`drop_failing`. + /// per-test blocks, not just the first). Cleared by the lane's own keyed + /// non-blank non-subline lines, by `RUNNING`, and by + /// `commit_failing`/`drop_failing` — never by raw lines, which reach the + /// lane only via routing fallback and can't speak for the trail. trail_rearm: Option, } @@ -338,7 +340,7 @@ impl<'a> SurefireBlock<'a> { /// line — identical to `line` outside mvnd parallel reactors); `line` is /// the original, which is what gets buffered and emitted so module /// identity survives in the output. - fn step(&mut self, line: &'a str, core: &str, out: &mut String) -> SurefireStep<'a> { + fn step(&mut self, line: &'a str, core: &str, keyed: bool, out: &mut String) -> SurefireStep<'a> { if PLUGIN_BANNER.is_match(core) { return SurefireStep::Consumed; } @@ -410,17 +412,27 @@ impl<'a> SurefireBlock<'a> { // let the blank fall through (outer keep-lists drop it). return SurefireStep::Passthrough; } - self.trail_rearm = None; // disarm unconditionally on non-blank (load-bearing) - if is_per_test_subline(core) { - self.failure_trail = true; - self.drop_trail = dropped; - if !dropped { - out.push_str(line); - out.push('\n'); + if keyed { + // Only the lane's own keyed lines disarm (load-bearing for + // the sequential stale-rearm case: `[INFO] Results:` etc.). + self.trail_rearm = None; + if is_per_test_subline(core) { + self.failure_trail = true; + self.drop_trail = dropped; + if !dropped { + out.push_str(line); + out.push('\n'); + } + return SurefireStep::Consumed; } - return SurefireStep::Consumed; + // Keyed non-subline: trail is over; already disarmed — fall + // through. } - // Non-subline: trail is over; already disarmed — fall through. + // Raw line: leave the rearm armed. A raw line reached this lane + // only via the routing fallback; per-test sublines are always + // keyed, so a raw stray can neither be the re-entry line nor + // prove the trail is over — letting it disarm would drop (kept + // class) or leak (capped class) every detail block after it. } SurefireStep::Passthrough @@ -792,7 +804,7 @@ fn drive_surefire_line<'a>( } }; - let step = lanes.get(idx).block.step(line, core, out); + let step = lanes.get(idx).block.step(line, core, key.is_some(), out); // A lane inside a Surefire block has no pending javac continuations: // entering a block retires any stale armed claim, so a single lane can't // hold a permanent armed-vs-block tie against raw-line routing. @@ -2065,6 +2077,83 @@ mod tests { assert_raw_stray_does_not_disarm_continuation(filter_compile); } + /// child-a variant: one class with TWO blank-separated per-test detail + /// blocks (Surefire's multi-failure shape) — `trail_rearm` carries the + /// keep/drop decision across the blanks. + const SWEEP_MULTIFAIL_A: [&str; 9] = [ + "[child-a] [INFO] Running com.example.rtk.MultiFailTest", + "[child-a] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in com.example.rtk.MultiFailTest", + "[child-a] [ERROR] com.example.rtk.MultiFailTest.first -- Time elapsed: 0.020 s <<< FAILURE!", + "org.opentest4j.AssertionFailedError: boomFirst ==> expected: <1> but was: <2>", + "[child-a] [INFO] ", + "[child-a] [ERROR] com.example.rtk.MultiFailTest.second -- Time elapsed: 0.030 s <<< FAILURE!", + "java.lang.IllegalStateException: boomSecond", + "\tat com.example.rtk.MultiFailTest.second(MultiFailTest.java:30)", + "[child-a] [INFO] ", + ]; + + const RAW_STRAY: [&str; 1] = ["stray stdout from another reactor module"]; + + /// Multi-failure class × raw stray, all 10 positions: a raw stray must + /// not disarm `trail_rearm` between detail blocks — per-test sublines + /// are always keyed, so a raw line can neither be the re-entry line nor + /// prove the trail is over. Pre-fix, a stray in the rearm window dropped + /// the second block's exception message. + fn assert_raw_stray_does_not_disarm_trail_rearm(filter: fn(&str) -> String) { + for (n, m) in merges(&SWEEP_MULTIFAIL_A, &RAW_STRAY).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("boomFirst") + && o.contains("boomSecond") + && o.contains("MultiFailTest.first") + && o.contains("MultiFailTest.second") + && o.contains("at com.example.rtk.MultiFailTest.second(MultiFailTest.java:30)"), + "stray at position #{n} broke the multi-failure trail;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_raw_stray_does_not_disarm_trail_rearm() { + assert_raw_stray_does_not_disarm_trail_rearm(filter_surefire); + } + + #[test] + fn mvnd_package_raw_stray_does_not_disarm_trail_rearm() { + assert_raw_stray_does_not_disarm_trail_rearm(filter_package); + } + + /// Drop side of the same claim: with the class capped, the stray must + /// not disarm the *dropping* rearm either — a capped class drops ALL its + /// detail blocks, and a disarm here leaks the second subline through the + /// `[ERROR]` catch-all. + fn assert_raw_stray_does_not_leak_capped_rearm(filter: fn(&str, usize) -> String) { + let mut m: Vec<&str> = SWEEP_MULTIFAIL_A[..5].to_vec(); + m.push(RAW_STRAY[0]); + m.extend_from_slice(&SWEEP_MULTIFAIL_A[5..]); + let i = sweep_input(&m); + let o = filter(&i, 0); + assert!( + !o.contains("MultiFailTest") && !o.contains("boom"), + "capped class stays fully dropped across the stray; got:\n{o}" + ); + assert!( + o.contains("+1 more failing test classes"), + "capped class still reported in the tail; got:\n{o}" + ); + } + + #[test] + fn mvnd_raw_stray_does_not_leak_capped_rearm() { + assert_raw_stray_does_not_leak_capped_rearm(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_raw_stray_does_not_leak_capped_rearm() { + assert_raw_stray_does_not_leak_capped_rearm(filter_package_with_cap); + } + // Snapshot regression tests locking the full filtered output of every // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the // substring assertions above document intent; the snapshots catch From 68d641a1f982cf536774c4acfbcac6b7695badb2 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Wed, 19 Aug 2026 10:09:58 -0400 Subject: [PATCH 09/14] test(mvn): complete the claimant matrix and CRLF coverage - Two concurrently armed lanes are a tie on their own: raw continuation lines are preserved verbatim rather than routed to a guess. Pinned on all three filter paths (in filter_compile this is the route -> None arm, previously exercised only via armed+trail / armed+block ties). - filter_compile gains the same CRLF equivalence test the surefire and package paths already had, against the real mvnd compile fixture. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index c0d177e25a..acc59859af 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -2154,6 +2154,40 @@ mod tests { assert_raw_stray_does_not_leak_capped_rearm(filter_package_with_cap); } + /// Two modules armed concurrently: raw continuation lines have no unique + /// owner (two armed lanes are a tie on their own) and must be preserved + /// verbatim rather than routed to a guess. Completes the claimant + /// matrix; in `filter_compile` this is the `route → None` arm. + fn assert_two_armed_lanes_preserve_continuations(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] /C:/work/child-a/src/main/java/com/example/rtk/A.java:[7,9] cannot find symbol\n\ + [child-b] [ERROR] /C:/work/child-b/src/main/java/com/example/rtk/B.java:[3,5] cannot find symbol\n\ + \x20 symbol: variable bar\n\ + \x20 location: class com.example.rtk.A\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "ambiguous continuations preserved verbatim; got:\n{o}" + ); + } + + #[test] + fn mvnd_two_armed_lanes_preserve_continuations() { + assert_two_armed_lanes_preserve_continuations(filter_surefire); + } + + #[test] + fn mvnd_package_two_armed_lanes_preserve_continuations() { + assert_two_armed_lanes_preserve_continuations(filter_package); + } + + #[test] + fn mvnd_compile_two_armed_lanes_preserve_continuations() { + assert_two_armed_lanes_preserve_continuations(filter_compile); + } + // Snapshot regression tests locking the full filtered output of every // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the // substring assertions above document intent; the snapshots catch @@ -2739,6 +2773,20 @@ mod tests { ); } + #[test] + fn compile_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_compile(&i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_compile(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + // ── Cap: failing-class blocks ──────────────────────────────────────────── /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect From 150845cb66c603ac1ff599dfa5391a19a151cf9e Mon Sep 17 00:00:00 2001 From: kylehgc Date: Sat, 29 Aug 2026 02:28:58 -0400 Subject: [PATCH 10/14] fix(mvn): close review findings -- lane routing, summary budgets, mvnd.cmd rewrite Review response for upstream #3199 (KuSh round + follow-up passes). All findings probe-verified and pinned by tests; every fix was proven load-bearing by reverting it and watching its test fail. Lane routing (split_lane / Lanes::route): - split_lane now enforces an exhaustive classification contract: [tag] [LEVEL] ... (non-empty, non-level tag) lane-keys; [LEVEL] ... and bare [LEVEL] blanks root-key; every other [-leading line (empty tag, non-level second bracket, bare bracket fragments like "[1, 2]") is raw so raw_owner's ownership rules decide. Closes four probe-confirmed silent-loss shapes where assertion-message fragments or app log lines vanished from a failing module's committed trail. - A never-seen tag only mints a lane on a genuine module-establishing line; lane count is capped (MAX_LANES=256, structural O(n) bound) and past-cap opener lines are preserved verbatim rather than guessed. - Continuation claims arm on any [ERROR] for the root lane (pre-PR plain mvn behavior, pinned) but only on a genuine compiler diagnostic (file.java:[line,col]) for tagged lanes, so phantom lanes stay inert. - The [WARNING] disarm sites are keyed-gated like every other disarm site; a foreign raw blank no longer terminates a tagged lane's trail (only keyed blanks or the root lane's raw blank do). Failures-summary budget (FailuresSummaryCap): - Generational budgets: each summary phase (Surefire, then Failsafe) gets its own reactor-wide budget of cap entries; a repeat header starts the next generation exactly once, interleaved headers share it. Fixes the Failsafe summary collapsing to "+N more" on mvn verify. - [ERROR] Errors:-only summaries now respect the cap (pre-existing gap in machinery this PR rebuilt). Discover: - mvnd.cmd classifies and rewrites (pattern + rewrite_prefixes, mirroring the mvn rule's mvnw.cmd handling; the old pattern never matched it). README: mvn (>=90%) and mvnd (>=60% floor, 64.9-76.5% actuals) savings claims stated separately; mvnd_compile_error fixture gains its savings assertion. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 982 +++++++++++++++++++++++++++++++++++---- src/discover/registry.rs | 24 + src/discover/rules.rs | 11 +- 4 files changed, 933 insertions(+), 86 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 08251e30ac..e5b8db4de1 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -36,7 +36,7 @@ Key behaviours: - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). - **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. -Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. +Token-savings tests run inline as part of `cargo test --all`. The `mvn` fixtures (gzipped, ~1100 lines each; `flate2`, already in `Cargo.toml`, decompresses the ~3 KB gzipped fixtures in milliseconds) verify ≥90% savings for `mvn test` and ≥85% for `mvn install`. The `mvnd` fixtures are smaller synthetic captures and are held to the repo-wide ≥60% savings floor instead (`mvnd_reactor_pass_savings`, `mvnd_test_fail_savings`, `mvnd_parallel_reactor_fail_savings`, `mvnd_compile_error_savings`; actuals run 64.9–76.5%). ### Integrity-check whitelist diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index acc59859af..85fd2251d0 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -20,6 +20,14 @@ use std::sync::LazyLock; /// entries — test-failure cap class, same binding as pytest/rspec/rake/runner. const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS; +/// Hard cap on [`Lanes`] growth. Real reactors have a bounded module count +/// (dozens, not thousands); 256 is generous headroom above any real build. +/// Invariant: `Lanes::route`'s linear scan is O(`MAX_LANES`), never +/// O(distinct tags seen) — once the cap is hit, no further tag mints a new +/// lane, so neither the lane count nor the per-line scan can grow with a +/// pathological input (e.g. thousands of uniquely-tagged log lines). +const MAX_LANES: usize = 256; + // ── Shared regex patterns ──────────────────────────────────────────────────── /// `[INFO] Running com.example.app.FooTest` @@ -169,18 +177,42 @@ fn is_blank_separator(line: &str) -> bool { /// `mvnd_reactor_fail_raw.txt`). Classification must therefore happen on the /// prefix-stripped view, and Surefire block state must be tracked per module. /// -/// Returns `(Some(key), core)` for log-level lines — `key` is the module tag -/// (`""` for unprefixed reactor-level lines) and `core` is the line with the -/// module prefix stripped, for classification — or `(None, line)` for raw -/// lines (stack traces, stray stdout), whose owning lane is resolved by -/// [`Lanes::raw_owner`] (unique owner, or preserved verbatim). +/// Exhaustive contract, in order: /// -/// Residual seam: a raw line of shape `[tag] text` where `tag` is not a log -/// level and `text` doesn't start with `[` (e.g. slf4j's `[main] INFO …` on -/// test stdout) keys to the root lane rather than classifying as raw, so -/// inside a trail it bypasses trail handling and falls to the root -/// keep-list. Real Surefire trail lines start with an FQCN, `at `, -/// `Caused by:`, or whitespace, so no realistic diagnostic takes this path. +/// - `[tag] [LEVEL] …` (tag non-empty and not itself a level, second bracket +/// a genuine level) → `(Some(tag), core)`, lane-keyed. An *empty* tag +/// (`[] [LEVEL] …`) is excluded and falls to the raw case below — `""` is +/// the root lane's reserved key ([`Lanes::new`] seeds it there), so an +/// empty tag would impersonate root-keyed routing and bypass `raw_owner` +/// the same way the non-level-shaped cases below do; mvnd never emits an +/// empty module tag. Real mvnd module tags are +/// followed by `[INFO]`/`[ERROR]`/etc. That alone doesn't rule out +/// look-alikes: application logging shaped `[main] [INFO] started …` (a +/// thread-name tag followed by a level bracket) is syntactically identical +/// to a real `[module] [INFO] …` mvnd line — no per-line check can tell +/// them apart. [`Lanes::route`] resolves the ambiguity using state this +/// function doesn't have: a **never-seen** tag is only admitted as a new +/// lane when its line is also a genuine module-establishing line +/// ([`is_lane_opener`]); otherwise it falls back to ownership rules like a +/// raw line. +/// - `[LEVEL] …` (single bracket, Maven's own unprefixed output — the tag +/// itself is the level, with more content after it) → `(Some(""), line)`, +/// root-keyed. +/// - bare `[LEVEL]` (trailing whitespace tolerated; no `"] "` substring at +/// all — the daemon-prefixed trail terminator, e.g. `[INFO] ` or, once +/// `"] "` is absent, just `[INFO]`) → `(Some(""), line)`, root-keyed. +/// Keeps the root lane's blank-terminator contract (`is_blank_separator`) +/// intact. +/// - any other `[`-leading line (bracket-shaped but neither of the above — +/// e.g. `[boom] weird bracket assertion line`, `[1, 2] != [1, 3]`, or a +/// bare `[1, 2]`/`[boom]` with no `"] "` substring at all — a +/// bracket-leading fragment of a multi-line assertion message, not +/// Maven's own output) → `(None, line)`, raw. `Lanes::raw_owner` decides +/// ownership like any other raw line, instead of unconditionally landing +/// on the root keep-list (root-keyed bypasses `raw_owner` entirely, so +/// such a line inside a *tagged* lane's active failure trail/block was +/// dropped: it never reached the trail-keeping logic). +/// - non-bracket-leading line → `(None, line)`, raw (checked first, below). fn split_lane(line: &str) -> (Option<&str>, &str) { if !line.starts_with('[') { return (None, line); @@ -188,12 +220,86 @@ fn split_lane(line: &str) -> (Option<&str>, &str) { if let Some(end) = line.find("] ") { let tag = &line[1..end]; let rest = &line[end + 2..]; - if !matches!(tag, "INFO" | "ERROR" | "WARNING" | "DEBUG" | "FATAL") && rest.starts_with('[') - { - return (Some(tag), rest); + if tag.is_empty() { + // `""` is the root lane's reserved key (`Lanes::new` seeds it + // there) — an empty tag would impersonate root-keyed routing + // and bypass `raw_owner` entirely. mvnd never emits an empty + // module tag, so `[] [LEVEL] …` is raw, not root-keyed. + return (None, line); + } + if !is_log_level(tag) { + if lane_rest_level(rest).is_some() { + return (Some(tag), rest); + } + return (None, line); } + return (Some(""), line); + } + // No "] " substring: root-key only Maven's own bare `[LEVEL]` blank + // (the daemon-prefixed trail terminator, trailing whitespace tolerated) + // — everything else `[`-leading with no "] " (e.g. `[1, 2]`, `[boom]`, + // a bracketed fragment of a multi-line assertion diff) is raw, not + // Maven's own output, so `Lanes::raw_owner` decides ownership. + let bare = line.trim_end(); + if bare + .strip_prefix('[') + .and_then(|r| r.strip_suffix(']')) + .is_some_and(is_log_level) + { + return (Some(""), line); } - (Some(""), line) + (None, line) +} + +fn is_log_level(tag: &str) -> bool { + matches!(tag, "INFO" | "ERROR" | "WARNING" | "DEBUG" | "FATAL") +} + +/// The bracketed tag at the start of `rest`, if it's a real log level — +/// i.e. `rest` looks like `[LEVEL] …` or (the no-trailing-space blank +/// spelling, `[tag] [INFO]` with nothing after) exactly `[LEVEL]`. Used to +/// confirm a candidate lane tag is genuinely followed by a level bracket, +/// not just any `[...]` — both spellings must be recognized so +/// `is_blank_separator`'s `[INFO]`-blank contract holds for a tagged lane +/// regardless of whether the daemon's trailing space survived. +fn lane_rest_level(rest: &str) -> Option<&str> { + let rest = rest.strip_prefix('[')?; + let level = match rest.find("] ") { + Some(end) => &rest[..end], + None => rest.strip_suffix(']')?, + }; + is_log_level(level).then_some(level) +} + +/// Whether `core` (the tag-stripped remainder of a candidate lane-keyed +/// line) is a genuine Maven/mvnd module-establishing line: a test class +/// starting (`RUNNING`), a plugin or module banner, a `Building` line, a +/// per-class result (`CLOSE`), or an `[ERROR]` diagnostic (compiler errors +/// are always the first line mvnd emits for a module that fails to +/// compile — there is no `RUNNING`/`Building` line first). `[tag] [LEVEL] …` +/// is syntactically identical whether `tag` is a real mvnd module or an +/// application thread/timestamp caught in test-captured stdout (see +/// `split_lane`); this heuristic only narrows the `[INFO]`-shaped case +/// (e.g. `[main] [INFO] …`), which is the common one in practice — real +/// module tags are always *introduced* by one of these lines before any +/// other content appears under them, so this is the signal +/// [`Lanes::route`] uses to admit a never-seen tag as a new lane rather +/// than falling back to raw-line ownership. An `[ERROR]`-shaped app log on +/// a never-seen tag (e.g. `[Server] [ERROR] connection refused`) still +/// mints a lane — but that lane is inert: a continuation claim only ever +/// arms on a genuine compiler diagnostic (see the `FILE_COORD` guard at +/// each `keep_continuation` arm site), so a one-off log line can open a +/// lane without ever contesting routing for it. Lane growth itself is +/// bounded by [`MAX_LANES`], so an adversarial run of uniquely-tagged +/// `[ERROR]` lines can mint at most that many lanes, keeping `route`'s scan +/// O(`MAX_LANES`) regardless of how many distinct tags the input contains. +fn is_lane_opener(core: &str) -> bool { + RUNNING.is_match(core) + || CLOSE.is_match(core) + || MODULE_BANNER.is_match(core) + || PLUGIN_BANNER.is_match(core) + || core.starts_with("[INFO] Building ") + || core.starts_with("[ERROR]") } /// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). @@ -339,13 +445,31 @@ impl<'a> SurefireBlock<'a> { /// Matching is done on `core` (the module-prefix-stripped view of the /// line — identical to `line` outside mvnd parallel reactors); `line` is /// the original, which is what gets buffered and emitted so module - /// identity survives in the output. - fn step(&mut self, line: &'a str, core: &str, keyed: bool, out: &mut String) -> SurefireStep<'a> { - if PLUGIN_BANNER.is_match(core) { + /// identity survives in the output. `keyed` is whether this line was + /// routed by its own module tag (see [`Lanes::route`]) rather than + /// falling back to ownership rules; `is_root` is whether this lane is + /// the root (untagged) lane, the only one plain `mvn` ever uses. + fn step( + &mut self, + line: &'a str, + core: &str, + keyed: bool, + is_root: bool, + out: &mut String, + ) -> SurefireStep<'a> { + // PLUGIN_BANNER/RUNNING/CLOSE only mean anything as *this lane's own* + // state transitions. A fallback-routed line (keyed == false) landed + // here via raw-line ownership rules, not because it's genuinely + // this lane's banner/class-start/class-end — treating its core as + // one anyway (e.g. a lane-cap-overflow module's own `Running`/close + // line, misrouted to an unrelated lane) would flush or discard that + // lane's real open block, or open a block that never closes. Not + // keyed: fall through and treat it as ordinary content instead. + if keyed && PLUGIN_BANNER.is_match(core) { return SurefireStep::Consumed; } - if RUNNING.is_match(core) { + if keyed && RUNNING.is_match(core) { if self.in_block { self.flush_open_block_as_keep(out); } @@ -360,30 +484,42 @@ impl<'a> SurefireBlock<'a> { } if self.in_block { - if let Some(caps) = CLOSE.captures(core) { - let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); - let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); - if fail || err { - let lines = std::mem::take(&mut self.block_lines); - let running = self.block_running.take(); + if keyed { + if let Some(caps) = CLOSE.captures(core) { + let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); + let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); + if fail || err { + let lines = std::mem::take(&mut self.block_lines); + let running = self.block_running.take(); + self.in_block = false; + return SurefireStep::FailingClose { + running, + lines, + close: line, + }; + } + self.block_lines.clear(); + self.block_running = None; self.in_block = false; - return SurefireStep::FailingClose { - running, - lines, - close: line, - }; + return SurefireStep::Consumed; } - self.block_lines.clear(); - self.block_running = None; - self.in_block = false; - return SurefireStep::Consumed; } self.block_lines.push(line); return SurefireStep::Consumed; } if self.failure_trail { - if is_blank_separator(core) { + // Invariant: a trail terminates on a blank line only when that + // blank is genuinely this lane's own — keyed (a tagged lane's + // `[tag] [INFO] ` blank, or any other keyed line reaching here) + // or the root lane (plain `mvn`'s terminator is *always* a raw, + // unkeyed empty line — mvnd is the only binary that prefixes + // blanks, so a tagged lane's real terminator is always keyed). + // A raw blank landing in a *tagged* lane's trail is foreign + // (another module's stray blank println, or a blank line inside + // a multi-line assertion message) and must not end the trail — + // it's just more trail content. + if is_blank_separator(core) && (keyed || is_root) { if !self.drop_trail { out.push('\n'); } @@ -517,21 +653,35 @@ impl<'a> SurefireBlock<'a> { /// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. /// -/// The budget is **reactor-wide**, not per module: a parallel reactor emits one -/// summary block per failing module, so each lane keeps its own `in_summary` -/// flag ([`SurefireLane::in_summary`]) while the entry count is shared. The -/// budget spans the whole filter invocation and never resets — whether module -/// summaries interleave or run back-to-back, the run keeps at most `cap` -/// entries total, never `modules × cap`. `dropped` alone resets when a tail is -/// emitted, so each `… +N more` reports the drops since the previous tail. -/// This also means a `verify` run whose Surefire and Failsafe phases each emit -/// a summary shares one budget across both — the second summary can get zero -/// entries, with the tail still reporting every drop. That is the documented -/// semantics: at most `cap` entries per run. +/// The budget is **reactor-wide within one generation**, not per module: a +/// parallel reactor emits one summary block per failing module, and each +/// module's *first* summary in a generation shares that generation's one +/// running budget — a 20-module reactor still keeps at most `cap` entries +/// total for that pass, never `modules × cap`, whether the modules' +/// summaries interleave or run back-to-back. But `mvn verify`/`install` runs +/// Surefire's summary then Failsafe's as two independent generations over +/// the *same* lanes — a lane's second `[ERROR] Failures:`/`Errors:` header +/// starts a new generation, not a continuation, so it gets a fresh `cap`. +/// Invariant: **each summary block keeps at most `cap` entries; entries +/// beyond that collapse to `… +N more failures`** — a generation's budget is +/// shared reactor-wide the first time each lane opens a summary in it, and a +/// new generation begins (with a fresh budget) exactly once per repeat +/// header, on whichever lane's header repeats first; every other lane's +/// header that then interleaves before the reset lane's `AGG` is treated as +/// that lane's first sighting *in the new generation* (no second reset) — +/// otherwise an interleaved reactor could double-reset mid-generation and +/// let one still-open block emit up to `2 × cap` entries. `dropped` alone +/// additionally resets when a tail is emitted, so each `… +N more` reports +/// the drops since the previous tail. struct FailuresSummaryCap { cap: usize, emitted: usize, dropped: usize, + /// Lanes that have opened a summary block in the *current generation*. + /// Cleared and re-seeded with just the triggering lane when the first + /// repeat header of a generation starts a new one — see + /// [`FailuresSummaryCap::handle_header`]. + seen_lanes: HashSet, } impl FailuresSummaryCap { @@ -540,6 +690,7 @@ impl FailuresSummaryCap { cap, emitted: 0, dropped: 0, + seen_lanes: HashSet::new(), } } @@ -562,13 +713,41 @@ impl FailuresSummaryCap { true } - /// Detect the `[ERROR] Failures:` header so subsequent `[ERROR] ` lines - /// get capped. Caller is responsible for writing the header to `out`. - fn handle_header(&mut self, line: &str, in_summary: &mut bool) { - if !line.starts_with("[ERROR] Failures:") || *in_summary { + /// Detect the `[ERROR] Failures:` or `[ERROR] Errors:` header so + /// subsequent `[ERROR] ` lines get capped — a run whose failures are + /// all thrown exceptions (no assertion failures) gets an `Errors:`-only + /// summary with no `Failures:` header at all, and must be capped the + /// same way. Caller is responsible for writing the header to `out`. + /// `lane` is the opening lane's index. + /// + /// Generation semantics: `seen_lanes` tracks lanes that have opened a + /// summary in the *current* generation (phase). A lane's first header in + /// a generation shares that generation's running budget (reactor-wide, + /// same as before). The first lane whose header repeats within the + /// current generation is the signal a new generation has begun (e.g. + /// Failsafe's summary following Surefire's): that starts a fresh + /// generation — `seen_lanes` is cleared (and re-seeded with just this + /// lane) and `emitted` resets once. Any *other* lane's header that then + /// interleaves before this lane's `AGG` line is a first sighting in the + /// new generation too (its `insert` succeeds), so it shares the new + /// generation's budget without triggering a second reset — otherwise an + /// interleaved reactor could reset `emitted` twice per generation and + /// let one lane's still-open block emit up to `2 × cap` entries. The + /// `*in_summary` guard above already prevents a mid-block `Errors:` + /// header (Maven can emit `Failures:` then `Errors:` as two sections of + /// one summary) from being treated as a new generation at all. + fn handle_header(&mut self, line: &str, in_summary: &mut bool, lane: usize) { + let is_header = + line.starts_with("[ERROR] Failures:") || line.starts_with("[ERROR] Errors:"); + if !is_header || *in_summary { return; } *in_summary = true; + if !self.seen_lanes.insert(lane) { + self.seen_lanes.clear(); + self.seen_lanes.insert(lane); + self.emitted = 0; + } } /// Pre-emit the `… +N more failures` tail when the aggregate @@ -631,6 +810,10 @@ struct Lanes<'a> { hot: usize, } +/// Index of the root (untagged, `""`) lane — always 0, by construction of +/// [`Lanes::new`]; never reassigned since lanes are only ever appended. +const ROOT_LANE: usize = 0; + impl<'a> Lanes<'a> { fn new() -> Self { Self { @@ -643,20 +826,51 @@ impl<'a> Lanes<'a> { &mut self.lanes[idx].1 } - /// Lane index for a line: by module key for log-level lines (creating the - /// lane on first sight), by ownership rules for raw lines. `None` means a - /// raw line's ownership is genuinely ambiguous and the caller must - /// preserve it verbatim rather than guess a lane that may drop it. - fn route(&mut self, key: Option<&'a str>) -> Option { + /// Lane index for a line, plus whether it was routed *by its own key* + /// (`true`) or fell back to ownership rules (`false`) — the latter must + /// be treated exactly like a raw line by callers (it may disarm a claim + /// or re-enter a trail only a genuinely keyed line for that lane may + /// touch). Keyed routing creates the lane on first sight, but only for a + /// tag whose line is a genuine module-establishing line per + /// [`is_lane_opener`] *and* only while under [`MAX_LANES`]; a never-seen + /// tag on an ordinary line falls back to ownership rules, since it may + /// be an application log line whose bracketed thread/timestamp only + /// looks like a module tag. `None` means the line's ownership is + /// genuinely ambiguous (a raw line with no unique claimant, or a + /// confirmed new module that can't be routed because the lane cap is + /// full) and the caller must preserve it verbatim rather than guess a + /// lane that may drop or corrupt it. + fn route(&mut self, key: Option<&'a str>, core: &str) -> Option<(usize, bool)> { match key { - Some(k) => Some(match self.lanes.iter().position(|(t, _)| *t == k) { - Some(i) => i, - None => { + Some(k) => { + if let Some(i) = self.lanes.iter().position(|(t, _)| *t == k) { + return Some((i, true)); + } + if !is_lane_opener(core) { + // Never-seen tag on an ordinary line: not a confirmed + // module — fall back to ownership rules instead of + // minting a phantom lane that would divert this line + // away from whichever lane's block/trail it actually + // belongs to. + return self.raw_owner().map(|i| (i, false)); + } + if self.lanes.len() < MAX_LANES { self.lanes.push((k, SurefireLane::new())); - self.lanes.len() - 1 + return Some((self.lanes.len() - 1, true)); } - }), - None => self.raw_owner(), + // A confirmed new module, but the lane cap is already full: + // preserve verbatim rather than guess an owner via + // raw_owner. Unlike the phantom-tag case above, this line's + // core genuinely looks like a state transition (`Running`, + // a close, a banner) — routing it onto an unrelated + // existing lane would corrupt that lane's block/trail, not + // just misattribute a bystander line. Ceiling cost: a 257th + // genuine module's own lines are preserved but ungrouped + // (over-keep), never dropped or misattributed onto another + // module's block (never data loss). + None + } + None => self.raw_owner().map(|i| (i, false)), } } @@ -781,8 +995,9 @@ impl FailingClassCap { /// Surefire block machine, and commit/drop failing closes against the /// reactor-wide class cap. Returns `Some((lane index, core, keyed))` when /// the line fell through to the caller's outside-block keep-list — `keyed` -/// is whether the line carried a module prefix (routed by key) or was raw -/// (routed by ownership claim); callers must only disarm a lane's armed +/// is whether the line was routed *by its own module tag* (see +/// [`Lanes::route`]) or fell back to ownership rules, whether raw or a +/// never-established tag; callers must only disarm a lane's armed /// continuation on that lane's own keyed lines, since a raw line reached /// the lane *because of* the claim. `None` when the line was consumed — or /// preserved verbatim on ambiguous raw-line ownership. @@ -793,8 +1008,8 @@ fn drive_surefire_line<'a>( out: &mut String, ) -> Option<(usize, &'a str, bool)> { let (key, core) = split_lane(line); - let idx = match lanes.route(key) { - Some(i) => i, + let (idx, keyed) = match lanes.route(key, core) { + Some(v) => v, None => { // Ambiguous ownership: preserve rather than risk dropping a // failing module's diagnostics into a passing block. @@ -804,7 +1019,7 @@ fn drive_surefire_line<'a>( } }; - let step = lanes.get(idx).block.step(line, core, key.is_some(), out); + let step = lanes.get(idx).block.step(line, core, keyed, idx == ROOT_LANE, out); // A lane inside a Surefire block has no pending javac continuations: // entering a block retires any stale armed claim, so a single lane can't // hold a permanent armed-vs-block tie against raw-line routing. @@ -830,7 +1045,7 @@ fn drive_surefire_line<'a>( lanes.get(idx).keep_continuation = false; None } - SurefireStep::Passthrough => Some((idx, core, key.is_some())), + SurefireStep::Passthrough => Some((idx, core, keyed)), } } @@ -885,7 +1100,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Pre-emit the summary tail when we're about to write AGG. summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); // Detect summary header so subsequent `[ERROR] ` entries get capped. - summary.handle_header(core, &mut lanes.get(idx).in_summary); + summary.handle_header(core, &mut lanes.get(idx).in_summary, idx); out.push_str(line); out.push('\n'); // The armed per-lane flag is an owner claim in its own right: @@ -894,10 +1109,20 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // own keyed lines may rewrite the claim (a kept raw line can't // arm anyway — starting with `[ERROR]` would have keyed it). if keyed { + // Invariant: on the root (untagged) lane, any `[ERROR]` line + // arms the claim — plain `mvn` never mints tagged lanes, so + // there is no phantom-tag risk there, and this preserves the + // pre-PR arming behavior exactly. On a tagged (mvnd module) + // lane, only a genuine compiler diagnostic + // (`file.java:[line,col]`) arms it — an `[ERROR]`-shaped app + // log (or the Surefire summary/close lines excluded below) + // can open a lane but can never arm it, so a one-off stray + // line can't hold a claim open. lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") && !core.starts_with("[ERROR] Tests run:") && !core.starts_with("[ERROR] Failures:") - && !core.starts_with("[ERROR] Errors:"); + && !core.starts_with("[ERROR] Errors:") + && (idx == ROOT_LANE || FILE_COORD.is_match(core)); } continue; } @@ -947,9 +1172,8 @@ pub fn filter_compile(raw: &str) -> String { // Classify on the module-prefix-stripped view; emit the original so // module identity survives in mvnd parallel reactors. let (key, core) = split_lane(line); - let keyed = key.is_some(); - let idx = match lanes.route(key) { - Some(i) => i, + let (idx, keyed) = match lanes.route(key, core) { + Some(v) => v, // Reachable when two modules are armed concurrently (a tie): // preserve the raw line verbatim rather than guess an owner. None => { @@ -961,7 +1185,14 @@ pub fn filter_compile(raw: &str) -> String { if MODULE_BANNER.is_match(core) { out.push_str(line); out.push('\n'); - lanes.get(idx).keep_continuation = false; + // Uniform with every other disarm site: only this lane's own + // keyed line may rewrite its claim. Provably unreachable with + // `keyed == false` today (`is_lane_opener` already requires a + // `MODULE_BANNER` match to establish a lane), but guarded here + // too so that invariant doesn't have to hold forever for safety. + if keyed { + lanes.get(idx).keep_continuation = false; + } continue; } if BUILD_FOOT.is_match(core) @@ -972,7 +1203,9 @@ pub fn filter_compile(raw: &str) -> String { { out.push_str(line); out.push('\n'); - lanes.get(idx).keep_continuation = false; + if keyed { + lanes.get(idx).keep_continuation = false; + } continue; } // Help boilerplate: drop before the `[ERROR]` catch-all (parity with @@ -988,8 +1221,12 @@ pub fn filter_compile(raw: &str) -> String { out.push_str(line); out.push('\n'); // Armed flag is an owner claim scanned by raw_owner — no `hot` - // bookkeeping needed. - lanes.get(idx).keep_continuation = true; + // bookkeeping needed. Root lane: any `[ERROR]` arms (pre-PR + // behavior, no phantom-tag risk on plain `mvn`). Tagged lane: + // only a genuine compiler diagnostic (`file.java:[line,col]`) + // arms — see the matching invariant on the Surefire/package arm + // sites. + lanes.get(idx).keep_continuation = idx == ROOT_LANE || FILE_COORD.is_match(core); continue; } if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { @@ -1004,7 +1241,15 @@ pub fn filter_compile(raw: &str) -> String { out.push_str(line); out.push('\n'); } - lanes.get(idx).keep_continuation = false; + // Only this lane's own keyed line may rewrite its claim — a + // `[tag] [WARNING] …` line for a never-established tag routes + // here (via raw_owner's fallback) with `keyed == false` when + // this lane happens to be armed, and clearing the claim on that + // raw-routed line would drop the `symbol:` / `location:` + // continuations that follow. See the fall-through reset below. + if keyed { + lanes.get(idx).keep_continuation = false; + } continue; } // Drop everything else. Only keyed lines disarm: a raw stray only @@ -1063,16 +1308,26 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); - summary.handle_header(core, &mut lanes.get(idx).in_summary); + summary.handle_header(core, &mut lanes.get(idx).in_summary, idx); out.push_str(line); out.push('\n'); // Armed flag is an owner claim scanned by raw_owner; only keyed // lines rewrite it — see filter_surefire_with_cap. if keyed { + // Invariant: on the root (untagged) lane, any `[ERROR]` line + // arms the claim — plain `mvn` never mints tagged lanes, so + // there is no phantom-tag risk there, and this preserves the + // pre-PR arming behavior exactly. On a tagged (mvnd module) + // lane, only a genuine compiler diagnostic + // (`file.java:[line,col]`) arms it — an `[ERROR]`-shaped app + // log (or the Surefire summary/close lines excluded below) + // can open a lane but can never arm it, so a one-off stray + // line can't hold a claim open. lanes.get(idx).keep_continuation = core.starts_with("[ERROR]") && !core.starts_with("[ERROR] Tests run:") && !core.starts_with("[ERROR] Failures:") - && !core.starts_with("[ERROR] Errors:"); + && !core.starts_with("[ERROR] Errors:") + && (idx == ROOT_LANE || FILE_COORD.is_match(core)); } continue; } @@ -1082,15 +1337,21 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { continue; } if core.starts_with("[WARNING]") { - // `[WARNING]`-prefixed lines are always keyed (split_lane keys - // any `[`-initial log line), so this reset never hits a claim. let payload = core.strip_prefix("[WARNING] ").unwrap_or(core); let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); } - lanes.get(idx).keep_continuation = false; + // Only this lane's own keyed line may rewrite its claim — a + // `[tag] [WARNING] …` line for a never-established tag reaches + // here via raw_owner's fallback (`keyed == false`) whenever this + // lane is the one currently armed, and clearing the claim on + // that raw-routed line would drop the `symbol:` / `location:` + // continuations that follow. See the fall-through reset below. + if keyed { + lanes.get(idx).keep_continuation = false; + } continue; } // Raw fall-through lines never disarm — see filter_surefire_with_cap. @@ -1714,6 +1975,154 @@ mod tests { assert_summary_cap_spans_sequential_lanes(filter_package_with_cap); } + /// Reviewer probe (upstream PR #3199 finding 1): `mvn verify`/`install` + /// emits two independent failures summaries in the same (unprefixed) + /// lane — Surefire's for unit-test failures, then Failsafe's for + /// integration-test failures. Each must get its own budget of `cap`: the + /// second summary must not start at zero remaining because the first + /// one already spent the whole invocation-wide budget. + #[test] + fn failures_summary_cap_resets_per_phase_on_same_lane() { + let mut i = String::from("[INFO] Scanning for projects...\n[ERROR] Failures:\n"); + for n in 1..=12 { + i.push_str(&format!("[ERROR] UnitTest.case{n}:{n} boom u{n}\n")); + } + i.push_str("[ERROR] Tests run: 12, Failures: 12, Errors: 0, Skipped: 0\n"); + i.push_str("[ERROR] Failures:\n"); + for n in 1..=12 { + i.push_str(&format!("[ERROR] ITTest.case{n}:{n} boom it{n}\n")); + } + i.push_str("[ERROR] Tests run: 12, Failures: 12, Errors: 0, Skipped: 0\n[INFO] BUILD FAILURE\n"); + + let o = filter_package(&i); + assert_eq!( + o.matches("boom u").count(), + 10, + "Surefire summary keeps its own 10 entries; got:\n{o}" + ); + assert_eq!( + o.matches("boom it").count(), + 10, + "Failsafe summary gets a fresh budget of 10, not zero; got:\n{o}" + ); + assert_eq!( + o.matches("… +2 more failures").count(), + 2, + "each phase reports its own 2 dropped entries; got:\n{o}" + ); + } + + /// Reviewer follow-up (upstream PR #3199, follow-up cold review): a run + /// whose failures are all thrown exceptions gets an `[ERROR] Errors:` + /// summary with no `Failures:` header at all — pre-fix, `handle_header` + /// only recognized `Failures:`, so this summary shape bypassed the + /// budget entirely (probe: 5/5 kept at cap=2). + fn assert_errors_only_summary_respects_cap(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [ERROR] Errors: \n\ + [ERROR] BoomTest.one:11 java.lang.IllegalStateException\n\ + [ERROR] BoomTest.two:12 java.lang.IllegalStateException\n\ + [ERROR] BoomTest.three:13 java.lang.IllegalStateException\n\ + [ERROR] BoomTest.four:14 java.lang.IllegalStateException\n\ + [ERROR] BoomTest.five:15 java.lang.IllegalStateException\n\ + [ERROR] Tests run: 5, Failures: 0, Errors: 5, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 2); + assert_eq!( + o.matches("java.lang.IllegalStateException").count(), + 2, + "cap=2 bounds an Errors-only summary same as a Failures one; got:\n{o}" + ); + assert!( + o.contains("… +3 more failures"), + "tail reports the 3 dropped entries; got:\n{o}" + ); + } + + #[test] + fn surefire_errors_only_summary_respects_cap() { + assert_errors_only_summary_respects_cap(filter_surefire_with_cap); + } + + #[test] + fn package_errors_only_summary_respects_cap() { + assert_errors_only_summary_respects_cap(filter_package_with_cap); + } + + /// Same phase-reset invariant as `failures_summary_cap_resets_per_phase_on_same_lane`, + /// but the second phase is headed by `[ERROR] Errors:` instead of + /// `[ERROR] Failures:` — the reset must not care which of the two + /// header spellings opened either phase. + #[test] + fn errors_header_resets_summary_cap_per_phase_same_as_failures_header() { + let mut i = String::from("[INFO] Scanning for projects...\n[ERROR] Failures:\n"); + for n in 1..=12 { + i.push_str(&format!("[ERROR] UnitTest.case{n}:{n} boom u{n}\n")); + } + i.push_str("[ERROR] Tests run: 12, Failures: 12, Errors: 0, Skipped: 0\n"); + i.push_str("[ERROR] Errors:\n"); + for n in 1..=12 { + i.push_str(&format!("[ERROR] ITTest.case{n}:{n} boom it{n}\n")); + } + i.push_str("[ERROR] Tests run: 0, Failures: 0, Errors: 12, Skipped: 0\n[INFO] BUILD FAILURE\n"); + + let o = filter_package(&i); + assert_eq!( + o.matches("boom u").count(), + 10, + "Failures:-headed phase keeps its own 10 entries; got:\n{o}" + ); + assert_eq!( + o.matches("boom it").count(), + 10, + "Errors:-headed phase gets a fresh budget of 10, not zero; got:\n{o}" + ); + assert_eq!( + o.matches("… +2 more failures").count(), + 2, + "each phase reports its own 2 dropped entries; got:\n{o}" + ); + } + + /// Reviewer follow-up (upstream PR #3199, final review round): child-a + /// and child-b both finish a first-generation summary (Surefire), then + /// child-a opens its second-generation summary (Failsafe) — the reset + /// generation boundary. Before child-a's second-generation summary + /// closes, child-b's own second-generation header interleaves in. That + /// must be treated as child-b's first sighting *in the new generation* + /// (sharing the budget child-a already spent), not a second reset — a + /// second reset would let child-a's still-open block emit up to + /// `2 × cap` entries total instead of `cap`. + #[test] + fn interleaved_second_generation_header_does_not_double_reset_budget() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [ERROR] Failures:\n\ + [child-a] [ERROR] entryA1\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [child-b] [ERROR] Failures:\n\ + [child-b] [ERROR] entryB1\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [child-a] [ERROR] Failures:\n\ + [child-a] [ERROR] entryA2\n\ + [child-a] [ERROR] entryA3\n\ + [child-b] [ERROR] Failures:\n\ + [child-a] [ERROR] entryA4\n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [child-b] [ERROR] entryB2\n\ + [child-b] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_package_with_cap(i, 2); + assert!( + o.contains("entryA2") && o.contains("entryA3"), + "second-generation entries under the cap survive; got:\n{o}" + ); + assert!( + !o.contains("entryA4"), + "child-a's second-generation block stays at cap=2, not 3 \ + (child-b's interleaved header must not double-reset the budget); got:\n{o}" + ); + } + /// A compile error surfacing inside a test/package run: the raw indented /// `symbol:` / `location:` continuations must route back to the module /// that armed them even when another module's line lands in between — @@ -2072,6 +2481,42 @@ mod tests { assert_raw_stray_does_not_disarm_continuation(filter_package); } + /// Reviewer blocker (upstream PR #3199, final review round): a + /// `[tag] [WARNING] …` line for a never-established tag (e.g. an app log + /// line, not a real module) reaches an armed lane via `raw_owner`'s + /// fallback with `keyed == false` — the `[WARNING]` dedup branch in + /// `filter_compile` and `filter_package` must not disarm on it, exactly + /// like every other disarm site. Swept through every position of the + /// compile-error sequence, on all three filter paths (`filter_surefire` + /// has no `[WARNING]` branch but shares the same fall-through reset). + fn assert_warning_interloper_does_not_disarm_continuation(filter: fn(&str) -> String) { + const WARNING_INTERLOPER: [&str; 1] = ["[main] [WARNING] connection pool exhausted"]; + for (n, m) in merges(&SWEEP_COMPILE_A, &WARNING_INTERLOPER).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "WARNING interloper at position #{n} disarmed the continuation;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_warning_interloper_does_not_disarm_continuation() { + assert_warning_interloper_does_not_disarm_continuation(filter_surefire); + } + + #[test] + fn mvnd_compile_warning_interloper_does_not_disarm_continuation() { + assert_warning_interloper_does_not_disarm_continuation(filter_compile); + } + + #[test] + fn mvnd_package_warning_interloper_does_not_disarm_continuation() { + assert_warning_interloper_does_not_disarm_continuation(filter_package); + } + #[test] fn mvnd_compile_raw_stray_does_not_disarm_continuation() { assert_raw_stray_does_not_disarm_continuation(filter_compile); @@ -2154,6 +2599,73 @@ mod tests { assert_raw_stray_does_not_leak_capped_rearm(filter_package_with_cap); } + const RAW_BLANK_STRAY: [&str; 1] = [""]; + + /// Reviewer blocker (upstream PR #3199, cold review round): a raw BLANK + /// line landing inside a *tagged* lane's failure trail must not be + /// mistaken for that lane's own `[tag] [INFO] ` terminator. mvnd always + /// prefixes a tagged lane's real blank terminator, so an unprefixed + /// blank reaching a tagged trail is foreign (another module's stray + /// blank println, or a blank line inside a multi-line assertion + /// message) — the trail must stay open until the lane's own keyed + /// terminator arrives. Pre-fix, the blank closed the trail early and + /// the remaining assertion message / user frame found no claim and were + /// dropped by the keep-lists. + fn assert_raw_blank_does_not_terminate_trail(filter: fn(&str) -> String) { + for (n, m) in merges(&SWEEP_FAIL_A, &RAW_BLANK_STRAY).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("expected: <1> but was: <2>") + && o.contains("ParallelFailTest.reactorDiagnostic(ParallelFailTest.java:10)"), + "raw blank at position #{n} truncated the trail;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_raw_blank_does_not_terminate_trail() { + assert_raw_blank_does_not_terminate_trail(filter_surefire); + } + + #[test] + fn mvnd_package_raw_blank_does_not_terminate_trail() { + assert_raw_blank_does_not_terminate_trail(filter_package); + } + + /// Drop side of the same claim: with the class capped (fully dropped), a + /// raw blank inside the trail must not end `drop_trail` mode early — + /// otherwise the remaining (still-supposed-to-be-suppressed) trail + /// content falls through unclaimed and can leak past the outer + /// keep-list instead of staying dropped. + fn assert_raw_blank_does_not_leak_capped_trail(filter: fn(&str, usize) -> String) { + // Insert the blank between the assertion message and the user frame + // — squarely inside the (dropped) trail. + let mut m: Vec<&str> = SWEEP_FAIL_A[..4].to_vec(); + m.push(RAW_BLANK_STRAY[0]); + m.extend_from_slice(&SWEEP_FAIL_A[4..]); + let i = sweep_input(&m); + let o = filter(&i, 0); + assert!( + !o.contains("ParallelFailTest") && !o.contains("expected: <1> but was: <2>"), + "capped class stays fully dropped across the raw blank; got:\n{o}" + ); + assert!( + o.contains("+1 more failing test classes"), + "capped class still reported in the tail; got:\n{o}" + ); + } + + #[test] + fn mvnd_raw_blank_does_not_leak_capped_trail() { + assert_raw_blank_does_not_leak_capped_trail(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_raw_blank_does_not_leak_capped_trail() { + assert_raw_blank_does_not_leak_capped_trail(filter_package_with_cap); + } + /// Two modules armed concurrently: raw continuation lines have no unique /// owner (two armed lanes are a tie on their own) and must be preserved /// verbatim rather than routed to a guess. Completes the claimant @@ -2230,6 +2742,17 @@ mod tests { assert!(!o.contains("BuildTimeEventSpy")); } + /// Parity with the other three mvnd fixtures' dedicated savings tests + /// (`mvnd_reactor_pass_savings`, `mvnd_test_fail_savings`, + /// `mvnd_parallel_reactor_fail_savings`) — same ≥60% floor. + #[test] + fn mvnd_compile_error_savings() { + let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); + let o = filter_compile(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); + } + // ── Surefire filter ────────────────────────────────────────────────────── #[test] @@ -2674,6 +3197,43 @@ mod tests { ); } + /// Reviewer follow-up (upstream PR #3199, final review round): the + /// `FILE_COORD` arming guard is scoped to *tagged* (mvnd module) lanes + /// only. On the root (untagged) lane — the only lane a plain `mvn` run + /// ever uses — any `[ERROR]` line still arms broadly, exactly as it did + /// pre-PR, so a non-compiler `[ERROR]` (no `file.java:[line,col]` + /// coordinate) with an indented raw continuation is still kept. + fn assert_root_lane_arms_broadly_without_file_coord(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [ERROR] Internal error: java.lang.IllegalStateException: broken plugin\n\ + \x20 at com.example.PluginImpl.run(PluginImpl.java:42)\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + o.contains("Internal error: java.lang.IllegalStateException"), + "the ERROR line itself survives; got:\n{o}" + ); + assert!( + o.contains("at com.example.PluginImpl.run(PluginImpl.java:42)"), + "root lane arms broadly (pre-PR behavior), so the raw continuation survives; got:\n{o}" + ); + } + + #[test] + fn surefire_root_lane_arms_broadly_without_file_coord() { + assert_root_lane_arms_broadly_without_file_coord(filter_surefire); + } + + #[test] + fn compile_root_lane_arms_broadly_without_file_coord() { + assert_root_lane_arms_broadly_without_file_coord(filter_compile); + } + + #[test] + fn package_root_lane_arms_broadly_without_file_coord() { + assert_root_lane_arms_broadly_without_file_coord(filter_package); + } + #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; @@ -3279,6 +3839,264 @@ mod tests { o ); } + + // ── split_lane / app-log false-positive lane guard ────────────────────── + + /// `[tag] [LEVEL] …` only counts as lane-shaped when the second bracket + /// is a genuine level — a non-level second bracket (or none at all) + /// falls through to the root lane exactly like a plain unprefixed line. + #[test] + fn split_lane_requires_real_level_in_second_bracket() { + assert_eq!( + split_lane("[child-a] [INFO] Running com.example.rtk.FooTest"), + (Some("child-a"), "[INFO] Running com.example.rtk.FooTest") + ); + assert_eq!( + split_lane("[main] [status] app started"), + (None, "[main] [status] app started") + ); + assert_eq!( + split_lane("[INFO] plain single-bracket line"), + (Some(""), "[INFO] plain single-bracket line") + ); + assert_eq!( + split_lane("at com.example.rtk.FooTest.bar(FooTest.java:1)"), + (None, "at com.example.rtk.FooTest.bar(FooTest.java:1)") + ); + // Bracket-shaped but not Maven's own `[LEVEL] ...` output and not a + // real `[tag] [LEVEL]` module line either: raw, not root-keyed. + assert_eq!( + split_lane("[boom] weird bracket assertion line"), + (None, "[boom] weird bracket assertion line") + ); + assert_eq!( + split_lane("[1, 2] != [1, 3]"), + (None, "[1, 2] != [1, 3]") + ); + // No `"] "` substring at all: only Maven's own bare `[LEVEL]` blank + // root-keys — a bracketed assertion-diff fragment with no `"] "` + // anywhere is raw, same as the `"] "`-present case above. + assert_eq!(split_lane("[INFO]"), (Some(""), "[INFO]")); + assert_eq!(split_lane("[1, 2]"), (None, "[1, 2]")); + assert_eq!(split_lane("[boom]"), (None, "[boom]")); + // Empty tag: `""` is the root lane's reserved key, so `[] [LEVEL] …` + // must not impersonate root-keyed routing — raw, not `(Some(""), …)`. + assert_eq!( + split_lane("[] [INFO] started"), + (None, "[] [INFO] started") + ); + } + + /// Reviewer probe (upstream PR #3199 finding 2): an application log line + /// shaped `[thread] [LEVEL] …` inside a failing test's captured stdout + /// must not mint a phantom lane and vanish from the committed block — + /// `main` was never established as a module (no `Running`/`Building`/ + /// banner/`[ERROR]` line for it), so it falls back to raw-line ownership + /// and rides along with the rest of the failing block's content. + #[test] + fn app_log_line_survives_inside_failing_block() { + let i = "[INFO] Running com.example.rtk.MyTest\n\ + [main] [INFO] app started with config=/etc/foo.yml\n\ + plain stdout line\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.MyTest\n\ + [ERROR] com.example.rtk.MyTest.testFoo -- Time elapsed: 0.05 s <<< FAILURE!\n\ + java.lang.AssertionError: expected <1> but was <2>\n\ + \tat com.example.rtk.MyTest.testFoo(MyTest.java:10)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!( + o.contains("app started with config=/etc/foo.yml"), + "app log line kept in the committed block; got:\n{o}" + ); + assert!( + o.contains("plain stdout line"), + "adjacent unbracketed stdout still kept; got:\n{o}" + ); + } + + /// A genuine mvnd module tag (established via its `Running` line) must + /// still lane-split correctly: interleaved output from two modules is + /// kept separate, so a passing class from one module never leaks under + /// the other's failing block. + #[test] + fn genuine_mvnd_module_tags_still_lane_split() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.FailTest\n\ + [child-b] [INFO] Running com.example.rtk.PassTest\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.010 s -- in com.example.rtk.PassTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.020 s <<< FAILURE! -- in com.example.rtk.FailTest\n\ + [child-a] [ERROR] com.example.rtk.FailTest.bar -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: <1> but was: <2>\n\ + \tat com.example.rtk.FailTest.bar(FailTest.java:5)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!( + o.contains("[child-a] [ERROR] Tests run: 1, Failures: 1"), + "failing module's close kept; got:\n{o}" + ); + assert!( + o.contains("expected: <1> but was: <2>"), + "failing module's assertion kept; got:\n{o}" + ); + assert!( + !o.contains("PassTest"), + "passing module's class collapsed, not leaked under child-a's block; got:\n{o}" + ); + } + + /// Reviewer blocker (upstream PR #3199, follow-up cold review): a + /// multi-line assertion message inside a *tagged* lane's failure trail + /// can contain bracket-leading continuation lines (`[1, 2] != [1, 3]`, + /// a collection diff; `[boom] weird bracket assertion line`) that are + /// bracket-shaped but not Maven's own `[LEVEL] ...` output. Pre-fix, + /// `split_lane` root-keyed these (bypassing `raw_owner` entirely), so + /// they landed on the root keep-list and were dropped even though + /// child-a's trail was active. They must now route as raw content and + /// survive as part of the trail, same as any other continuation line. + fn assert_bracket_leading_trail_line_survives(filter: fn(&str) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.FooTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.FooTest\n\ + [child-a] [ERROR] com.example.rtk.FooTest.bar -- Time elapsed: 0.05 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: collections differ\n\ + [1, 2] != [1, 3]\n\ + [boom] weird bracket assertion line\n\ + [1, 2]\n\ + [boom]\n\ + [] [INFO] started\n\ + \tat com.example.rtk.FooTest.bar(FooTest.java:5)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i); + assert!( + o.contains("[1, 2] != [1, 3]"), + "bracket-leading collection diff survives; got:\n{o}" + ); + assert!( + o.contains("[boom] weird bracket assertion line"), + "bracket-leading assertion fragment survives; got:\n{o}" + ); + // No `"] "` substring at all — the sibling shape split_lane must + // also not root-key (only a bare `[LEVEL]` blank may). + assert!( + o.lines().any(|l| l.trim_end() == "[1, 2]"), + "bare bracket diff line (no \"] \") survives; got:\n{o}" + ); + assert!( + o.lines().any(|l| l.trim_end() == "[boom]"), + "bare bracket fragment (no \"] \") survives; got:\n{o}" + ); + // Empty tag: `[] [INFO] started` must not impersonate the root + // lane and get swallowed by the root keep-list. + assert!( + o.contains("[] [INFO] started"), + "empty-tag line survives, not swallowed as root-keyed; got:\n{o}" + ); + assert!( + o.contains("at com.example.rtk.FooTest.bar(FooTest.java:5)"), + "user frame still survives too; got:\n{o}" + ); + } + + #[test] + fn surefire_bracket_leading_trail_line_survives() { + assert_bracket_leading_trail_line_survives(filter_surefire); + } + + #[test] + fn package_bracket_leading_trail_line_survives() { + assert_bracket_leading_trail_line_survives(filter_package); + } + + /// Reviewer probe (upstream PR #3199 finding 3): two `[tag] [ERROR] …` + /// app-log lines earlier in the output each mint a lane, but neither + /// looks like a compiler diagnostic, so neither ever arms a + /// continuation claim. A later real failing class's trail must then be + /// the sole, unambiguous claimant of its own raw frames: the single + /// user frame survives, and the framework frames (`org.junit.jupiter`, + /// `java.base/`) are stripped exactly as they would be with no phantom + /// lanes in play at all. + #[test] + fn phantom_lane_claim_never_arms_and_never_steals_a_real_trail() { + let i = "[INFO] Scanning for projects...\n\ + [Server] [ERROR] connection refused to db\n\ + [Cache] [ERROR] connection refused to cache\n\ + [INFO] Running com.example.rtk.RealTest\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.RealTest\n\ + [ERROR] com.example.rtk.RealTest.foo -- Time elapsed: 0.05 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.rtk.RealTest.foo(RealTest.java:5)\n\ + \tat org.junit.jupiter.engine.execution.SomeThing.invoke(SomeThing.java:1)\n\ + \tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!( + o.contains("at com.example.rtk.RealTest.foo(RealTest.java:5)"), + "the single user frame survives; got:\n{o}" + ); + assert!( + !o.contains("org.junit.jupiter"), + "junit framework frame must not leak; got:\n{o}" + ); + assert!( + !o.contains("java.base/"), + "java.base framework frame must not leak; got:\n{o}" + ); + } + + /// Finding 4: an adversarial run of uniquely-tagged `[ERROR]` lines + /// (each individually opener-shaped, per `is_lane_opener`) must not mint + /// unbounded lanes — `Lanes::route`'s scan stays `O(MAX_LANES)`, not + /// `O(distinct tags)`. Also exercises the public entry point end to end + /// to confirm the cap doesn't panic or drop output. + #[test] + fn lane_count_is_capped_on_pathological_input() { + let mut i = String::from("[INFO] Scanning for projects...\n"); + // Non-coordinate `[ERROR]` text deliberately: `is_lane_opener`'s + // blanket `[ERROR]`-prefix rule still mints a lane per tag (what + // this test measures), but without a `FILE_COORD` match these + // never arm `keep_continuation` — so the overflow-module assertion + // below isn't accidentally satisfied by an unrelated 255-way armed + // tie in `raw_owner` instead of exercising the fix it targets. + for n in 0..(MAX_LANES + 50) { + i.push_str(&format!("[tag{n}] [ERROR] connection refused for tag{n}\n")); + } + // One more never-seen tag, past the cap: a genuine failing test + // class (`Running` + failing close), not just a bare `[ERROR]` + // line — the shape the cold-review fix specifically targets + // (`Lanes::route` preferring `None` over `raw_owner` guesswork once + // the cap is exceeded, so this doesn't get misrouted onto an + // unrelated lane and corrupt or drop it). + i.push_str( + "[overflow-mod] [INFO] Running com.example.rtk.OverflowTest\n\ + [overflow-mod] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.01 s <<< FAILURE! -- in com.example.rtk.OverflowTest\n", + ); + i.push_str("[INFO] BUILD FAILURE\n"); + + let mut lanes = Lanes::new(); + let mut classes = FailingClassCap::new(MAX_MVN_FAILING_CLASSES); + let mut out = String::new(); + for line in i.lines() { + drive_surefire_line(&mut lanes, line, &mut classes, &mut out); + } + assert!( + lanes.lanes.len() <= MAX_LANES, + "lane count must stay capped at {MAX_LANES}, got {}", + lanes.lanes.len() + ); + + let o = filter_surefire(&i); + assert!(!o.is_empty(), "capped run still produces output"); + assert!( + o.contains("Running com.example.rtk.OverflowTest") + && o.contains("<<< FAILURE! -- in com.example.rtk.OverflowTest"), + "an overflow (past-cap) module's own failing diagnostics must survive \ + (preserved verbatim), not be lost to a misrouted guess; got:\n{o}" + ); + } } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 8612af4313..5822979928 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -4548,6 +4548,30 @@ mod tests { )); } + /// Upstream PR #3199 review, finding 5 — `mvnd.cmd` (mvnd's Windows + /// wrapper) must classify and rewrite to `rtk mvnd`, mirroring how the + /// mvn rule handles `mvnw.cmd`. `^mvnd\b` alone matches the `.` boundary + /// but can't then reach `\s+(compile|...)`, so it silently classified + /// as unsupported before `mvnd.cmd` was added to the pattern. + #[test] + fn test_classify_mvnd_cmd_wrapper() { + assert!(matches!( + classify_command("mvnd.cmd package"), + Classification::Supported { + rtk_equivalent: "rtk mvnd", + .. + } + )); + } + + #[test] + fn test_rewrite_mvnd_cmd_clean_install() { + assert_eq!( + rewrite_command_no_prefixes("mvnd.cmd clean install", &[]), + Some("rtk mvnd clean install".into()) + ); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index d7249edbda..16f829a9a5 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -782,10 +782,15 @@ pub const RULES: &[RtkRule] = &[ }, RtkRule { // `mvnd` is a separate binary, not a `mvn` wrapper — it must keep its - // own rtk_cmd so the daemon is what actually runs. - pattern: r"^mvnd\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", + // own rtk_cmd so the daemon is what actually runs. mvnd ships + // `mvnd.cmd` on Windows; listed explicitly (longer prefix first) on + // both the pattern and rewrite_prefixes, mirroring the mvn rule's + // `mvnw.cmd` handling — `^mvnd\b` alone matches the `.` boundary in + // `mvnd.cmd` but can't then reach `\s+(compile|...)`, so it silently + // fails to classify the command at all. + pattern: r"^(?:mvnd\.cmd|mvnd)\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", rtk_cmd: "rtk mvnd", - rewrite_prefixes: &["mvnd"], + rewrite_prefixes: &["mvnd.cmd", "mvnd"], category: "Build", savings_pct: 82.0, ..RtkRule::DEFAULT From 9e7e01d7dd04f53506d4bcfd02b9f8c461d54dd0 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Sat, 29 Aug 2026 15:39:26 -0400 Subject: [PATCH 11/14] fix(mvn): second review round -- root-lane disarm, per-lane summary tails, opener narrowing, drop insta Review response for upstream #3199 (KuSh round 2), plus preclear findings. - Root-lane claims disarm on any fall-through line again (pre-d602a3b behavior restored; tagged lanes keep the keyed-only rule). Fixes the plain-mvn frame leak on the repo's own fixtures -- filter_compile output is byte-identical to base ba7a9ce on mvn_test_fail_slice_raw.txt and mvn_test_multifail_slice_raw.txt, pinned by fixture-backed tests. Full base A/B: 56/56 outputs identical across all four entry points. - Failures-summary tails are attributed per lane: dropped moves onto SurefireLane, each lane's '+N more failures' tail flushes at that lane's own aggregate (or at end-of-stream for truncated input), while the reactor-wide entry budget stays shared. A module's tail can no longer be collected under another module's header. - is_lane_opener's [ERROR] arm narrowed to genuine compiler diagnostics (FILE_COORD), so [tag] [ERROR] app logs no longer escape a buffered block or get re-ordered ahead of it; they route by ownership rules. - The [ERROR] arm sites only rewrite keep_continuation on the lane's own keyed lines, matching every sibling arm/disarm site; interloper sweeps now cover both [WARNING] and [ERROR] spellings on all three filters. - insta removed (first dev-dependency, and .claude/rules/cli-testing.md prescribes plain assert_eq! with include_str! fixtures): the four snapshots are now expected-output fixtures under tests/fixtures/, Cargo.lock drops insta/console/encode_unicode/similar. - split_lane's no-trailing-space tagged blank pinned by tests. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 36 - Cargo.toml | 3 - src/cmds/jvm/mvn_cmd.rs | 647 +++++++++++++++--- .../fixtures/mvnd_compile_error_expected.txt | 4 - .../fixtures/mvnd_reactor_fail_expected.txt | 4 - .../fixtures/mvnd_reactor_pass_expected.txt | 4 - .../fixtures/mvnd_test_fail_expected.txt | 4 - 7 files changed, 552 insertions(+), 150 deletions(-) rename src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap => tests/fixtures/mvnd_compile_error_expected.txt (90%) rename src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap => tests/fixtures/mvnd_reactor_fail_expected.txt (96%) rename src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap => tests/fixtures/mvnd_reactor_pass_expected.txt (96%) rename src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap => tests/fixtures/mvnd_test_fail_expected.txt (96%) diff --git a/Cargo.lock b/Cargo.lock index af2b84ac72..cd7af9e28c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,17 +241,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -353,12 +342,6 @@ dependencies = [ "syn", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -685,18 +668,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -972,7 +943,6 @@ dependencies = [ "flate2", "getrandom 0.4.2", "ignore", - "insta", "libc", "oem_cp", "quick-xml", @@ -1148,12 +1118,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "siphasher" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 9412b5ec49..d5ed08fa1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,9 +54,6 @@ windows-sys = { version = "0.59", features = [ [build-dependencies] toml = "0.8" -[dev-dependencies] -insta = "1" - [profile.release] opt-level = 3 lto = true diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 85fd2251d0..9f5daa0037 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -274,8 +274,9 @@ fn lane_rest_level(rest: &str) -> Option<&str> { /// Whether `core` (the tag-stripped remainder of a candidate lane-keyed /// line) is a genuine Maven/mvnd module-establishing line: a test class /// starting (`RUNNING`), a plugin or module banner, a `Building` line, a -/// per-class result (`CLOSE`), or an `[ERROR]` diagnostic (compiler errors -/// are always the first line mvnd emits for a module that fails to +/// per-class result (`CLOSE`), or a genuine compiler diagnostic — a +/// `file.java:[line,col]`-coordinate `[ERROR]` line ([`FILE_COORD`]; compiler +/// errors are always the first line mvnd emits for a module that fails to /// compile — there is no `RUNNING`/`Building` line first). `[tag] [LEVEL] …` /// is syntactically identical whether `tag` is a real mvnd module or an /// application thread/timestamp caught in test-captured stdout (see @@ -284,22 +285,44 @@ fn lane_rest_level(rest: &str) -> Option<&str> { /// module tags are always *introduced* by one of these lines before any /// other content appears under them, so this is the signal /// [`Lanes::route`] uses to admit a never-seen tag as a new lane rather -/// than falling back to raw-line ownership. An `[ERROR]`-shaped app log on -/// a never-seen tag (e.g. `[Server] [ERROR] connection refused`) still -/// mints a lane — but that lane is inert: a continuation claim only ever -/// arms on a genuine compiler diagnostic (see the `FILE_COORD` guard at -/// each `keep_continuation` arm site), so a one-off log line can open a -/// lane without ever contesting routing for it. Lane growth itself is -/// bounded by [`MAX_LANES`], so an adversarial run of uniquely-tagged -/// `[ERROR]` lines can mint at most that many lanes, keeping `route`'s scan -/// O(`MAX_LANES`) regardless of how many distinct tags the input contains. +/// than falling back to raw-line ownership. +/// +/// Reviewer finding #1 (upstream PR #3199, second review round): a blanket +/// `[ERROR]`-prefix rule (no `FILE_COORD` requirement) let *any* +/// `[ERROR]`-shaped app log on a never-seen tag (e.g. `[Server] [ERROR] +/// connection refused`) mint a lane. The claim it opens stays inert either +/// way (a continuation only ever arms on a genuine compiler diagnostic — +/// see the `FILE_COORD` guard at each `keep_continuation` arm site), but +/// *minting the lane at all* was itself observable: routed to its own fresh +/// (empty, `in_block == false`) lane instead of falling back to +/// [`Lanes::raw_owner`], such a line bypassed whichever other lane's buffered +/// block was genuinely open around it — written to `out` immediately via the +/// outside-block keep-list while that other block was still buffering, +/// re-ordering it ahead of the `Running` line it followed in the input, and +/// on a green close (block discarded, not written at all) letting it survive +/// as a stray line the whole rest of the passing block was correctly dropped +/// for. Requiring `FILE_COORD` here fixes both: a non-diagnostic `[ERROR]` +/// line on a never-seen tag is no longer an opener, so `route` falls back to +/// `raw_owner`, which (with exactly one lane's block open) buffers it into +/// *that* lane at its correct input position — preserved in order on a +/// failing close, discarded with the rest on a green one. A genuine +/// `file.java:[line,col]` compiler diagnostic is unaffected (mvnd's +/// compile-error-first-sight case: no `Running`/`Building` line ever +/// precedes it). A Surefire close whose module is never otherwise seen +/// (`[ERROR] Tests run: … <<< FAILURE!`) is unaffected too — it's admitted +/// by the separate `CLOSE.is_match` arm above, not this one. +/// +/// Lane growth itself is bounded by [`MAX_LANES`] regardless, so an +/// adversarial run of uniquely-tagged opener-shaped lines can mint at most +/// that many lanes, keeping `route`'s scan O(`MAX_LANES`) regardless of how +/// many distinct tags the input contains. fn is_lane_opener(core: &str) -> bool { RUNNING.is_match(core) || CLOSE.is_match(core) || MODULE_BANNER.is_match(core) || PLUGIN_BANNER.is_match(core) || core.starts_with("[INFO] Building ") - || core.starts_with("[ERROR]") + || (core.starts_with("[ERROR]") && FILE_COORD.is_match(core)) } /// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). @@ -653,15 +676,31 @@ impl<'a> SurefireBlock<'a> { /// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. /// -/// The budget is **reactor-wide within one generation**, not per module: a -/// parallel reactor emits one summary block per failing module, and each -/// module's *first* summary in a generation shares that generation's one -/// running budget — a 20-module reactor still keeps at most `cap` entries -/// total for that pass, never `modules × cap`, whether the modules' -/// summaries interleave or run back-to-back. But `mvn verify`/`install` runs +/// The *budget* (`emitted`, how many entries may still be kept) is +/// **reactor-wide within one generation**, not per module: a parallel +/// reactor emits one summary block per failing module, and each module's +/// *first* summary in a generation shares that generation's one running +/// budget — a 20-module reactor still keeps at most `cap` entries total for +/// that pass, never `modules × cap`, whether the modules' summaries +/// interleave or run back-to-back. But `mvn verify`/`install` runs /// Surefire's summary then Failsafe's as two independent generations over /// the *same* lanes — a lane's second `[ERROR] Failures:`/`Errors:` header /// starts a new generation, not a continuation, so it gets a fresh `cap`. +/// +/// The *dropped-entry count* backing each `… +N more failures` tail is, by +/// contrast, **per lane** ([`SurefireLane::dropped`]) — the shared budget +/// says how many entries total may survive, but which module's entries got +/// dropped while spending that budget is per-module information, and each +/// module's own tail must report only its own drops, flushed at its own AGG +/// line. Reactor-wide `dropped` (the pre-fix design) let whichever lane's +/// AGG happened to arrive first flush *everyone's* outstanding drops under +/// its own header and zero the counter for every lane after it — dropping a +/// module's only failure with no tail to show for it, or crediting one +/// module's drops to another's summary. Attribution invariant: **a lane's +/// `… +N more failures` tail reports exactly the entries dropped while that +/// lane's own summary block was open, no more and no less** — even when +/// several lanes' summaries interleave and share one budget. +/// /// Invariant: **each summary block keeps at most `cap` entries; entries /// beyond that collapse to `… +N more failures`** — a generation's budget is /// shared reactor-wide the first time each lane opens a summary in it, and a @@ -670,13 +709,10 @@ impl<'a> SurefireBlock<'a> { /// header that then interleaves before the reset lane's `AGG` is treated as /// that lane's first sighting *in the new generation* (no second reset) — /// otherwise an interleaved reactor could double-reset mid-generation and -/// let one still-open block emit up to `2 × cap` entries. `dropped` alone -/// additionally resets when a tail is emitted, so each `… +N more` reports -/// the drops since the previous tail. +/// let one still-open block emit up to `2 × cap` entries. struct FailuresSummaryCap { cap: usize, emitted: usize, - dropped: usize, /// Lanes that have opened a summary block in the *current generation*. /// Cleared and re-seeded with just the triggering lane when the first /// repeat header of a generation starts a new one — see @@ -689,16 +725,25 @@ impl FailuresSummaryCap { Self { cap, emitted: 0, - dropped: 0, seen_lanes: HashSet::new(), } } /// If `core` is an `[ERROR] ` entry inside the calling lane's failures - /// summary, write `line` (the original, module prefix included) — or count - /// it as dropped — and return `true` so the caller skips its own keep-list. - /// Returns `false` otherwise. - fn handle_entry(&mut self, in_summary: bool, core: &str, line: &str, out: &mut String) -> bool { + /// summary, write `line` (the original, module prefix included) — or + /// increment `dropped` (the calling lane's own pending-drop count) — + /// and return `true` so the caller skips its own keep-list. Returns + /// `false` otherwise. `emitted` (the reactor-wide shared budget) is the + /// only thing that decides keep-vs-drop; `dropped` only tracks *whose* + /// tail reports the drop. + fn handle_entry( + &mut self, + in_summary: bool, + dropped: &mut usize, + core: &str, + line: &str, + out: &mut String, + ) -> bool { if !in_summary || !core.starts_with("[ERROR] ") { return false; } @@ -708,7 +753,7 @@ impl FailuresSummaryCap { out.push('\n'); self.emitted += 1; } else { - self.dropped += 1; + *dropped += 1; } true } @@ -735,7 +780,10 @@ impl FailuresSummaryCap { /// let one lane's still-open block emit up to `2 × cap` entries. The /// `*in_summary` guard above already prevents a mid-block `Errors:` /// header (Maven can emit `Failures:` then `Errors:` as two sections of - /// one summary) from being treated as a new generation at all. + /// one summary) from being treated as a new generation at all. A lane's + /// own `dropped` count is untouched here — it's per-lane and only ever + /// reset by that lane's own [`FailuresSummaryCap::handle_aggregate`] + /// flush, regardless of generation boundaries. fn handle_header(&mut self, line: &str, in_summary: &mut bool, lane: usize) { let is_header = line.starts_with("[ERROR] Failures:") || line.starts_with("[ERROR] Errors:"); @@ -750,39 +798,48 @@ impl FailuresSummaryCap { } } - /// Pre-emit the `… +N more failures` tail when the aggregate - /// `[ERROR] Tests run:` line is about to be written, then close this lane's - /// summary. Caller writes the AGG line itself afterwards. - fn handle_aggregate(&mut self, line: &str, out: &mut String, in_summary: &mut bool) { + /// Pre-emit the calling lane's own `… +N more failures` tail (from + /// `dropped`, that lane's own pending-drop count — never another lane's) + /// when the aggregate `[ERROR] Tests run:` line is about to be written, + /// then close this lane's summary and zero its `dropped`. Caller writes + /// the AGG line itself afterwards. + fn handle_aggregate( + &mut self, + line: &str, + dropped: &mut usize, + out: &mut String, + in_summary: &mut bool, + ) { if !*in_summary || !AGG.is_match(line) { return; } - if self.dropped > 0 { - out.push_str(&format!("\n… +{} more failures\n", self.dropped)); - self.dropped = 0; + if *dropped > 0 { + out.push_str(&format!("\n… +{} more failures\n", dropped)); + *dropped = 0; } *in_summary = false; } - - /// End-of-stream tail emission for cases where the AGG line never arrives - /// (truncated output). Emits the tail with no trailing newline guard so - /// the resulting filtered output is still well-formed. - fn finish(&mut self, out: &mut String) { - if self.dropped > 0 { - out.push_str(&format!("\n… +{} more failures\n", self.dropped)); - } - } } /// Per-module filter state for parallel reactors: each module gets its own /// Surefire block machine, continuation flag, and summary-open flag, because /// mvnd interleaves module output line-by-line (a `[child-b]` close can land /// between a `[child-a]` `Running` and its close). The failures-summary -/// *budget* is deliberately not here — see [`FailuresSummaryCap`]. +/// *shared budget* (`emitted`/`cap`) is deliberately not here — see +/// [`FailuresSummaryCap`] — but each lane's own *pending-drop count* +/// (`dropped`) is: it must be flushed as that lane's own `… +N more +/// failures` tail at that lane's own AGG line, never folded into or flushed +/// by another lane's. struct SurefireLane<'a> { block: SurefireBlock<'a>, keep_continuation: bool, in_summary: bool, + /// Entries dropped (cap exceeded) while *this* lane's failures summary + /// was open, not yet reported in a `… +N more failures` tail. Flushed + /// and reset to `0` by [`FailuresSummaryCap::handle_aggregate`] at this + /// lane's own AGG line — see the attribution invariant on + /// [`FailuresSummaryCap`]. + dropped: usize, } impl<'a> SurefireLane<'a> { @@ -791,6 +848,7 @@ impl<'a> SurefireLane<'a> { block: SurefireBlock::new(), keep_continuation: false, in_summary: false, + dropped: 0, } } } @@ -949,6 +1007,20 @@ impl<'a> Lanes<'a> { lane.block.finish(out); } } + + /// End-of-stream flush of every lane's own pending failures-summary + /// `dropped` count — covers truncated output where a lane's `[ERROR] + /// Failures:` block opened, entries were capped, but its own AGG line + /// never arrived. Per-lane, in lane order — see the attribution + /// invariant on [`FailuresSummaryCap`]. + fn finish_summaries(&mut self, out: &mut String) { + for (_, lane) in &mut self.lanes { + if lane.dropped > 0 { + out.push_str(&format!("\n… +{} more failures\n", lane.dropped)); + lane.dropped = 0; + } + } + } } /// Reactor-wide cap on emitted failing test classes, with the @@ -1089,18 +1161,24 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail // before AGG. The helper consumes only summary entries — other lines // (header, AGG) fall through to the keep-list below. - if summary.handle_entry(lanes.get(idx).in_summary, core, line, &mut out) { - continue; + { + let lane = lanes.get(idx); + if summary.handle_entry(lane.in_summary, &mut lane.dropped, core, line, &mut out) { + continue; + } } // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); if reactor_keep || keep_outside_block(core) { - // Pre-emit the summary tail when we're about to write AGG. - summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); + let lane = lanes.get(idx); + // Pre-emit this lane's own summary tail when we're about to + // write its own AGG (never another lane's — see the attribution + // invariant on `FailuresSummaryCap`). + summary.handle_aggregate(core, &mut lane.dropped, &mut out, &mut lane.in_summary); // Detect summary header so subsequent `[ERROR] ` entries get capped. - summary.handle_header(core, &mut lanes.get(idx).in_summary, idx); + summary.handle_header(core, &mut lane.in_summary, idx); out.push_str(line); out.push('\n'); // The armed per-lane flag is an owner claim in its own right: @@ -1128,18 +1206,25 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { } // Dropped keyed line (e.g. help boilerplate): reset so a stale flag // can't keep an indented line that follows a dropped `[ERROR]` line. - // Parity with filter_package's fall-through reset. Raw fall-through - // lines never disarm: a raw line only routed here *because of* the - // armed claim, so letting it clear that claim would drop the - // `symbol:` / `location:` continuations whenever another module's - // stray stdout lands in the arm window. - if keyed { + // Parity with filter_package's fall-through reset. On a *tagged* + // (mvnd module) lane, raw fall-through lines never disarm: a raw + // line only routed here *because of* the armed claim, so letting it + // clear that claim would drop the `symbol:` / `location:` + // continuations whenever another module's stray stdout lands in the + // arm window. The root (untagged) lane has no such ambiguity — it's + // the only lane a plain `mvn` run ever uses, so every raw line is + // genuinely this lane's own content — and disarms on any + // fall-through line, keyed or not: this is pre-d602a3b behavior, + // restored so a stale root-lane claim can't survive an intervening + // raw line (e.g. an exception message) and wrongly keep unrelated + // indented lines (e.g. stack frames) that follow it. + if keyed || idx == ROOT_LANE { lanes.get(idx).keep_continuation = false; } } lanes.finish(&mut out); - summary.finish(&mut out); + lanes.finish_summaries(&mut out); classes.finish(&mut out); out } @@ -1225,8 +1310,17 @@ pub fn filter_compile(raw: &str) -> String { // behavior, no phantom-tag risk on plain `mvn`). Tagged lane: // only a genuine compiler diagnostic (`file.java:[line,col]`) // arms — see the matching invariant on the Surefire/package arm - // sites. - lanes.get(idx).keep_continuation = idx == ROOT_LANE || FILE_COORD.is_match(core); + // sites. Only this lane's own keyed line may rewrite its claim + // (parity with every other disarm/arm site in this function): a + // never-established tag's `[ERROR]` app log (e.g. `[main] + // [ERROR] connection pool exhausted`) reaches here via + // `raw_owner`'s fallback with `keyed == false` whenever this + // lane is the uniquely armed one, and rewriting the claim on + // that raw-routed line would silently disarm it, dropping the + // `symbol:`/`location:` continuations that follow. + if keyed { + lanes.get(idx).keep_continuation = idx == ROOT_LANE || FILE_COORD.is_match(core); + } continue; } if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { @@ -1252,10 +1346,13 @@ pub fn filter_compile(raw: &str) -> String { } continue; } - // Drop everything else. Only keyed lines disarm: a raw stray only - // routed to this lane because of its armed claim, and clearing it - // would drop the `symbol:` / `location:` continuations that follow. - if keyed { + // Drop everything else. On a *tagged* lane, only keyed lines disarm: + // a raw stray only routed to this lane because of its armed claim, + // and clearing it would drop the `symbol:` / `location:` + // continuations that follow. The root lane disarms on any + // fall-through line — see the matching comment in + // `filter_surefire_with_cap`. + if keyed || idx == ROOT_LANE { lanes.get(idx).keep_continuation = false; } } @@ -1298,8 +1395,11 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { None => continue, }; // Failures-summary cap (see filter_surefire_with_cap for details). - if summary.handle_entry(lanes.get(idx).in_summary, core, line, &mut out) { - continue; + { + let lane = lanes.get(idx); + if summary.handle_entry(lane.in_summary, &mut lane.dropped, core, line, &mut out) { + continue; + } } // Order matters: call reactor_summary_keep first so its BUILD_FOOT @@ -1307,8 +1407,9 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { - summary.handle_aggregate(core, &mut out, &mut lanes.get(idx).in_summary); - summary.handle_header(core, &mut lanes.get(idx).in_summary, idx); + let lane = lanes.get(idx); + summary.handle_aggregate(core, &mut lane.dropped, &mut out, &mut lane.in_summary); + summary.handle_header(core, &mut lane.in_summary, idx); out.push_str(line); out.push('\n'); // Armed flag is an owner claim scanned by raw_owner; only keyed @@ -1354,14 +1455,15 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { } continue; } - // Raw fall-through lines never disarm — see filter_surefire_with_cap. - if keyed { + // Tagged-lane raw fall-through lines never disarm; the root lane + // disarms on any fall-through line — see filter_surefire_with_cap. + if keyed || idx == ROOT_LANE { lanes.get(idx).keep_continuation = false; } } lanes.finish(&mut out); - summary.finish(&mut out); + lanes.finish_summaries(&mut out); classes.finish(&mut out); out } @@ -1579,6 +1681,57 @@ mod tests { s.split_whitespace().count() } + /// Reviewer finding #3 (upstream PR #3199, second review round): d602a3b + /// gated every fall-through `keep_continuation` disarm on `keyed`, + /// protecting mvnd's tagged lanes — but the root (untagged) lane, the + /// only one plain `mvn` ever uses, has no raw-line ambiguity to protect: + /// every raw line genuinely belongs to it. Gating its disarm too let a + /// stale claim (armed by any `[ERROR]` line, root lane arms broadly — + /// see `assert_root_lane_arms_broadly_without_file_coord`) survive an + /// intervening raw, non-indented line (here, a JUnit assertion message) + /// and then wrongly keep unrelated indented lines that follow it (here, + /// stack frames misread as compile-error `symbol:`/`location:` + /// continuations). Probed through `filter_compile` — it shares the same + /// `Lanes`/`keep_continuation` machinery as `filter_surefire`/ + /// `filter_package` and has no Surefire block state to obscure the + /// effect. Byte/frame counts pinned to the exact numbers measured + /// against merge-base ba7a9ce (pre-d602a3b: 0 frames kept, byte-identical + /// output) and against the d602a3b-era regression (10 / 17 frames leaked, + /// output grown to 1588 / 2391 bytes) — a regression on either axis fails + /// this test. + fn assert_root_lane_disarms_on_raw_trail_line(fixture: &str, expected_bytes: usize) { + let o = filter_compile(fixture); + let frames = o + .lines() + .filter(|l| l.trim_start().starts_with("at ")) + .count(); + assert_eq!( + frames, 0, + "stray stack frames leaked through as compile continuations;\noutput:\n{o}" + ); + assert_eq!( + o.len(), + expected_bytes, + "output size drifted from the known-good (pre-d602a3b) baseline;\noutput:\n{o}" + ); + } + + #[test] + fn root_lane_disarms_on_raw_trail_line_single_failure() { + assert_root_lane_disarms_on_raw_trail_line( + include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"), + 822, + ); + } + + #[test] + fn root_lane_disarms_on_raw_trail_line_multi_failure() { + assert_root_lane_disarms_on_raw_trail_line( + include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"), + 1254, + ); + } + fn gunzip(bytes: &[u8]) -> String { let mut s = String::new(); GzDecoder::new(bytes) @@ -1903,10 +2056,21 @@ mod tests { ); } - /// The `[ERROR] Failures:` cap is reactor-wide: two modules' summaries - /// interleaving must share one budget, not get `modules × cap` entries. + /// The `[ERROR] Failures:` *budget* is reactor-wide: two modules' + /// summaries interleaving must share one budget, not get `modules × cap` + /// entries. But the `… +N more failures` *tail* is attributed per lane + /// (reviewer finding #3199, second round): each module's own tail + /// reports only its own drops, flushed at its own AGG line — here + /// child-a and child-b each drop exactly one entry while sharing the + /// cap=2 budget, so each gets its own `… +1 more failures`, not one + /// combined `… +2 more failures` sitting under whichever AGG happened + /// to arrive first. fn assert_summary_cap_shared_across_lanes(filter: fn(&str, usize) -> String) { let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ + [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-b] [ERROR] Failures: \n\ @@ -1922,9 +2086,14 @@ mod tests { 2, "cap=2 bounds the whole reactor, not each module; got:\n{o}" ); + assert_eq!( + o.matches("… +1 more failures").count(), + 2, + "each lane reports only its own drop, not a combined tail on whichever lane's AGG arrived first; got:\n{o}" + ); assert!( - o.contains("… +2 more failures"), - "tail counts both dropped entries; got:\n{o}" + !o.contains("… +2 more failures"), + "no lane's tail should absorb another lane's drop; got:\n{o}" ); } @@ -1938,12 +2107,97 @@ mod tests { assert_summary_cap_shared_across_lanes(filter_package_with_cap); } + /// Reviewer finding #2 (upstream PR #3199, second review round), exact + /// probe: cap=2, child-a has 3 failures, child-b has 1 — and by the time + /// child-b's *own* single entry (B1) arrives, child-a's first two + /// entries have already spent the whole shared budget, so B1 is + /// dropped. child-b's AGG line then arrives before child-a's. + /// + /// Attribution invariant (see the doc comment on + /// [`FailuresSummaryCap`]): each lane's `… +N more failures` tail + /// reports exactly the entries dropped while *that* lane's own summary + /// block was open, flushed at *that* lane's own AGG — never another + /// lane's. Pre-fix (reactor-wide `dropped`, flushed once at whichever + /// AGG arrived first): B1 vanished with no tail under child-b's + /// zero-entry header, the flush under child-b's AGG double-counted + /// child-a's still-pending drop, and child-a's own AGG then found + /// `dropped` already zeroed and emitted no tail at all — a module's + /// only failure silently disappearing is the core wrong this fixes. + /// Post-fix: child-b's header is legitimately empty (its one entry lost + /// the shared-budget race) but is followed by its own `… +1 more + /// failures`, and child-a's block keeps its two entries and reports its + /// own `… +1 more failures` for the third — every drop is accounted for + /// under the lane that actually dropped it, and no header is left + /// looking complete when it silently isn't. + fn assert_shared_budget_race_attributes_tails_per_lane(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ + [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] ChildATest.one:11 boom a1\n\ + [child-a] [ERROR] ChildATest.two:12 boom a2\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] ChildBTest.one:11 boom b1\n\ + [child-a] [ERROR] ChildATest.three:13 boom a3\n\ + [child-b] [INFO] \n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [child-a] [INFO] \n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 2); + assert_eq!( + o.matches("boom ").count(), + 2, + "shared budget still bounds the whole reactor at 2 kept entries; got:\n{o}" + ); + assert!( + !o.contains("boom b1"), + "B1 lost the shared-budget race, same as pre-fix; got:\n{o}" + ); + assert_eq!( + o.matches("… +1 more failures").count(), + 2, + "child-a and child-b each report exactly their own one drop, not a combined or misattributed tail; got:\n{o}" + ); + assert!( + !o.contains("… +2 more failures"), + "no lane's tail should double-count another lane's drop; got:\n{o}" + ); + // child-b's header is legitimately empty (its only entry lost the + // race) — but must never look *complete*: its own tail must follow + // directly, not vanish silently. + let b_header = o.find("[child-b] [ERROR] Failures:").expect("child-b header kept"); + let b_agg = o + .find("[child-b] [ERROR] Tests run:") + .expect("child-b AGG kept"); + assert!( + o[b_header..b_agg].contains("… +1 more failures"), + "child-b's zero-entry header is followed by its own tail before its AGG; got:\n{o}" + ); + } + + #[test] + fn mvnd_shared_budget_race_attributes_tails_per_lane() { + assert_shared_budget_race_attributes_tails_per_lane(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_shared_budget_race_attributes_tails_per_lane() { + assert_shared_budget_race_attributes_tails_per_lane(filter_package_with_cap); + } + /// The failures-summary budget spans the whole invocation: module /// summaries that run back-to-back (child A opens and closes its summary /// before child B opens) share one budget too — sequential lanes must not /// each get a fresh `cap`. fn assert_summary_cap_spans_sequential_lanes(filter: fn(&str, usize) -> String) { let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ + [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-a] [ERROR] ChildATest.two:12 boom a2\n\ @@ -2049,6 +2303,59 @@ mod tests { assert_errors_only_summary_respects_cap(filter_package_with_cap); } + /// Cold-preclear finding (🟡 2): truncated output — a lane's failures + /// summary opened, entries got capped, but its own AGG line never + /// arrives before the stream ends. `Lanes::finish_summaries` must still + /// flush that lane's own pending `dropped` count as a `… +N more + /// failures` tail, attributed to the right lane — not silently lost, and + /// not folded into another lane's count. Two lanes truncate at once here + /// (`child-a` drops 1, `child-b` drops 2) to prove the per-lane + /// attribution invariant (see the doc comment on `FailuresSummaryCap`) + /// survives into the end-of-stream path too, not just the AGG-driven one + /// `handle_aggregate` covers. + fn assert_truncated_summary_flushes_per_lane_tail(filter: fn(&str, usize) -> String) { + let i = "[INFO] BUILD FAILURE\n\ + [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ + [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] ChildATest.one:11 boom a1\n\ + [child-a] [ERROR] ChildATest.two:12 boom a2\n\ + [child-a] [ERROR] ChildATest.three:13 boom a3\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] ChildBTest.one:11 boom b1\n\ + [child-b] [ERROR] ChildBTest.two:12 boom b2\n"; + let o = filter(i, 2); + assert_eq!( + o.matches("boom ").count(), + 2, + "shared budget still bounds kept entries at 2; got:\n{o}" + ); + assert!( + o.contains("… +1 more failures"), + "child-a's own truncated drop (1 entry) is flushed at end-of-stream; got:\n{o}" + ); + assert!( + o.contains("… +2 more failures"), + "child-b's own truncated drop (2 entries) is flushed at end-of-stream; got:\n{o}" + ); + assert!( + !o.contains("… +3 more failures"), + "the two lanes' truncated drops must not be folded into one combined tail; got:\n{o}" + ); + } + + #[test] + fn mvnd_truncated_summary_flushes_per_lane_tail() { + assert_truncated_summary_flushes_per_lane_tail(filter_surefire_with_cap); + } + + #[test] + fn mvnd_package_truncated_summary_flushes_per_lane_tail() { + assert_truncated_summary_flushes_per_lane_tail(filter_package_with_cap); + } + /// Same phase-reset invariant as `failures_summary_cap_resets_per_phase_on_same_lane`, /// but the second phase is headed by `[ERROR] Errors:` instead of /// `[ERROR] Failures:` — the reset must not care which of the two @@ -2522,6 +2829,44 @@ mod tests { assert_raw_stray_does_not_disarm_continuation(filter_compile); } + /// Cold-preclear finding: a `[tag] [ERROR] …` app-log line for a + /// never-established tag (e.g. `[main] [ERROR] connection pool + /// exhausted`, no `FILE_COORD` coordinate — not an opener) reaches an + /// armed lane via `raw_owner`'s fallback with `keyed == false`, exactly + /// like the `[WARNING]` interloper above. `filter_compile`'s `[ERROR]` + /// arm/rewrite site was missing the `keyed` guard every sibling site has + /// (surefire, package, and `filter_compile`'s own `[WARNING]`/banner/ + /// fall-through sites) and silently disarmed on it, dropping the + /// `symbol:`/`location:` continuations that follow. Swept through every + /// position of the compile-error sequence, on all three filter paths. + fn assert_error_interloper_does_not_disarm_continuation(filter: fn(&str) -> String) { + const ERROR_INTERLOPER: [&str; 1] = ["[main] [ERROR] connection pool exhausted"]; + for (n, m) in merges(&SWEEP_COMPILE_A, &ERROR_INTERLOPER).iter().enumerate() { + let i = sweep_input(m); + let o = filter(&i); + assert!( + o.contains("symbol: variable bar") + && o.contains("location: class com.example.rtk.A"), + "ERROR interloper at position #{n} disarmed the continuation;\ninput:\n{i}\noutput:\n{o}" + ); + } + } + + #[test] + fn mvnd_error_interloper_does_not_disarm_continuation() { + assert_error_interloper_does_not_disarm_continuation(filter_surefire); + } + + #[test] + fn mvnd_compile_error_interloper_does_not_disarm_continuation() { + assert_error_interloper_does_not_disarm_continuation(filter_compile); + } + + #[test] + fn mvnd_package_error_interloper_does_not_disarm_continuation() { + assert_error_interloper_does_not_disarm_continuation(filter_package); + } + /// child-a variant: one class with TWO blank-separated per-test detail /// blocks (Surefire's multi-failure shape) — `trail_rearm` carries the /// keep/drop decision across the blanks. @@ -2700,33 +3045,39 @@ mod tests { assert_two_armed_lanes_preserve_continuations(filter_compile); } - // Snapshot regression tests locking the full filtered output of every - // mvnd fixture (insta, per docs/contributing/CODING_PRACTICES.md) — the - // substring assertions above document intent; the snapshots catch - // everything else. + // Full-output regression tests locking the complete filtered output of + // every mvnd fixture, per the repo-prescribed fixture pattern + // (`.claude/rules/cli-testing.md`: expected-output files in + // `tests/fixtures/`, compared via `include_str!` + `assert_eq!` — no + // snapshot-testing crate) — the substring assertions above document + // intent; these catch everything else. #[test] - fn mvnd_reactor_pass_snapshot() { + fn mvnd_reactor_pass_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); - insta::assert_snapshot!(filter_package(i)); + let expected = include_str!("../../../tests/fixtures/mvnd_reactor_pass_expected.txt"); + assert_eq!(filter_package(i), expected); } #[test] - fn mvnd_test_fail_snapshot() { + fn mvnd_test_fail_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); - insta::assert_snapshot!(filter_surefire(i)); + let expected = include_str!("../../../tests/fixtures/mvnd_test_fail_expected.txt"); + assert_eq!(filter_surefire(i), expected); } #[test] - fn mvnd_parallel_reactor_fail_snapshot() { + fn mvnd_parallel_reactor_fail_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); - insta::assert_snapshot!(filter_surefire(i)); + let expected = include_str!("../../../tests/fixtures/mvnd_reactor_fail_expected.txt"); + assert_eq!(filter_surefire(i), expected); } #[test] - fn mvnd_compile_error_snapshot() { + fn mvnd_compile_error_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); - insta::assert_snapshot!(filter_compile(i)); + let expected = include_str!("../../../tests/fixtures/mvnd_compile_error_expected.txt"); + assert_eq!(filter_compile(i), expected); } /// `mvnd compile` on a syntax error (exit code 1): compile diagnostics @@ -3885,6 +4236,11 @@ mod tests { split_lane("[] [INFO] started"), (None, "[] [INFO] started") ); + // Tagged blank, no-trailing-space spelling (`[tag] [LEVEL]`, nothing + // after — `lane_rest_level`'s documented other spelling of the + // daemon-prefixed trail terminator, alongside `[tag] [LEVEL] `). + // Pinned so the "both spellings" contract can't rot. + assert_eq!(split_lane("[child-a] [INFO]"), (Some("child-a"), "[INFO]")); } /// Reviewer probe (upstream PR #3199 finding 2): an application log line @@ -4047,7 +4403,105 @@ mod tests { ); } - /// Finding 4: an adversarial run of uniquely-tagged `[ERROR]` lines + /// Reviewer finding #1 (upstream PR #3199, second review round), failing + /// side: a `[tag] [ERROR]` app-log line for a never-seen tag (e.g. + /// `[Server] [ERROR] connection refused`, no `file.java:[line,col]` + /// coordinate) landing *inside* another lane's still-open buffered block + /// must stay inside that block, at its input position — not mint its own + /// fresh lane and get written to `out` immediately, ahead of the + /// `Running` line (and the rest of the block) it followed in the input. + /// Pre-fix, `is_lane_opener`'s blanket `[ERROR]`-prefix rule minted + /// `[Server]` its own lane; post-fix it has no `FILE_COORD` coordinate, + /// so it's not an opener and `Lanes::route` falls back to `raw_owner`, + /// which buffers it into `child-a`'s uniquely-open block instead. + #[test] + fn app_log_error_stays_in_order_inside_failing_block() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.SlowTest\n\ + [Server] [ERROR] connection refused\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.SlowTest\n\ + [child-a] [ERROR] com.example.rtk.SlowTest.foo -- Time elapsed: 0.05 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.rtk.SlowTest.foo(SlowTest.java:5)\n\ + [child-a] [INFO] \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + let running = o.find("Running com.example.rtk.SlowTest").expect("Running kept"); + let server = o + .find("[Server] [ERROR] connection refused") + .expect("app log line kept, buffered inside the block"); + let close = o + .find("<<< FAILURE! -- in com.example.rtk.SlowTest") + .expect("close line kept"); + assert!( + running < server && server < close, + "app-log line must stay in its original input order inside the block \ + (Running, then the app log, then the close), not jump ahead of Running; got:\n{o}" + ); + } + + /// Cold-preclear finding (🟡 1): `lane_rest_level` documents two blank + /// terminator spellings for a tagged lane's trail — `[tag] [LEVEL] ` + /// (trailing space, mvnd's usual daemon-prefixed blank) and `[tag] + /// [LEVEL]` (no trailing space, nothing after). Every other + /// trail-termination test in this file uses the trailing-space spelling + /// (see `split_lane`'s own doc comment); this one pins the no-space + /// spelling so that half of the documented contract can't silently rot. + /// If termination on the no-space spelling ever breaks, the bare + /// terminator line survives as literal trail content instead of being + /// consumed, so it would appear verbatim right before the next + /// `Running` line. + #[test] + fn tagged_trail_terminates_on_no_trailing_space_blank() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.SlowTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.SlowTest\n\ + [child-a] [ERROR] com.example.rtk.SlowTest.foo -- Time elapsed: 0.05 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.rtk.SlowTest.foo(SlowTest.java:5)\n\ + \tat org.junit.jupiter.api.Assertions.fail(Assertions.java:1)\n\ + [child-a] [INFO]\n\ + [child-a] [INFO] Running com.example.rtk.NextTest\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.NextTest\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!( + o.contains("at com.example.rtk.SlowTest.foo(SlowTest.java:5)"), + "user frame survives; got:\n{o}" + ); + assert!( + !o.contains("org.junit.jupiter"), + "framework frame stripped; got:\n{o}" + ); + assert!( + !o.contains("[child-a] [INFO]\n[child-a] [INFO] Running"), + "the no-space blank must be consumed as the trail terminator, not survive \ + as a stray literal line before the next Running; got:\n{o}" + ); + assert!(o.contains("BUILD FAILURE"), "footer survives; got:\n{o}"); + } + + /// Reviewer finding #1 (upstream PR #3199, second review round), green + /// side: the same `[tag] [ERROR]` app-log line, but the block it lands + /// inside closes clean (0 failures/errors) — the whole block, app-log + /// line included, must be discarded with the rest, not survive as a + /// stray leftover from a lane it should never have minted. + #[test] + fn app_log_error_dropped_with_green_close_block() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.SlowTest\n\ + [Server] [ERROR] connection refused\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.05 s -- in com.example.rtk.SlowTest\n\ + [INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + assert!( + !o.contains("[Server]") && !o.contains("connection refused"), + "app-log line inside a green-closed block must be discarded with the rest \ + of the block, not survive via a phantom lane; got:\n{o}" + ); + } + + /// Finding 4: an adversarial run of uniquely-tagged opener-shaped lines /// (each individually opener-shaped, per `is_lane_opener`) must not mint /// unbounded lanes — `Lanes::route`'s scan stays `O(MAX_LANES)`, not /// `O(distinct tags)`. Also exercises the public entry point end to end @@ -4055,14 +4509,17 @@ mod tests { #[test] fn lane_count_is_capped_on_pathological_input() { let mut i = String::from("[INFO] Scanning for projects...\n"); - // Non-coordinate `[ERROR]` text deliberately: `is_lane_opener`'s - // blanket `[ERROR]`-prefix rule still mints a lane per tag (what - // this test measures), but without a `FILE_COORD` match these - // never arm `keep_continuation` — so the overflow-module assertion - // below isn't accidentally satisfied by an unrelated 255-way armed - // tie in `raw_owner` instead of exercising the fix it targets. + // `Running` lines deliberately (not bare `[ERROR]` text): since + // finding #1 (upstream PR #3199, second round) narrowed + // `is_lane_opener`'s `[ERROR]` arm to genuine `FILE_COORD` compiler + // diagnostics, a non-coordinate `[ERROR]` line no longer mints a + // lane at all. `Running` is unaffected by that fix and, unlike a + // `FILE_COORD` line, never arms `keep_continuation` either — so the + // overflow-module assertion below isn't accidentally satisfied by an + // unrelated 255-way armed tie in `raw_owner` instead of exercising + // the fix it targets. for n in 0..(MAX_LANES + 50) { - i.push_str(&format!("[tag{n}] [ERROR] connection refused for tag{n}\n")); + i.push_str(&format!("[tag{n}] [INFO] Running com.example.rtk.Tag{n}Test\n")); } // One more never-seen tag, past the cap: a genuine failing test // class (`Running` + failing close), not just a bare `[ERROR]` diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap b/tests/fixtures/mvnd_compile_error_expected.txt similarity index 90% rename from src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap rename to tests/fixtures/mvnd_compile_error_expected.txt index c994023770..188a775e0d 100644 --- a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_compile_error_snapshot.snap +++ b/tests/fixtures/mvnd_compile_error_expected.txt @@ -1,7 +1,3 @@ ---- -source: src/cmds/jvm/mvn_cmd.rs -expression: filter_compile(i) ---- [INFO] Scanning for projects... [INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- [INFO] Building multifail-skeleton 1.0.0-SNAPSHOT diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap b/tests/fixtures/mvnd_reactor_fail_expected.txt similarity index 96% rename from src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap rename to tests/fixtures/mvnd_reactor_fail_expected.txt index 1409f39a69..4460792ca9 100644 --- a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_parallel_reactor_fail_snapshot.snap +++ b/tests/fixtures/mvnd_reactor_fail_expected.txt @@ -1,7 +1,3 @@ ---- -source: src/cmds/jvm/mvn_cmd.rs -expression: filter_surefire(i) ---- [INFO] Scanning for projects... [multi-module-fail-skeleton] [INFO] -------------< com.example.rtk:multi-module-fail-skeleton >------------- [multi-module-fail-skeleton] [INFO] Building multi-module-fail-skeleton 1.0.0-SNAPSHOT [1/3] diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap b/tests/fixtures/mvnd_reactor_pass_expected.txt similarity index 96% rename from src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap rename to tests/fixtures/mvnd_reactor_pass_expected.txt index ba2ddd65d6..15729aef05 100644 --- a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_reactor_pass_snapshot.snap +++ b/tests/fixtures/mvnd_reactor_pass_expected.txt @@ -1,7 +1,3 @@ ---- -source: src/cmds/jvm/mvn_cmd.rs -expression: filter_package(i) ---- [INFO] Scanning for projects... [multi-module-skeleton] [INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- [multi-module-skeleton] [INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] diff --git a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap b/tests/fixtures/mvnd_test_fail_expected.txt similarity index 96% rename from src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap rename to tests/fixtures/mvnd_test_fail_expected.txt index daaa491db2..bb4deec5fb 100644 --- a/src/cmds/jvm/snapshots/rtk__cmds__jvm__mvn_cmd__tests__mvnd_test_fail_snapshot.snap +++ b/tests/fixtures/mvnd_test_fail_expected.txt @@ -1,7 +1,3 @@ ---- -source: src/cmds/jvm/mvn_cmd.rs -expression: filter_surefire(i) ---- [INFO] Scanning for projects... [INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- [INFO] Building multifail-skeleton 1.0.0-SNAPSHOT From ce26d60000dd3bc0af496823d898f52554448413 Mon Sep 17 00:00:00 2001 From: kylehgc Date: Sat, 29 Aug 2026 19:07:19 -0400 Subject: [PATCH 12/14] fix(mvn): gate lane routing on daemon mode, repair summary cap, phase-marker generations Review response for upstream #3199 (KuSh round 3): - The lane layer is gated on daemon mode (KuSh's option 1): with daemon == false, split_lane short-circuits so every line takes the base single-state-machine path -- plain mvn is byte-identical to base by construction (A/B: all 14 pre-existing fixtures through all four entry points, byte-identical), and the phantom-lane class (SLF4J thread tags, timestamp tags) is structurally unreachable there. Both probes are tests. - The failures-summary cap engages on lane-established reactors again: an unclosed per-class block was swallowing the summary header as block content, bypassing the cap entirely. step() now treats a keyed summary header mid-block as a structural boundary, mirroring the RUNNING recovery. - Generation resets come from plugin-banner phase markers (surefire -> failsafe goal change, both the new and the maven-*-plugin coordinate spellings) instead of lane-repeat inference, so a module whose only failures are Failsafe's gets a fresh budget. Plain mvn reproduces base's every-header-resets semantics exactly. - Daemon-mode Building lines only open/keep on the genuine module header shape (trailing [n/m] reactor counter, per the real fixtures), so [thread] [INFO] Building app-log spam no longer defeats the savings floor. - The one accepted residual (an app-log Running line minting an unconfirmed-tag lane that flushes bounded and in order at end-of-stream) is pinned by a test and documented as the enforced contract. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 1113 ++++++++++++++++++++++++++++++--------- 1 file changed, 860 insertions(+), 253 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 9f5daa0037..eb733d2af6 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -64,6 +64,38 @@ static AGG: LazyLock = LazyLock::new(|| { static PLUGIN_BANNER: LazyLock = LazyLock::new(|| Regex::new(r"^\[INFO\] --- .* @ .* ---$").unwrap()); +/// Surefire/Failsafe plugin execution banner specifically — captures the +/// goal identifier (`plugin:version:goal (execution-id)`, e.g. +/// `surefire:3.5.5:test (default-test)` vs `failsafe:3.5.5:integration-test +/// (default-integration-test)`) so a phase transition between the two can be +/// detected independent of lane/module identity (see +/// [`FailuresSummaryCap::observe_plugin_banner`]). Deliberately narrower +/// than [`PLUGIN_BANNER`], which matches *any* plugin's banner +/// (`compiler:...`, `resources:...`, `clean:...`, …) — those never open a +/// failures summary and must never trigger a budget reset. +/// Widened per cold-preclear finding (upstream PR #3199, fourth review +/// round): Maven ≤3.8 / mvnd 0.x spell the plugin coordinate +/// `maven-surefire-plugin:2.22.2` / `maven-failsafe-plugin:2.22.2`, not the +/// bare `surefire:`/`failsafe:` shorthand newer Maven emits — missing that +/// spelling silently disabled the generation reset on older daemons, the +/// exact failure mode the phase marker exists to prevent. +static TEST_PLUGIN_BANNER: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[INFO\] --- ((?:maven-)?(?:surefire|failsafe)(?:-plugin)?:\S+ \([^)]*\)) @ .* ---$").unwrap() +}); + +/// A genuine mvnd reactor module header: `Building +/// [n/m]` — the trailing `[n/m]` reactor-position counter is what a real +/// module's own `Building` line always carries (confirmed against +/// `mvnd_reactor_pass_raw.txt` / `mvnd_reactor_fail_raw.txt`) and an app log +/// coincidentally shaped `[pool-1] [INFO] Building segment N of the data +/// pipeline` never does. Used to gate the `[INFO] Building ` opener/keeper +/// on *tagged* lanes only — see [`is_lane_opener`] and +/// [`keep_outside_block`]. The root lane's own `Building` line (plain `mvn`, +/// single-module — never reactor-numbered) is unaffected: it's kept by the +/// unconditional `starts_with` check, not this one. +static BUILDING_MODULE_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^\[INFO\] Building .*\[\d+/\d+\]\s*$").unwrap()); + /// Module banner with project name in brackets. static MODULE_BANNER: LazyLock = LazyLock::new(|| Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap()); @@ -213,7 +245,34 @@ fn is_blank_separator(line: &str) -> bool { /// such a line inside a *tagged* lane's active failure trail/block was /// dropped: it never reached the trail-keeping logic). /// - non-bracket-leading line → `(None, line)`, raw (checked first, below). -fn split_lane(line: &str) -> (Option<&str>, &str) { +/// +/// `daemon` gates this whole lane layer (cold-preclear finding, upstream PR +/// #3199, third review round): plain `mvn` never interleaves module output — +/// there is nothing for lane tracking to protect against, and the bracket +/// heuristic above is exactly what let an SLF4J/Logback `[%thread] [%level] +/// %msg` line (or a `[timestamp] [LEVEL] …` layout) masquerade as a real +/// mvnd module tag, minting a phantom lane, opening a block that never +/// closes, and resurrecting or reordering content base would have dropped +/// or kept in place. `daemon == false` short-circuits to `(Some(""), line)` +/// unconditionally — every line, bracket-leading or not, root-keyed with +/// `core == line` — which is exactly base's (pre-lane) flat model: a single +/// always-keyed state machine, `is_lane_opener`/`Lanes::raw_owner`/ +/// `MAX_LANES` never consulted at all (`Lanes::route` finds the pre-seeded +/// root lane on the first lookup). This makes *routing* byte-identical to +/// base *by construction*, not by case analysis. +/// +/// Scope of that claim: it covers `split_lane`/`Lanes::route` only, not +/// every byte `filter_surefire`/`filter_package` can ever emit for plain +/// `mvn`. `SurefireBlock::step`'s in-block summary-header recovery (flushing +/// a stale open block when an `[ERROR] Failures:`/`Errors:` header arrives +/// mid-block — see its own doc comment) is a single shared fix that also +/// improves the root lane's handling of a malformed/truncated stream over +/// base's; that divergence is deliberate and orthogonal to routing, not a +/// gap in this function. +fn split_lane(line: &str, daemon: bool) -> (Option<&str>, &str) { + if !daemon { + return (Some(""), line); + } if !line.starts_with('[') { return (None, line); } @@ -312,16 +371,38 @@ fn lane_rest_level(rest: &str) -> Option<&str> { /// (`[ERROR] Tests run: … <<< FAILURE!`) is unaffected too — it's admitted /// by the separate `CLOSE.is_match` arm above, not this one. /// +/// The `RUNNING` arm is *not* narrowed the way `[ERROR]`/`Building` are: a +/// genuine mvnd `Running` line carries no extra marker beyond `[INFO] +/// Running ` (confirmed against every real fixture — unlike +/// `Building`'s trailing `[n/m]` reactor-position counter, there is nothing +/// fixture-grounded to gate on), so an app log merely shaped `[main] [INFO] +/// Running the widget pipeline` still mints a tagged lane in daemon mode. +/// Contract for that lane: it is bounded to the lines that opened and fed +/// it — its unconfirmed tag's block never closes (no genuine `CLOSE` line +/// ever arrives for it), so it flushes verbatim, in its own input order, +/// at end-of-stream (i.e. after everything else, including the build +/// footer). Over-keep and reorder of exactly those lines, never loss and +/// never touching any other lane's content — see +/// `app_log_running_line_mints_tagged_lane_and_flushes_at_end_of_stream`. +/// /// Lane growth itself is bounded by [`MAX_LANES`] regardless, so an /// adversarial run of uniquely-tagged opener-shaped lines can mint at most /// that many lanes, keeping `route`'s scan O(`MAX_LANES`) regardless of how /// many distinct tags the input contains. +/// +/// Cold-preclear finding (upstream PR #3199, fourth review round): the +/// `Building` arm used a bare `starts_with` — any `[pool-1] [INFO] Building +/// segment N of the data pipeline` app log qualified. This function is only +/// ever consulted for a *never-seen* tag (see [`Lanes::route`]), i.e. always +/// a candidate *tagged* lane, never root — so it can require the real mvnd +/// module shape unconditionally: [`BUILDING_MODULE_HEADER`]'s trailing +/// `[n/m]` reactor-position counter. fn is_lane_opener(core: &str) -> bool { RUNNING.is_match(core) || CLOSE.is_match(core) || MODULE_BANNER.is_match(core) || PLUGIN_BANNER.is_match(core) - || core.starts_with("[INFO] Building ") + || BUILDING_MODULE_HEADER.is_match(core) || (core.starts_with("[ERROR]") && FILE_COORD.is_match(core)) } @@ -369,7 +450,18 @@ fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { *in_reactor_summary } -fn keep_outside_block(line: &str) -> bool { +/// `gate_building`: cold-preclear finding (upstream PR #3199, fourth review +/// round) — a bare `[INFO] Building ` keeper let `[pool-1] [INFO] Building +/// segment N of the data pipeline` app logs on a *tagged* mvnd lane through +/// wholesale (probe: 2000 such lines in a green run, ~0.1% savings). The +/// root lane's own `Building` header (plain `mvn`, single-module — never +/// reactor-numbered) must stay on the unconditional check, so the caller +/// passes `gate_building = daemon && idx != ROOT_LANE`: `true` narrows the +/// `[INFO] Building ` keeper to [`BUILDING_MODULE_HEADER`]'s real +/// `[n/m]`-numbered shape; `false` (root, or plain `mvn`) keeps the original +/// unconditional check. `Building war:`/`jar:`/`ear:` (artifact-packaging +/// lines, a different shape entirely) are never gated. +fn keep_outside_block(line: &str, gate_building: bool) -> bool { // Help boilerplate must be rejected before the `[ERROR]` catch-all below // (non-quiet parity with `filter_quiet`'s boilerplate stripping). if is_boilerplate(line) { @@ -381,7 +473,8 @@ fn keep_outside_block(line: &str) -> bool { || MODULE_BANNER.is_match(line) || line.starts_with("[INFO] Total time:") || line.starts_with("[INFO] Finished at:") - || line.starts_with("[INFO] Building ") + || (line.starts_with("[INFO] Building ") + && (!gate_building || BUILDING_MODULE_HEADER.is_match(line))) || line.starts_with("[INFO] Scanning ") || line.starts_with("[INFO] Installing ") || line.starts_with("[ERROR] Failures:") @@ -506,6 +599,29 @@ impl<'a> SurefireBlock<'a> { return SurefireStep::Consumed; } + // A `[ERROR] Failures:` / `[ERROR] Errors:` summary header can never + // legitimately appear while a per-class block is genuinely still + // open — Maven only emits it after every class in the module has + // already closed (see `mvnd_reactor_fail_raw.txt`: the last class's + // own close line always precedes it). If one reaches here anyway — + // nothing closed the last class explicitly — swallowing it (and the + // entries/AGG that follow) into `block_lines` would dump the whole + // summary uncapped at end-of-stream, bypassing `FailuresSummaryCap` + // entirely. Flush the stale block as keep (the same recovery + // `RUNNING` already gets, right above) and fall through to + // `Passthrough` so the header actually reaches the summary-cap + // machinery. Invariant: the reactor-wide budget is keyed to summary + // blocks (`[ERROR] Failures:`/`Errors:` … the `AGG` aggregate), + // never to whether the carrying lane's last per-class block + // happened to close first. + if keyed + && self.in_block + && (core.starts_with("[ERROR] Failures:") || core.starts_with("[ERROR] Errors:")) + { + self.flush_open_block_as_keep(out); + return SurefireStep::Passthrough; + } + if self.in_block { if keyed { if let Some(caps) = CLOSE.captures(core) { @@ -676,56 +792,65 @@ impl<'a> SurefireBlock<'a> { /// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. /// -/// The *budget* (`emitted`, how many entries may still be kept) is -/// **reactor-wide within one generation**, not per module: a parallel -/// reactor emits one summary block per failing module, and each module's -/// *first* summary in a generation shares that generation's one running -/// budget — a 20-module reactor still keeps at most `cap` entries total for -/// that pass, never `modules × cap`, whether the modules' summaries -/// interleave or run back-to-back. But `mvn verify`/`install` runs -/// Surefire's summary then Failsafe's as two independent generations over -/// the *same* lanes — a lane's second `[ERROR] Failures:`/`Errors:` header -/// starts a new generation, not a continuation, so it gets a fresh `cap`. +/// The *budget* (`emitted`, how many entries may still be kept) behaves +/// differently by mode ([`FailuresSummaryCap::daemon`]): +/// +/// - **Plain `mvn`** (`daemon == false`): matches base (pre-lane) semantics +/// exactly — every `[ERROR] Failures:`/`Errors:` header, however many a +/// sequential multi-module build shows, gets its own unconditionally +/// fresh budget. Plain `mvn` has no reactor-wide sharing concept; there is +/// only ever one (root) lane, so there is nothing to share *between*. +/// - **mvnd** (`daemon == true`): **reactor-wide within one generation**, not +/// per module — a parallel reactor emits one summary block per failing +/// module, and each module's *first* summary in a generation shares that +/// generation's one running budget: a 20-module reactor still keeps at +/// most `cap` entries total for that pass, never `modules × cap`, whether +/// the modules' summaries interleave or run back-to-back. But `mvn +/// verify`/`install` runs Surefire's summary then Failsafe's as two +/// independent generations — a new generation gets a fresh `cap`. The +/// boundary between generations is an explicit phase marker +/// ([`FailuresSummaryCap::observe_plugin_banner`]), not lane-repeat +/// inference (cold-preclear finding, upstream PR #3199, third review +/// round): inferring "new generation" from a lane's header *repeating* +/// silently failed to reset the budget when a module's only failures were +/// integration-test ones — its Failsafe header was its first-ever +/// sighting, never a repeat, so it shared whatever Surefire's phase had +/// already spent (potentially zero) instead of a fresh `cap`. /// -/// The *dropped-entry count* backing each `… +N more failures` tail is, by -/// contrast, **per lane** ([`SurefireLane::dropped`]) — the shared budget -/// says how many entries total may survive, but which module's entries got +/// The *dropped-entry count* backing each `… +N more failures` tail is +/// **per lane** ([`SurefireLane::dropped`]) in both modes — the budget says +/// how many entries total may survive, but which module's entries got /// dropped while spending that budget is per-module information, and each /// module's own tail must report only its own drops, flushed at its own AGG -/// line. Reactor-wide `dropped` (the pre-fix design) let whichever lane's -/// AGG happened to arrive first flush *everyone's* outstanding drops under -/// its own header and zero the counter for every lane after it — dropping a +/// line. Reactor-wide `dropped` (an earlier design) let whichever lane's AGG +/// happened to arrive first flush *everyone's* outstanding drops under its +/// own header and zero the counter for every lane after it — dropping a /// module's only failure with no tail to show for it, or crediting one /// module's drops to another's summary. Attribution invariant: **a lane's /// `… +N more failures` tail reports exactly the entries dropped while that /// lane's own summary block was open, no more and no less** — even when /// several lanes' summaries interleave and share one budget. -/// -/// Invariant: **each summary block keeps at most `cap` entries; entries -/// beyond that collapse to `… +N more failures`** — a generation's budget is -/// shared reactor-wide the first time each lane opens a summary in it, and a -/// new generation begins (with a fresh budget) exactly once per repeat -/// header, on whichever lane's header repeats first; every other lane's -/// header that then interleaves before the reset lane's `AGG` is treated as -/// that lane's first sighting *in the new generation* (no second reset) — -/// otherwise an interleaved reactor could double-reset mid-generation and -/// let one still-open block emit up to `2 × cap` entries. struct FailuresSummaryCap { cap: usize, emitted: usize, - /// Lanes that have opened a summary block in the *current generation*. - /// Cleared and re-seeded with just the triggering lane when the first - /// repeat header of a generation starts a new one — see - /// [`FailuresSummaryCap::handle_header`]. - seen_lanes: HashSet, + /// `false` for plain `mvn`: every header resets unconditionally (base + /// parity — see the struct doc). `true` for mvnd: resets only at a + /// plugin-banner phase transition ([`FailuresSummaryCap:: + /// observe_plugin_banner`]). + daemon: bool, + /// mvnd only: the goal identifier (`plugin:version:goal (execution-id)`) + /// captured from the most recent Surefire/Failsafe plugin banner. `None` + /// until the first banner is seen. + phase: Option, } impl FailuresSummaryCap { - fn new(cap: usize) -> Self { + fn new(cap: usize, daemon: bool) -> Self { Self { cap, emitted: 0, - seen_lanes: HashSet::new(), + daemon, + phase: None, } } @@ -733,9 +858,8 @@ impl FailuresSummaryCap { /// summary, write `line` (the original, module prefix included) — or /// increment `dropped` (the calling lane's own pending-drop count) — /// and return `true` so the caller skips its own keep-list. Returns - /// `false` otherwise. `emitted` (the reactor-wide shared budget) is the - /// only thing that decides keep-vs-drop; `dropped` only tracks *whose* - /// tail reports the drop. + /// `false` otherwise. `emitted` is the only thing that decides + /// keep-vs-drop; `dropped` only tracks *whose* tail reports the drop. fn handle_entry( &mut self, in_summary: bool, @@ -763,37 +887,43 @@ impl FailuresSummaryCap { /// all thrown exceptions (no assertion failures) gets an `Errors:`-only /// summary with no `Failures:` header at all, and must be capped the /// same way. Caller is responsible for writing the header to `out`. - /// `lane` is the opening lane's index. /// - /// Generation semantics: `seen_lanes` tracks lanes that have opened a - /// summary in the *current* generation (phase). A lane's first header in - /// a generation shares that generation's running budget (reactor-wide, - /// same as before). The first lane whose header repeats within the - /// current generation is the signal a new generation has begun (e.g. - /// Failsafe's summary following Surefire's): that starts a fresh - /// generation — `seen_lanes` is cleared (and re-seeded with just this - /// lane) and `emitted` resets once. Any *other* lane's header that then - /// interleaves before this lane's `AGG` line is a first sighting in the - /// new generation too (its `insert` succeeds), so it shares the new - /// generation's budget without triggering a second reset — otherwise an - /// interleaved reactor could reset `emitted` twice per generation and - /// let one lane's still-open block emit up to `2 × cap` entries. The + /// Plain `mvn` (`!self.daemon`): every header resets `emitted` and the + /// caller's `dropped` unconditionally — base parity, see the struct doc. + /// mvnd: no reset here at all — generation boundaries are driven + /// exclusively by [`FailuresSummaryCap::observe_plugin_banner`]. The /// `*in_summary` guard above already prevents a mid-block `Errors:` /// header (Maven can emit `Failures:` then `Errors:` as two sections of - /// one summary) from being treated as a new generation at all. A lane's - /// own `dropped` count is untouched here — it's per-lane and only ever - /// reset by that lane's own [`FailuresSummaryCap::handle_aggregate`] - /// flush, regardless of generation boundaries. - fn handle_header(&mut self, line: &str, in_summary: &mut bool, lane: usize) { + /// one summary) from being treated as a new generation at all. + fn handle_header(&mut self, line: &str, in_summary: &mut bool, dropped: &mut usize) { let is_header = line.starts_with("[ERROR] Failures:") || line.starts_with("[ERROR] Errors:"); if !is_header || *in_summary { return; } *in_summary = true; - if !self.seen_lanes.insert(lane) { - self.seen_lanes.clear(); - self.seen_lanes.insert(lane); + if !self.daemon { + self.emitted = 0; + *dropped = 0; + } + } + + /// mvnd only (no-op for plain `mvn`): `goal` is the captured + /// `plugin:version:goal (execution-id)` from a Surefire/Failsafe plugin + /// banner (see [`TEST_PLUGIN_BANNER`]). A goal that differs from the + /// currently tracked phase is an unambiguous generation boundary — + /// every module's own banner within one phase carries the *same* goal + /// string (module identity isn't part of the capture), so repeated + /// banners for different modules in the same phase never trigger a + /// reset; only a genuine Surefire → Failsafe (or vice versa) transition + /// does, regardless of whether any lane's summary header happens to + /// repeat across it. + fn observe_plugin_banner(&mut self, goal: &str) { + if !self.daemon { + return; + } + if self.phase.as_deref() != Some(goal) { + self.phase = Some(goal.to_string()); self.emitted = 0; } } @@ -1077,9 +1207,11 @@ fn drive_surefire_line<'a>( lanes: &mut Lanes<'a>, line: &'a str, classes: &mut FailingClassCap, + summary: &mut FailuresSummaryCap, + daemon: bool, out: &mut String, ) -> Option<(usize, &'a str, bool)> { - let (key, core) = split_lane(line); + let (key, core) = split_lane(line, daemon); let (idx, keyed) = match lanes.route(key, core) { Some(v) => v, None => { @@ -1091,6 +1223,20 @@ fn drive_surefire_line<'a>( } }; + // Surefire/Failsafe plugin banners are swallowed silently by + // `block.step()` below (never surface to the outer loop's keep-list), + // so this is the only point that can observe a phase transition — + // needed before the swallow so `FailuresSummaryCap` can reset the + // budget at an unambiguous boundary instead of inferring one from lane + // repetition (cold-preclear finding, upstream PR #3199, third review + // round: that inference silently shared Surefire's leftover budget with + // a module whose only failures were integration-test ones). + if keyed { + if let Some(caps) = TEST_PLUGIN_BANNER.captures(core) { + summary.observe_plugin_banner(caps.get(1).map_or("", |m| m.as_str())); + } + } + let step = lanes.get(idx).block.step(line, core, keyed, idx == ROOT_LANE, out); // A lane inside a Surefire block has no pending javac continuations: // entering a block retires any stale armed claim, so a single lane can't @@ -1130,11 +1276,11 @@ fn drive_surefire_line<'a>( /// /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). -pub fn filter_surefire(raw: &str) -> String { - filter_surefire_with_cap(raw, MAX_MVN_FAILING_CLASSES) +pub fn filter_surefire(raw: &str, daemon: bool) -> String { + filter_surefire_with_cap(raw, MAX_MVN_FAILING_CLASSES, daemon) } -fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { +fn filter_surefire_with_cap(raw: &str, cap: usize, daemon: bool) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -1143,15 +1289,16 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let mut out = String::new(); let mut lanes = Lanes::new(); let mut classes = FailingClassCap::new(cap); - let mut summary = FailuresSummaryCap::new(cap); + let mut summary = FailuresSummaryCap::new(cap, daemon); let mut in_reactor_summary = false; for line in stripped.lines() { - let (idx, core, keyed) = - match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { - Some(v) => v, - None => continue, - }; + let (idx, core, keyed) = match drive_surefire_line( + &mut lanes, line, &mut classes, &mut summary, daemon, &mut out, + ) { + Some(v) => v, + None => continue, + }; if lanes.get(idx).keep_continuation && (core.starts_with(' ') || core.starts_with('\t')) { out.push_str(line); out.push('\n'); @@ -1171,14 +1318,14 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); - if reactor_keep || keep_outside_block(core) { + if reactor_keep || keep_outside_block(core, daemon && idx != ROOT_LANE) { let lane = lanes.get(idx); // Pre-emit this lane's own summary tail when we're about to // write its own AGG (never another lane's — see the attribution // invariant on `FailuresSummaryCap`). summary.handle_aggregate(core, &mut lane.dropped, &mut out, &mut lane.in_summary); // Detect summary header so subsequent `[ERROR] ` entries get capped. - summary.handle_header(core, &mut lane.in_summary, idx); + summary.handle_header(core, &mut lane.in_summary, &mut lane.dropped); out.push_str(line); out.push('\n'); // The armed per-lane flag is an owner claim in its own right: @@ -1237,7 +1384,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { /// time, scanning line, install lines, and `[ERROR]` blocks with indented /// continuation (` symbol:`, ` ^`, ` required:`). Deduplicates `[WARNING]` /// lines by normalised message (strip file coordinates). -pub fn filter_compile(raw: &str) -> String { +pub fn filter_compile(raw: &str, daemon: bool) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -1250,13 +1397,14 @@ pub fn filter_compile(raw: &str) -> String { // between must not clear the flag armed by `[child-a] [ERROR]`. Compile // never opens Surefire blocks, so `route` resolves raw lines to the unique // armed lane (or preserves them verbatim when several lanes are armed). + // `daemon` gates the whole lane layer — see `split_lane`. let mut lanes = Lanes::new(); let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { // Classify on the module-prefix-stripped view; emit the original so // module identity survives in mvnd parallel reactors. - let (key, core) = split_lane(line); + let (key, core) = split_lane(line, daemon); let (idx, keyed) = match lanes.route(key, core) { Some(v) => v, // Reachable when two modules are armed concurrently (a tie): @@ -1280,8 +1428,11 @@ pub fn filter_compile(raw: &str) -> String { } continue; } + // `[INFO] Building ` gated the same way as `keep_outside_block` — + // see the cold-preclear finding on that function's doc comment. if BUILD_FOOT.is_match(core) - || core.starts_with("[INFO] Building ") + || (core.starts_with("[INFO] Building ") + && (!(daemon && idx != ROOT_LANE) || BUILDING_MODULE_HEADER.is_match(core))) || core.starts_with("[INFO] Total time:") || core.starts_with("[INFO] Finished at:") || core.starts_with("[INFO] Scanning ") @@ -1368,11 +1519,11 @@ pub fn filter_compile(raw: &str) -> String { /// `[INFO] Running …` line is seen, switches back on `Tests run:` close. /// Outside any Surefire block, applies the unified keep-list (compile keepers /// + install/artifact lines). -pub fn filter_package(raw: &str) -> String { - filter_package_with_cap(raw, MAX_MVN_FAILING_CLASSES) +pub fn filter_package(raw: &str, daemon: bool) -> String { + filter_package_with_cap(raw, MAX_MVN_FAILING_CLASSES, daemon) } -fn filter_package_with_cap(raw: &str, cap: usize) -> String { +fn filter_package_with_cap(raw: &str, cap: usize, daemon: bool) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -1382,18 +1533,19 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { // Per-module lanes + raw-line routing: see drive_surefire_line. let mut lanes = Lanes::new(); let mut classes = FailingClassCap::new(cap); - let mut summary = FailuresSummaryCap::new(cap); + let mut summary = FailuresSummaryCap::new(cap, daemon); let mut in_reactor_summary = false; // Warning dedup is deliberately global: the same warning surfacing from // several reactor modules is still the same warning. let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { - let (idx, core, keyed) = - match drive_surefire_line(&mut lanes, line, &mut classes, &mut out) { - Some(v) => v, - None => continue, - }; + let (idx, core, keyed) = match drive_surefire_line( + &mut lanes, line, &mut classes, &mut summary, daemon, &mut out, + ) { + Some(v) => v, + None => continue, + }; // Failures-summary cap (see filter_surefire_with_cap for details). { let lane = lanes.get(idx); @@ -1406,10 +1558,10 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. - if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core) { + if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core, daemon && idx != ROOT_LANE) { let lane = lanes.get(idx); summary.handle_aggregate(core, &mut lane.dropped, &mut out, &mut lane.in_summary); - summary.handle_header(core, &mut lane.in_summary, idx); + summary.handle_header(core, &mut lane.in_summary, &mut lane.dropped); out.push_str(line); out.push('\n'); // Armed flag is an owner claim scanned by raw_owner; only keyed @@ -1645,21 +1797,21 @@ fn run_tool(args: &[String], daemon: bool, verbose: u8) -> Result { new_mvn_command(args, daemon), tool, &args_display, - filter_surefire, + move |raw: &str| filter_surefire(raw, daemon), RunOptions::with_tee("mvn_test"), ), MvnPhase::Compile => runner::run_filtered( new_mvn_command(args, daemon), tool, &args_display, - filter_compile, + move |raw: &str| filter_compile(raw, daemon), RunOptions::with_tee("mvn_compile"), ), MvnPhase::Package => runner::run_filtered( new_mvn_command(args, daemon), tool, &args_display, - filter_package, + move |raw: &str| filter_package(raw, daemon), RunOptions::with_tee("mvn_package"), ), MvnPhase::Passthrough => { @@ -1681,6 +1833,258 @@ mod tests { s.split_whitespace().count() } + /// Cold-preclear finding (upstream PR #3199, fourth review round): the + /// `[INFO] Building ` keeper was a bare `starts_with`, so on a *tagged* + /// lane it kept any app log merely shaped like one — mvnd tags a + /// module's entire output stream with its own module tag regardless of + /// which thread emitted a line, so background/pool logging from a + /// module's own code (`[child-a] [INFO] Building segment N of the data + /// pipeline`) is exactly as opener/keeper-shaped as mvnd's own genuine + /// `Building [n/m]` header. Probe: 50 such lines in an + /// otherwise-green single-module run kept wholesale pre-fix (bloating + /// output on a real reactor's 2000-line equivalent to ~0.1% savings); + /// post-fix, dropped, same as plain `mvn` already drops any `[INFO]` + /// line outside its keep-list. + #[test] + fn daemon_mode_building_keeper_drops_app_log_spam_on_tagged_lane() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [child-a] [INFO] ----------------------< com.example.rtk:child-a >-----------------------\n\ + [child-a] [INFO] Building child-a 1.0.0-SNAPSHOT [1/1]\n\ + [child-a] [INFO] Running com.example.rtk.APassTest\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.APassTest\n", + ); + for n in 0..50 { + i.push_str(&format!( + "[child-a] [INFO] Building segment {n} of the data pipeline\n" + )); + } + i.push_str("[INFO] BUILD SUCCESS\n"); + let o = filter_surefire(&i, true); + assert!( + !o.contains("Building segment"), + "app-log spam shaped like a Building header is dropped on a tagged lane; got:\n{o}" + ); + assert!( + o.contains("< com.example.rtk:child-a >") && o.contains("BUILD SUCCESS"), + "genuine module banner and footer survive; got:\n{o}" + ); + assert!( + o.len() < 400, + "savings recover once the spam is dropped (input was {} bytes); got {} bytes:\n{o}", + i.len(), + o.len() + ); + } + + /// Real-fixture control for the same gate: mvnd's own genuine + /// `Building [n/m]` headers (the shape + /// [`BUILDING_MODULE_HEADER`] requires) must still survive on tagged + /// lanes — the gate narrows, it doesn't drop real content. Already + /// covered end-to-end by the `mvnd_reactor_pass_full_output` / + /// `mvnd_parallel_reactor_fail_full_output` fixture-diff tests staying + /// green (both fixtures' `Building … [n/3]` lines are asserted present + /// via `include_str!`-fixture byte equality); this test pins the + /// specific line directly, independent of the fuller snapshot. + #[test] + fn daemon_mode_building_keeper_keeps_real_module_header() { + let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); + let o = filter_package(i, true); + assert!( + o.contains("Building child-a 1.0.0-SNAPSHOT") && o.contains("Building child-b 1.0.0-SNAPSHOT"), + "genuine `[n/m]`-numbered module headers survive on tagged lanes; got:\n{o}" + ); + } + + /// Cold-preclear pin (upstream PR #3199, fourth review round): unlike + /// `[ERROR]`/`Building`, `is_lane_opener`'s `RUNNING` arm has no + /// fixture-grounded shape to narrow on, so an app log merely shaped + /// `[main] [INFO] Running the widget pipeline` still mints a tagged lane + /// in daemon mode. Enforced contract (see `is_lane_opener`'s doc + /// comment): that lane's unconfirmed tag never gets a genuine `CLOSE` + /// line, so its block flushes verbatim, in its own order, at + /// end-of-stream — after every other lane's content, including the + /// build footer. Bounded to exactly that one line: nothing else + /// reorders, nothing is lost, no other lane's content is touched. + #[test] + fn app_log_running_line_mints_tagged_lane_and_flushes_at_end_of_stream() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.APassTest\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.APassTest\n\ + [main] [INFO] Running the widget pipeline\n\ + [INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i, true); + assert_eq!( + o.matches("Running the widget pipeline").count(), + 1, + "the app-log line survives exactly once, not lost or duplicated; got:\n{o}" + ); + let pipeline = o.find("Running the widget pipeline").expect("line kept"); + let footer = o.find("BUILD SUCCESS").expect("footer kept"); + assert!( + footer < pipeline, + "the unconfirmed tag's block flushes at end-of-stream, after the footer \ + (bounded over-keep + reorder of exactly this one line); got:\n{o}" + ); + assert!( + o.contains("Scanning for projects"), + "unrelated content is untouched; got:\n{o}" + ); + } + + // Thin `daemon`-fixed wrappers so the bulk of the test suite (written + // before the `daemon` gate — cold-preclear finding, upstream PR #3199, + // third review round) keeps its original `fn(&str) -> String` / + // `fn(&str, usize) -> String` call shape. `_plain` == real `mvn` + // (`daemon == false`, base parity by construction); `_daemon` == `mvnd`. + fn filter_surefire_plain(raw: &str) -> String { + filter_surefire(raw, false) + } + fn filter_surefire_daemon(raw: &str) -> String { + filter_surefire(raw, true) + } + fn filter_compile_plain(raw: &str) -> String { + filter_compile(raw, false) + } + fn filter_compile_daemon(raw: &str) -> String { + filter_compile(raw, true) + } + fn filter_package_plain(raw: &str) -> String { + filter_package(raw, false) + } + fn filter_package_daemon(raw: &str) -> String { + filter_package(raw, true) + } + fn filter_surefire_with_cap_plain(raw: &str, cap: usize) -> String { + filter_surefire_with_cap(raw, cap, false) + } + fn filter_surefire_with_cap_daemon(raw: &str, cap: usize) -> String { + filter_surefire_with_cap(raw, cap, true) + } + fn filter_package_with_cap_plain(raw: &str, cap: usize) -> String { + filter_package_with_cap(raw, cap, false) + } + fn filter_package_with_cap_daemon(raw: &str, cap: usize) -> String { + filter_package_with_cap(raw, cap, true) + } + + /// Cold-preclear finding #2 (upstream PR #3199, third review round), + /// KuSh's "fuller two-module probe": module banners, `Running`, genuine + /// failing closes (Surefire 3.x multi-failure shape — one class per + /// module, two failing methods each), per-module `Results:`/`Failures:`, + /// and interleaved `AGG` lines — modeled on `mvnd_reactor_fail_raw.txt`'s + /// real shape, extended with enough failures to exceed the cap. Asserts + /// all three things the finding named: the reactor-wide budget still + /// engages on real (non-root) lanes (2 kept, not 4), each lane's tail is + /// attributed to its own AGG (never the other lane's), and output order + /// stays sane (banners before Running before closes before the capped + /// summary before the reactor footer). + fn assert_fuller_two_module_probe_engages_cap(filter: fn(&str, usize) -> String) { + let i = "[INFO] Scanning for projects...\n\ + [child-b] [INFO] ----------------------< com.example.rtk:child-b >-----------------------\n\ + [child-b] [INFO] Building child-b 1.0.0-SNAPSHOT\n\ + [child-a] [INFO] ----------------------< com.example.rtk:child-a >-----------------------\n\ + [child-a] [INFO] Building child-a 1.0.0-SNAPSHOT\n\ + [child-a] [INFO] Running com.example.rtk.AMultiFailTest\n\ + [child-b] [INFO] Running com.example.rtk.BMultiFailTest\n\ + [child-a] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.AMultiFailTest\n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.first -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a1 boom\n\ + \tat com.example.rtk.AMultiFailTest.first(AMultiFailTest.java:10)\n\ + [child-a] [INFO] \n\ + [child-b] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.04 s <<< FAILURE! -- in com.example.rtk.BMultiFailTest\n\ + [child-b] [ERROR] com.example.rtk.BMultiFailTest.first -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: b1 boom\n\ + \tat com.example.rtk.BMultiFailTest.first(BMultiFailTest.java:10)\n\ + [child-b] [INFO] \n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.second -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a2 boom\n\ + \tat com.example.rtk.AMultiFailTest.second(AMultiFailTest.java:20)\n\ + [child-a] [INFO] \n\ + [child-b] [ERROR] com.example.rtk.BMultiFailTest.second -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: b2 boom\n\ + \tat com.example.rtk.BMultiFailTest.second(BMultiFailTest.java:20)\n\ + [child-b] [INFO] \n\ + [child-a] [INFO] Results:\n\ + [child-b] [INFO] Results:\n\ + [child-a] [ERROR] Failures: \n\ + [child-b] [ERROR] Failures: \n\ + [child-a] [ERROR] AMultiFailTest.first:10 a1 boom\n\ + [child-b] [ERROR] BMultiFailTest.first:10 b1 boom\n\ + [child-a] [ERROR] AMultiFailTest.second:20 a2 boom\n\ + [child-b] [ERROR] BMultiFailTest.second:20 b2 boom\n\ + [child-a] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [child-b] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] Reactor Summary for multi-module-fail-skeleton 1.0.0-SNAPSHOT:\n\ + [INFO] \n\ + [INFO] child-a ............................................ FAILURE [ 1.234 s]\n\ + [INFO] child-b ............................................ FAILURE [ 1.234 s]\n\ + [INFO] BUILD FAILURE\n"; + let o = filter(i, 2); + + // Cap engagement: only the *first* summary entry per module is + // kept — pre-fix (stale open block swallowing the header/entries) + // all 4 would survive. + assert!( + o.contains("AMultiFailTest.first:10 a1 boom") && o.contains("BMultiFailTest.first:10 b1 boom"), + "the first entry of each module's summary is kept; got:\n{o}" + ); + assert!( + !o.contains("AMultiFailTest.second:20 a2 boom") && !o.contains("BMultiFailTest.second:20 b2 boom"), + "the second entry of each module's summary is capped, not kept; got:\n{o}" + ); + + // Per-lane tail attribution: each module's own drop is reported + // under its own AGG, not the other module's. + assert_eq!( + o.matches("… +1 more failures").count(), + 2, + "each module reports exactly its own one dropped entry; got:\n{o}" + ); + assert!( + !o.contains("… +2 more failures"), + "no module's tail should absorb the other's drop; got:\n{o}" + ); + let a_header = o.find("[child-a] [ERROR] Failures:").expect("child-a header kept"); + let a_agg = o + .find("[child-a] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n") + .expect("child-a AGG kept"); + let b_header = o.find("[child-b] [ERROR] Failures:").expect("child-b header kept"); + let b_agg = o + .rfind("[child-b] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n") + .expect("child-b AGG kept"); + assert!( + o[a_header..a_agg].contains("… +1 more failures"), + "child-a's tail sits between its own header and its own AGG; got:\n{o}" + ); + assert!( + o[b_header..b_agg].contains("… +1 more failures"), + "child-b's tail sits between its own header and its own AGG; got:\n{o}" + ); + + // Ordering: banners, Running, and the capped summary all survive in + // a sane relative order, ending at the reactor footer. + let banner_a = o.find("< com.example.rtk:child-a >").expect("child-a banner kept"); + let running_a = o + .find("Running com.example.rtk.AMultiFailTest") + .expect("child-a Running kept"); + let build_failure = o.rfind("BUILD FAILURE").expect("footer kept"); + assert!( + banner_a < running_a && running_a < a_header && a_header < a_agg && a_agg < build_failure, + "banner < Running < header < AGG < footer; got:\n{o}" + ); + } + + #[test] + fn mvnd_fuller_two_module_probe_engages_cap() { + assert_fuller_two_module_probe_engages_cap(filter_surefire_with_cap_daemon); + } + + #[test] + fn mvnd_package_fuller_two_module_probe_engages_cap() { + assert_fuller_two_module_probe_engages_cap(filter_package_with_cap_daemon); + } + /// Reviewer finding #3 (upstream PR #3199, second review round): d602a3b /// gated every fall-through `keep_continuation` disarm on `keyed`, /// protecting mvnd's tagged lanes — but the root (untagged) lane, the @@ -1700,7 +2104,7 @@ mod tests { /// output grown to 1588 / 2391 bytes) — a regression on either axis fails /// this test. fn assert_root_lane_disarms_on_raw_trail_line(fixture: &str, expected_bytes: usize) { - let o = filter_compile(fixture); + let o = filter_compile(fixture, true); let frames = o .lines() .filter(|l| l.trim_start().starts_with("at ")) @@ -1851,7 +2255,7 @@ mod tests { #[test] fn mvnd_reactor_pass_keeps_summary_drops_daemon_noise() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, true); assert!(o.contains("[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:")); assert!(o.contains("child-a ............................................ SUCCESS")); assert!(o.contains("child-b ............................................ SUCCESS")); @@ -1872,7 +2276,7 @@ mod tests { #[test] fn mvnd_reactor_pass_savings() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, true); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } @@ -1883,7 +2287,7 @@ mod tests { #[test] fn mvnd_test_fail_preserves_failures() { let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!(o.contains("[INFO] Running com.example.rtk.BoomTest")); assert!(o.contains("[INFO] Running com.example.rtk.CalcTest")); assert!(o.contains("failOne: addition should equal five ==> expected: <5> but was: <4>")); @@ -1905,7 +2309,7 @@ mod tests { #[test] fn mvnd_test_fail_savings() { let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, true); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } @@ -1920,7 +2324,7 @@ mod tests { #[test] fn mvnd_parallel_reactor_fail_preserves_diagnostics() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!(o.contains("[child-a] [INFO] Running com.example.rtk.ParallelFailTest")); assert!(o.contains( "[child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed:" @@ -1948,7 +2352,7 @@ mod tests { #[test] fn mvnd_parallel_reactor_fail_savings() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, true); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } @@ -1997,12 +2401,12 @@ mod tests { #[test] fn mvnd_interleaved_block_does_not_steal_failure_trail() { - assert_interleaved_trail_survives(filter_surefire); + assert_interleaved_trail_survives(filter_surefire_daemon); } #[test] fn mvnd_package_interleaved_block_does_not_steal_failure_trail() { - assert_interleaved_trail_survives(filter_package); + assert_interleaved_trail_survives(filter_package_daemon); } /// Raw lines with no unambiguous owner — several plain blocks open, no @@ -2025,12 +2429,12 @@ mod tests { #[test] fn mvnd_ambiguous_raw_line_is_preserved() { - assert_ambiguous_raw_line_preserved(filter_surefire); + assert_ambiguous_raw_line_preserved(filter_surefire_daemon); } #[test] fn mvnd_package_ambiguous_raw_line_is_preserved() { - assert_ambiguous_raw_line_preserved(filter_package); + assert_ambiguous_raw_line_preserved(filter_package_daemon); } /// javac emits `symbol:` / `location:` as raw indented lines *after* the @@ -2045,7 +2449,7 @@ mod tests { \x20 symbol: variable bar\n\ \x20 location: class com.example.rtk.A\n\ [INFO] BUILD FAILURE\n"; - let o = filter_compile(i); + let o = filter_compile(i, true); assert!( o.contains("symbol: variable bar"), "continuation survives the interleave; got:\n{o}" @@ -2065,12 +2469,18 @@ mod tests { /// cap=2 budget, so each gets its own `… +1 more failures`, not one /// combined `… +2 more failures` sitting under whichever AGG happened /// to arrive first. + /// + /// Cold-preclear finding (upstream PR #3199, third review round): a bare + /// `Running` line — no explicit close for it — is exactly what real + /// mvnd always emits for a module before its failures summary + /// (establishing the lane), and is deliberately used here rather than a + /// `Running` + passing-close pair: the pair alone doesn't reach the + /// stale-open-block path this test guards, so it wouldn't have caught + /// the regression. fn assert_summary_cap_shared_across_lanes(filter: fn(&str, usize) -> String) { let i = "[INFO] Scanning for projects...\n\ [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ - [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ - [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-b] [ERROR] Failures: \n\ @@ -2099,12 +2509,12 @@ mod tests { #[test] fn mvnd_failures_summary_cap_is_shared_across_lanes() { - assert_summary_cap_shared_across_lanes(filter_surefire_with_cap); + assert_summary_cap_shared_across_lanes(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_failures_summary_cap_is_shared_across_lanes() { - assert_summary_cap_shared_across_lanes(filter_package_with_cap); + assert_summary_cap_shared_across_lanes(filter_package_with_cap_daemon); } /// Reviewer finding #2 (upstream PR #3199, second review round), exact @@ -2132,9 +2542,7 @@ mod tests { fn assert_shared_budget_race_attributes_tails_per_lane(filter: fn(&str, usize) -> String) { let i = "[INFO] Scanning for projects...\n\ [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ - [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ - [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-a] [ERROR] ChildATest.two:12 boom a2\n\ @@ -2180,12 +2588,12 @@ mod tests { #[test] fn mvnd_shared_budget_race_attributes_tails_per_lane() { - assert_shared_budget_race_attributes_tails_per_lane(filter_surefire_with_cap); + assert_shared_budget_race_attributes_tails_per_lane(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_shared_budget_race_attributes_tails_per_lane() { - assert_shared_budget_race_attributes_tails_per_lane(filter_package_with_cap); + assert_shared_budget_race_attributes_tails_per_lane(filter_package_with_cap_daemon); } /// The failures-summary budget spans the whole invocation: module @@ -2195,9 +2603,7 @@ mod tests { fn assert_summary_cap_spans_sequential_lanes(filter: fn(&str, usize) -> String) { let i = "[INFO] Scanning for projects...\n\ [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ - [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ - [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-a] [ERROR] ChildATest.two:12 boom a2\n\ @@ -2221,12 +2627,12 @@ mod tests { #[test] fn mvnd_failures_summary_cap_spans_sequential_lanes() { - assert_summary_cap_spans_sequential_lanes(filter_surefire_with_cap); + assert_summary_cap_spans_sequential_lanes(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_failures_summary_cap_spans_sequential_lanes() { - assert_summary_cap_spans_sequential_lanes(filter_package_with_cap); + assert_summary_cap_spans_sequential_lanes(filter_package_with_cap_daemon); } /// Reviewer probe (upstream PR #3199 finding 1): `mvn verify`/`install` @@ -2248,7 +2654,7 @@ mod tests { } i.push_str("[ERROR] Tests run: 12, Failures: 12, Errors: 0, Skipped: 0\n[INFO] BUILD FAILURE\n"); - let o = filter_package(&i); + let o = filter_package(&i, false); assert_eq!( o.matches("boom u").count(), 10, @@ -2295,12 +2701,12 @@ mod tests { #[test] fn surefire_errors_only_summary_respects_cap() { - assert_errors_only_summary_respects_cap(filter_surefire_with_cap); + assert_errors_only_summary_respects_cap(filter_surefire_with_cap_plain); } #[test] fn package_errors_only_summary_respects_cap() { - assert_errors_only_summary_respects_cap(filter_package_with_cap); + assert_errors_only_summary_respects_cap(filter_package_with_cap_plain); } /// Cold-preclear finding (🟡 2): truncated output — a lane's failures @@ -2316,9 +2722,7 @@ mod tests { fn assert_truncated_summary_flushes_per_lane_tail(filter: fn(&str, usize) -> String) { let i = "[INFO] BUILD FAILURE\n\ [child-a] [INFO] Running com.example.rtk.ChildAPass\n\ - [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildAPass\n\ [child-b] [INFO] Running com.example.rtk.ChildBPass\n\ - [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.ChildBPass\n\ [child-a] [ERROR] Failures: \n\ [child-a] [ERROR] ChildATest.one:11 boom a1\n\ [child-a] [ERROR] ChildATest.two:12 boom a2\n\ @@ -2348,12 +2752,12 @@ mod tests { #[test] fn mvnd_truncated_summary_flushes_per_lane_tail() { - assert_truncated_summary_flushes_per_lane_tail(filter_surefire_with_cap); + assert_truncated_summary_flushes_per_lane_tail(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_truncated_summary_flushes_per_lane_tail() { - assert_truncated_summary_flushes_per_lane_tail(filter_package_with_cap); + assert_truncated_summary_flushes_per_lane_tail(filter_package_with_cap_daemon); } /// Same phase-reset invariant as `failures_summary_cap_resets_per_phase_on_same_lane`, @@ -2373,7 +2777,7 @@ mod tests { } i.push_str("[ERROR] Tests run: 0, Failures: 0, Errors: 12, Skipped: 0\n[INFO] BUILD FAILURE\n"); - let o = filter_package(&i); + let o = filter_package(&i, false); assert_eq!( o.matches("boom u").count(), 10, @@ -2391,42 +2795,47 @@ mod tests { ); } - /// Reviewer follow-up (upstream PR #3199, final review round): child-a - /// and child-b both finish a first-generation summary (Surefire), then - /// child-a opens its second-generation summary (Failsafe) — the reset - /// generation boundary. Before child-a's second-generation summary - /// closes, child-b's own second-generation header interleaves in. That - /// must be treated as child-b's first sighting *in the new generation* - /// (sharing the budget child-a already spent), not a second reset — a - /// second reset would let child-a's still-open block emit up to - /// `2 × cap` entries total instead of `cap`. + /// Cold-preclear finding (upstream PR #3199, third review round): + /// generation resets are now driven exclusively by + /// [`FailuresSummaryCap::observe_plugin_banner`], not lane-repeat + /// inference — a design that structurally can't double-reset from two + /// *different* lanes' headers repeating (there's no per-lane repeat + /// tracking left to trip over), but must also not spuriously reset + /// *within* one phase just because two modules each carry their own + /// Surefire banner. child-a and child-b share the identical + /// `surefire:3.5.5:test (default-test)` goal string — `observe_plugin_banner` + /// must treat child-b's own banner as a no-op (same phase), not a fresh + /// budget, even though it's a different lane's banner. #[test] - fn interleaved_second_generation_header_does_not_double_reset_budget() { + fn same_goal_banners_from_different_lanes_do_not_reset_budget() { let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] --- surefire:3.5.5:test (default-test) @ child-a ---\n\ + [child-a] [INFO] Running com.example.rtk.AOneTest\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.AOneTest\n\ + [child-b] [INFO] --- surefire:3.5.5:test (default-test) @ child-b ---\n\ + [child-b] [INFO] Running com.example.rtk.BOneTest\n\ + [child-b] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.BOneTest\n\ [child-a] [ERROR] Failures:\n\ [child-a] [ERROR] entryA1\n\ - [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [child-a] [ERROR] entryA2\n\ [child-b] [ERROR] Failures:\n\ [child-b] [ERROR] entryB1\n\ + [child-a] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ - [child-a] [ERROR] Failures:\n\ - [child-a] [ERROR] entryA2\n\ - [child-a] [ERROR] entryA3\n\ - [child-b] [ERROR] Failures:\n\ - [child-a] [ERROR] entryA4\n\ - [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ - [child-b] [ERROR] entryB2\n\ - [child-b] [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ [INFO] BUILD FAILURE\n"; - let o = filter_package_with_cap(i, 2); + let o = filter_package_with_cap(i, 2, true); assert!( - o.contains("entryA2") && o.contains("entryA3"), - "second-generation entries under the cap survive; got:\n{o}" + o.contains("entryA1") && o.contains("entryA2"), + "child-a's two entries fill the shared cap=2 budget; got:\n{o}" ); assert!( - !o.contains("entryA4"), - "child-a's second-generation block stays at cap=2, not 3 \ - (child-b's interleaved header must not double-reset the budget); got:\n{o}" + !o.contains("entryB1"), + "child-b's own same-goal banner must not grant it a fresh budget; got:\n{o}" + ); + assert_eq!( + o.matches("… +1 more failures").count(), + 1, + "exactly one dropped entry (child-b's), not a doubled or missing tail; got:\n{o}" ); } @@ -2455,12 +2864,12 @@ mod tests { #[test] fn mvnd_surefire_interleaved_compile_continuation_survives() { - assert_interleaved_compile_continuation_survives(filter_surefire); + assert_interleaved_compile_continuation_survives(filter_surefire_daemon); } #[test] fn mvnd_package_interleaved_compile_continuation_survives() { - assert_interleaved_compile_continuation_survives(filter_package); + assert_interleaved_compile_continuation_survives(filter_package_daemon); } // ── Exhaustive interleaving sweeps ────────────────────────────────────── @@ -2567,12 +2976,12 @@ mod tests { #[test] fn mvnd_every_interleaving_keeps_diagnostics() { - assert_every_interleaving_keeps_diagnostics(filter_surefire); + assert_every_interleaving_keeps_diagnostics(filter_surefire_daemon); } #[test] fn mvnd_package_every_interleaving_keeps_diagnostics() { - assert_every_interleaving_keeps_diagnostics(filter_package); + assert_every_interleaving_keeps_diagnostics(filter_package_daemon); } /// Two failing modules under `cap = 1`, all 924 merges: whichever class @@ -2614,12 +3023,12 @@ mod tests { #[test] fn mvnd_every_interleaving_keeps_admitted_class() { - assert_every_interleaving_keeps_admitted_class(filter_surefire_with_cap); + assert_every_interleaving_keeps_admitted_class(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_every_interleaving_keeps_admitted_class() { - assert_every_interleaving_keeps_admitted_class(filter_package_with_cap); + assert_every_interleaving_keeps_admitted_class(filter_package_with_cap_daemon); } /// Compile-error module × passing test module, all 35 merges: the raw @@ -2644,12 +3053,12 @@ mod tests { #[test] fn mvnd_every_interleaving_keeps_compile_continuation() { - assert_every_interleaving_keeps_compile_continuation(filter_surefire); + assert_every_interleaving_keeps_compile_continuation(filter_surefire_daemon); } #[test] fn mvnd_package_every_interleaving_keeps_compile_continuation() { - assert_every_interleaving_keeps_compile_continuation(filter_package); + assert_every_interleaving_keeps_compile_continuation(filter_package_daemon); } /// Compile-error module × *failing* test module, all 84 merges: the armed @@ -2676,12 +3085,12 @@ mod tests { #[test] fn mvnd_every_interleaving_keeps_continuation_and_failure() { - assert_every_interleaving_keeps_continuation_and_failure(filter_surefire); + assert_every_interleaving_keeps_continuation_and_failure(filter_surefire_daemon); } #[test] fn mvnd_package_every_interleaving_keeps_continuation_and_failure() { - assert_every_interleaving_keeps_continuation_and_failure(filter_package); + assert_every_interleaving_keeps_continuation_and_failure(filter_package_daemon); } /// Dropping-trail variant of the orphaned-continuation case, cap=1: A's @@ -2720,12 +3129,12 @@ mod tests { #[test] fn mvnd_continuation_survives_dropping_trail() { - assert_continuation_survives_dropping_trail(filter_surefire_with_cap); + assert_continuation_survives_dropping_trail(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_continuation_survives_dropping_trail() { - assert_continuation_survives_dropping_trail(filter_package_with_cap); + assert_continuation_survives_dropping_trail(filter_package_with_cap_daemon); } /// Entering a Surefire block retires a lane's stale armed claim: a lane @@ -2752,12 +3161,12 @@ mod tests { #[test] fn mvnd_block_entry_retires_armed_claim() { - assert_block_entry_retires_armed_claim(filter_surefire); + assert_block_entry_retires_armed_claim(filter_surefire_daemon); } #[test] fn mvnd_package_block_entry_retires_armed_claim() { - assert_block_entry_retires_armed_claim(filter_package); + assert_block_entry_retires_armed_claim(filter_package_daemon); } /// An unrelated raw stdout line (another module's, unprefixed) must not @@ -2780,12 +3189,12 @@ mod tests { #[test] fn mvnd_raw_stray_does_not_disarm_continuation() { - assert_raw_stray_does_not_disarm_continuation(filter_surefire); + assert_raw_stray_does_not_disarm_continuation(filter_surefire_daemon); } #[test] fn mvnd_package_raw_stray_does_not_disarm_continuation() { - assert_raw_stray_does_not_disarm_continuation(filter_package); + assert_raw_stray_does_not_disarm_continuation(filter_package_daemon); } /// Reviewer blocker (upstream PR #3199, final review round): a @@ -2811,22 +3220,22 @@ mod tests { #[test] fn mvnd_warning_interloper_does_not_disarm_continuation() { - assert_warning_interloper_does_not_disarm_continuation(filter_surefire); + assert_warning_interloper_does_not_disarm_continuation(filter_surefire_daemon); } #[test] fn mvnd_compile_warning_interloper_does_not_disarm_continuation() { - assert_warning_interloper_does_not_disarm_continuation(filter_compile); + assert_warning_interloper_does_not_disarm_continuation(filter_compile_daemon); } #[test] fn mvnd_package_warning_interloper_does_not_disarm_continuation() { - assert_warning_interloper_does_not_disarm_continuation(filter_package); + assert_warning_interloper_does_not_disarm_continuation(filter_package_daemon); } #[test] fn mvnd_compile_raw_stray_does_not_disarm_continuation() { - assert_raw_stray_does_not_disarm_continuation(filter_compile); + assert_raw_stray_does_not_disarm_continuation(filter_compile_daemon); } /// Cold-preclear finding: a `[tag] [ERROR] …` app-log line for a @@ -2854,17 +3263,17 @@ mod tests { #[test] fn mvnd_error_interloper_does_not_disarm_continuation() { - assert_error_interloper_does_not_disarm_continuation(filter_surefire); + assert_error_interloper_does_not_disarm_continuation(filter_surefire_daemon); } #[test] fn mvnd_compile_error_interloper_does_not_disarm_continuation() { - assert_error_interloper_does_not_disarm_continuation(filter_compile); + assert_error_interloper_does_not_disarm_continuation(filter_compile_daemon); } #[test] fn mvnd_package_error_interloper_does_not_disarm_continuation() { - assert_error_interloper_does_not_disarm_continuation(filter_package); + assert_error_interloper_does_not_disarm_continuation(filter_package_daemon); } /// child-a variant: one class with TWO blank-separated per-test detail @@ -2906,12 +3315,12 @@ mod tests { #[test] fn mvnd_raw_stray_does_not_disarm_trail_rearm() { - assert_raw_stray_does_not_disarm_trail_rearm(filter_surefire); + assert_raw_stray_does_not_disarm_trail_rearm(filter_surefire_daemon); } #[test] fn mvnd_package_raw_stray_does_not_disarm_trail_rearm() { - assert_raw_stray_does_not_disarm_trail_rearm(filter_package); + assert_raw_stray_does_not_disarm_trail_rearm(filter_package_daemon); } /// Drop side of the same claim: with the class capped, the stray must @@ -2936,12 +3345,12 @@ mod tests { #[test] fn mvnd_raw_stray_does_not_leak_capped_rearm() { - assert_raw_stray_does_not_leak_capped_rearm(filter_surefire_with_cap); + assert_raw_stray_does_not_leak_capped_rearm(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_raw_stray_does_not_leak_capped_rearm() { - assert_raw_stray_does_not_leak_capped_rearm(filter_package_with_cap); + assert_raw_stray_does_not_leak_capped_rearm(filter_package_with_cap_daemon); } const RAW_BLANK_STRAY: [&str; 1] = [""]; @@ -2970,12 +3379,12 @@ mod tests { #[test] fn mvnd_raw_blank_does_not_terminate_trail() { - assert_raw_blank_does_not_terminate_trail(filter_surefire); + assert_raw_blank_does_not_terminate_trail(filter_surefire_daemon); } #[test] fn mvnd_package_raw_blank_does_not_terminate_trail() { - assert_raw_blank_does_not_terminate_trail(filter_package); + assert_raw_blank_does_not_terminate_trail(filter_package_daemon); } /// Drop side of the same claim: with the class capped (fully dropped), a @@ -3003,12 +3412,12 @@ mod tests { #[test] fn mvnd_raw_blank_does_not_leak_capped_trail() { - assert_raw_blank_does_not_leak_capped_trail(filter_surefire_with_cap); + assert_raw_blank_does_not_leak_capped_trail(filter_surefire_with_cap_daemon); } #[test] fn mvnd_package_raw_blank_does_not_leak_capped_trail() { - assert_raw_blank_does_not_leak_capped_trail(filter_package_with_cap); + assert_raw_blank_does_not_leak_capped_trail(filter_package_with_cap_daemon); } /// Two modules armed concurrently: raw continuation lines have no unique @@ -3032,17 +3441,17 @@ mod tests { #[test] fn mvnd_two_armed_lanes_preserve_continuations() { - assert_two_armed_lanes_preserve_continuations(filter_surefire); + assert_two_armed_lanes_preserve_continuations(filter_surefire_daemon); } #[test] fn mvnd_package_two_armed_lanes_preserve_continuations() { - assert_two_armed_lanes_preserve_continuations(filter_package); + assert_two_armed_lanes_preserve_continuations(filter_package_daemon); } #[test] fn mvnd_compile_two_armed_lanes_preserve_continuations() { - assert_two_armed_lanes_preserve_continuations(filter_compile); + assert_two_armed_lanes_preserve_continuations(filter_compile_daemon); } // Full-output regression tests locking the complete filtered output of @@ -3056,28 +3465,28 @@ mod tests { fn mvnd_reactor_pass_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_pass_raw.txt"); let expected = include_str!("../../../tests/fixtures/mvnd_reactor_pass_expected.txt"); - assert_eq!(filter_package(i), expected); + assert_eq!(filter_package(i, true), expected); } #[test] fn mvnd_test_fail_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_test_fail_raw.txt"); let expected = include_str!("../../../tests/fixtures/mvnd_test_fail_expected.txt"); - assert_eq!(filter_surefire(i), expected); + assert_eq!(filter_surefire(i, true), expected); } #[test] fn mvnd_parallel_reactor_fail_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_reactor_fail_raw.txt"); let expected = include_str!("../../../tests/fixtures/mvnd_reactor_fail_expected.txt"); - assert_eq!(filter_surefire(i), expected); + assert_eq!(filter_surefire(i, true), expected); } #[test] fn mvnd_compile_error_full_output() { let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); let expected = include_str!("../../../tests/fixtures/mvnd_compile_error_expected.txt"); - assert_eq!(filter_compile(i), expected); + assert_eq!(filter_compile(i, true), expected); } /// `mvnd compile` on a syntax error (exit code 1): compile diagnostics @@ -3085,7 +3494,7 @@ mod tests { #[test] fn mvnd_compile_error_preserves_diagnostics() { let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); - let o = filter_compile(i); + let o = filter_compile(i, true); assert!(o.contains("Calc.java:[5,21] ';' expected")); assert!(o.contains("[INFO] BUILD FAILURE")); assert!(o.contains("[ERROR] Failed to execute goal")); @@ -3099,7 +3508,7 @@ mod tests { #[test] fn mvnd_compile_error_savings() { let i = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt"); - let o = filter_compile(i); + let o = filter_compile(i, true); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!(savings >= 60.0, "expected >=60% savings, got {savings:.1}%"); } @@ -3109,7 +3518,7 @@ mod tests { #[test] fn filter_surefire_pass_output_compact() { let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); // Passing fixture has 5 close lines; all should be dropped (no per-class line in output). assert!(!o.contains("Running org.apache.commons.cli.help.UtilTest")); assert!(!o.contains("Time elapsed: 1.023 s -- in")); @@ -3124,7 +3533,7 @@ mod tests { #[test] fn filter_surefire_fail_keeps_signal() { let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("BUILD FAILURE")); assert!(o.contains("Failures: 1")); } @@ -3132,7 +3541,7 @@ mod tests { #[test] fn surefire_drops_passing_block() { let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( !o.contains("at org.junit."), "framework frames stripped; got:\n{}", @@ -3158,7 +3567,7 @@ mod tests { #[test] fn surefire_preserves_failing_signal() { let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( o.contains("Failures: 1"), "failing aggregate preserved; got:\n{}", @@ -3186,7 +3595,7 @@ mod tests { #[test] fn surefire_matches_legacy_2x_close_line() { let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.123 s - in x.Foo\n[INFO] BUILD SUCCESS\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); // CLOSE matched → passing block dropped silently. assert!( !o.contains("Running x.Foo"), @@ -3205,7 +3614,7 @@ mod tests { #[test] fn surefire_matches_warning_skipped_close_line() { let i = "[INFO] -----< x >-----\n[INFO] Running x.Skip\n[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 5, Time elapsed: 0.010 s -- in x.Skip\n[INFO] BUILD SUCCESS\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( !o.contains("Running x.Skip"), "[WARNING] close-line matched; block dropped; got:\n{}", @@ -3227,7 +3636,7 @@ mod tests { \tat org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1)\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("AssertionFailedError"), "exception preserved; got:\n{}", o); assert!(o.contains("at x.Foo.bar"), "user frame preserved; got:\n{}", o); assert!( @@ -3246,7 +3655,7 @@ mod tests { #[test] fn surefire_keeps_all_failures_in_multi_failure_class() { let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( o.contains("AssertionFailedError: failOne: addition should equal five"), "first failure message preserved; got:\n{}", @@ -3284,7 +3693,7 @@ mod tests { #[test] fn package_keeps_all_failures_in_multi_failure_class() { let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); assert!( o.contains("AssertionFailedError: failOne: addition should equal five"), "first failure message preserved; got:\n{}", @@ -3332,7 +3741,7 @@ mod tests { \tat x.MultiFail.second(MultiFail.java:30)\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire_with_cap(i, 1); + let o = filter_surefire_with_cap(i, 1, false); assert!(o.contains("boomA"), "first class kept; got:\n{}", o); assert!( @@ -3368,7 +3777,7 @@ mod tests { [INFO] Results:\n\ [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("boomSecond"), "second block kept; got:\n{}", o); assert!( o.contains("[INFO] Results:"), @@ -3397,7 +3806,7 @@ mod tests { org.opentest4j.AssertionFailedError: boomSecond\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("boomFirst"), "first block kept; got:\n{}", o); assert!( o.contains("boomSecond"), @@ -3418,7 +3827,7 @@ mod tests { #[test] fn surefire_single_failure_output_unchanged() { let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); let expected = "[INFO] Scanning for projects...\n\ [INFO] ----------------------< commons-cli:commons-cli >-----------------------\n\ [INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT\n\ @@ -3447,7 +3856,7 @@ mod tests { #[test] fn savings_mvn_test_multifail_slice() { let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 30.0, @@ -3463,7 +3872,7 @@ mod tests { #[test] fn surefire_drops_help_boilerplate_in_nonquiet_mode() { let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( o.contains("[ERROR] Failed to execute goal"), "goal terminator kept; got:\n{}", @@ -3512,7 +3921,7 @@ mod tests { #[test] fn surefire_keeps_compile_continuation_on_test_phase() { let i = include_str!("../../../tests/fixtures/mvn_test_compile_fail_slice_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); assert!( o.contains("symbol: variable bar"), @@ -3534,7 +3943,7 @@ mod tests { #[test] fn package_still_keeps_compile_error_continuation_after_refactor() { let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); assert!( o.contains("symbol: variable bar"), @@ -3572,23 +3981,23 @@ mod tests { #[test] fn surefire_root_lane_arms_broadly_without_file_coord() { - assert_root_lane_arms_broadly_without_file_coord(filter_surefire); + assert_root_lane_arms_broadly_without_file_coord(filter_surefire_plain); } #[test] fn compile_root_lane_arms_broadly_without_file_coord() { - assert_root_lane_arms_broadly_without_file_coord(filter_compile); + assert_root_lane_arms_broadly_without_file_coord(filter_compile_plain); } #[test] fn package_root_lane_arms_broadly_without_file_coord() { - assert_root_lane_arms_broadly_without_file_coord(filter_package); + assert_root_lane_arms_broadly_without_file_coord(filter_package_plain); } #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!(o.contains("-----< com.example:myapp >-----")); } @@ -3600,7 +4009,7 @@ mod tests { #[test] fn surefire_preserves_real_durations() { let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in x.Foo\n[INFO] BUILD FAILURE\n[INFO] Total time: 4.567 s\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( o.contains("2.341 s"), "raw close-line duration preserved; got:\n{}", @@ -3621,7 +4030,7 @@ mod tests { #[test] fn footer_guard_french_passthrough() { let i = include_str!("../../../tests/fixtures/mvn_locale_fr_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); assert!( o.contains("BUILD ÉCHEC"), "footer-guard must pass through non-English output; got:\n{}", @@ -3638,7 +4047,7 @@ mod tests { #[test] fn footer_guard_no_pom_passthrough() { let i = include_str!("../../../tests/fixtures/mvn_no_pom_raw.txt"); - let o = filter_surefire(i); + let o = filter_surefire(i, false); // No BUILD footer → passthrough; user sees the `[ERROR] no POM` line. assert!( o.contains("there is no POM"), @@ -3660,9 +4069,9 @@ mod tests { fn surefire_handles_crlf_line_endings() { let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt") .replace("\r\n", "\n"); - let o_lf = filter_surefire(&i_lf); + let o_lf = filter_surefire(&i_lf, false); let i_crlf = i_lf.replace('\n', "\r\n"); - let o_crlf = filter_surefire(&i_crlf); + let o_crlf = filter_surefire(&i_crlf, false); assert_eq!( o_lf, o_crlf.replace("\r\n", "\n"), @@ -3674,9 +4083,9 @@ mod tests { fn package_handles_crlf_line_endings() { let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt") .replace("\r\n", "\n"); - let o_lf = filter_package(&i_lf); + let o_lf = filter_package(&i_lf, false); let i_crlf = i_lf.replace('\n', "\r\n"); - let o_crlf = filter_package(&i_crlf); + let o_crlf = filter_package(&i_crlf, false); assert_eq!( o_lf, o_crlf.replace("\r\n", "\n"), @@ -3688,9 +4097,9 @@ mod tests { fn compile_handles_crlf_line_endings() { let i_lf = include_str!("../../../tests/fixtures/mvnd_compile_error_raw.txt") .replace("\r\n", "\n"); - let o_lf = filter_compile(&i_lf); + let o_lf = filter_compile(&i_lf, false); let i_crlf = i_lf.replace('\n', "\r\n"); - let o_crlf = filter_compile(&i_crlf); + let o_crlf = filter_compile(&i_crlf, false); assert_eq!( o_lf, o_crlf.replace("\r\n", "\n"), @@ -3722,7 +4131,7 @@ mod tests { } i.push_str("[INFO] BUILD FAILURE\n"); - let o = filter_surefire_with_cap(&i, 3); + let o = filter_surefire_with_cap(&i, 3, false); // First 3 blocks emitted with their close lines. for n in 1..=3 { @@ -3778,7 +4187,7 @@ mod tests { )); } i.push_str("[INFO] BUILD FAILURE\n"); - let o = filter_surefire_with_cap(&i, 0); + let o = filter_surefire_with_cap(&i, 0, false); for n in 1..=5 { assert!( !o.contains(&format!("Running x.Fail{}", n)), @@ -3816,7 +4225,7 @@ mod tests { [ERROR] Tests run: 100, Failures: 5, Errors: 0, Skipped: 0\n\ [INFO] BUILD FAILURE\n", ); - let o = filter_surefire_with_cap(&i, 3); + let o = filter_surefire_with_cap(&i, 3, false); // First 3 entries kept. for n in 1..=3 { @@ -3860,7 +4269,7 @@ mod tests { #[test] fn reactor_summary_kept_on_multi_module_pass() { let i = include_str!("../../../tests/fixtures/mvn_reactor_pass_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); assert!( o.contains("Reactor Summary for multi-module-skeleton"), "reactor summary header preserved; got:\n{}", @@ -3890,7 +4299,7 @@ mod tests { #[test] fn reactor_summary_kept_on_multi_module_fail() { let i = include_str!("../../../tests/fixtures/mvn_reactor_fail_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); assert!( o.contains("Reactor Summary for multi-module-skeleton"), "reactor summary header preserved; got:\n{}", @@ -3936,7 +4345,7 @@ mod tests { #[test] fn filter_compile_error_compact() { let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); - let o = filter_compile(i); + let o = filter_compile(i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 30.0, @@ -3948,7 +4357,7 @@ mod tests { #[test] fn compile_preserves_error_continuation() { let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); - let o = filter_compile(i); + let o = filter_compile(i, false); assert!(o.contains("cannot find symbol"), "ERROR line preserved"); assert!( o.contains("symbol: variable bar"), @@ -3969,7 +4378,7 @@ mod tests { [WARNING] /b.java:[3,4] uses deprecated API\n\ [WARNING] /a.java:[5,6] unchecked cast\n\ [INFO] BUILD SUCCESS\n"; - let o = filter_compile(i); + let o = filter_compile(i, false); let warns = o.matches("[WARNING]").count(); assert_eq!(warns, 2, "dedup by normalised message; got:\n{}", o); } @@ -3979,7 +4388,7 @@ mod tests { #[test] fn filter_package_install_compact() { let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 50.0, @@ -3991,7 +4400,7 @@ mod tests { #[test] fn package_keeps_install_lines() { let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); - let o = filter_package(i); + let o = filter_package(i, false); assert!( o.contains("Installing"), "install line preserved; got:\n{}", @@ -4015,7 +4424,7 @@ mod tests { #[ignore] fn print_savings_summary() { let pf = gunzip(include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz")); - let pf_out = filter_surefire(&pf); + let pf_out = filter_surefire(&pf, false); let pf_in_tok = count_tokens(&pf); let pf_out_tok = count_tokens(&pf_out); let pf_s = 100.0 - (pf_out_tok as f64 / pf_in_tok as f64 * 100.0); @@ -4025,7 +4434,7 @@ mod tests { ); let inst = gunzip(include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz")); - let inst_out = filter_package(&inst); + let inst_out = filter_package(&inst, false); let inst_in_tok = count_tokens(&inst); let inst_out_tok = count_tokens(&inst_out); let inst_s = 100.0 - (inst_out_tok as f64 / inst_in_tok as f64 * 100.0); @@ -4039,7 +4448,7 @@ mod tests { fn savings_mvn_test_pass_full() { let bytes = include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz"); let i = gunzip(bytes); - let o = filter_surefire(&i); + let o = filter_surefire(&i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); assert!( savings >= 90.0, @@ -4054,7 +4463,7 @@ mod tests { fn savings_mvn_install_full() { let bytes = include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz"); let i = gunzip(bytes); - let o = filter_package(&i); + let o = filter_package(&i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); assert!( savings >= 85.0, @@ -4199,48 +4608,245 @@ mod tests { #[test] fn split_lane_requires_real_level_in_second_bracket() { assert_eq!( - split_lane("[child-a] [INFO] Running com.example.rtk.FooTest"), + split_lane("[child-a] [INFO] Running com.example.rtk.FooTest", true), (Some("child-a"), "[INFO] Running com.example.rtk.FooTest") ); assert_eq!( - split_lane("[main] [status] app started"), + split_lane("[main] [status] app started", true), (None, "[main] [status] app started") ); assert_eq!( - split_lane("[INFO] plain single-bracket line"), + split_lane("[INFO] plain single-bracket line", true), (Some(""), "[INFO] plain single-bracket line") ); assert_eq!( - split_lane("at com.example.rtk.FooTest.bar(FooTest.java:1)"), + split_lane("at com.example.rtk.FooTest.bar(FooTest.java:1)", true), (None, "at com.example.rtk.FooTest.bar(FooTest.java:1)") ); // Bracket-shaped but not Maven's own `[LEVEL] ...` output and not a // real `[tag] [LEVEL]` module line either: raw, not root-keyed. assert_eq!( - split_lane("[boom] weird bracket assertion line"), + split_lane("[boom] weird bracket assertion line", true), (None, "[boom] weird bracket assertion line") ); assert_eq!( - split_lane("[1, 2] != [1, 3]"), + split_lane("[1, 2] != [1, 3]", true), (None, "[1, 2] != [1, 3]") ); // No `"] "` substring at all: only Maven's own bare `[LEVEL]` blank // root-keys — a bracketed assertion-diff fragment with no `"] "` // anywhere is raw, same as the `"] "`-present case above. - assert_eq!(split_lane("[INFO]"), (Some(""), "[INFO]")); - assert_eq!(split_lane("[1, 2]"), (None, "[1, 2]")); - assert_eq!(split_lane("[boom]"), (None, "[boom]")); + assert_eq!(split_lane("[INFO]", true), (Some(""), "[INFO]")); + assert_eq!(split_lane("[1, 2]", true), (None, "[1, 2]")); + assert_eq!(split_lane("[boom]", true), (None, "[boom]")); // Empty tag: `""` is the root lane's reserved key, so `[] [LEVEL] …` // must not impersonate root-keyed routing — raw, not `(Some(""), …)`. assert_eq!( - split_lane("[] [INFO] started"), + split_lane("[] [INFO] started", true), (None, "[] [INFO] started") ); // Tagged blank, no-trailing-space spelling (`[tag] [LEVEL]`, nothing // after — `lane_rest_level`'s documented other spelling of the // daemon-prefixed trail terminator, alongside `[tag] [LEVEL] `). // Pinned so the "both spellings" contract can't rot. - assert_eq!(split_lane("[child-a] [INFO]"), (Some("child-a"), "[INFO]")); + assert_eq!(split_lane("[child-a] [INFO]", true), (Some("child-a"), "[INFO]")); + } + + // ── daemon gate: plain `mvn` never routes through the lane layer ──────── + + /// Cold-preclear finding (upstream PR #3199, third review round), SLF4J + /// probe: an SLF4J/Logback `[%thread] [%level] %msg` layout produces + /// lines syntactically identical to a real mvnd module line. Critically, + /// `is_lane_opener`'s `RUNNING` arm is intentionally permissive (any + /// `[INFO] Running …`, no `FILE_COORD` guard like the `[ERROR]` arm + /// has) — an app log line that merely happens to start with "Running" + /// (`[main] [INFO] Running the widget pipeline`) is exactly as + /// opener-shaped as a genuine mvnd module's own `Running` line. Pre-fix, + /// this minted a phantom `main` lane on *plain* `mvn` output (`daemon == + /// false`), opened a block that never closed, and detached the + /// failure's own context lines so they printed after the build footer, + /// reading as belonging to nothing. Post-fix, `daemon == false` + /// short-circuits `split_lane` before any bracket parsing happens at + /// all, so this is structurally impossible: the context lines stay + /// exactly where base would keep them — inside the failing block, + /// before the footer. + #[test] + fn slf4j_thread_tag_does_not_mint_phantom_lane_in_plain_mvn() { + let i = "[INFO] Scanning for projects...\n\ + [INFO] Running com.example.rtk.MyTest\n\ + [main] [INFO] Running the widget pipeline\n\ + [main] [INFO] connecting to db\n\ + [main] [INFO] connection refused\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.MyTest\n\ + [ERROR] com.example.rtk.MyTest.testFoo -- Time elapsed: 0.05 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.rtk.MyTest.testFoo(MyTest.java:10)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i, false); + let pipeline = o + .find("Running the widget pipeline") + .expect("app-log Running-shaped line kept"); + let db = o.find("connecting to db").expect("first context line kept"); + let refused = o.find("connection refused").expect("second context line kept"); + let footer = o.rfind("BUILD FAILURE").expect("footer kept"); + assert!( + pipeline < db && db < refused && refused < footer, + "context lines stay attached to their failing block, in order, \ + before the footer — never detached and reordered after it; got:\n{o}" + ); + } + + /// Cold-preclear finding (upstream PR #3199, third review round), + /// timestamp probe: a `[timestamp] [LEVEL] …` layout is exactly as + /// lane-shaped as an SLF4J thread tag. Pre-fix, 300 uniquely-timestamped + /// lines each minted their own phantom lane on plain `mvn` output — + /// base drops every one of them (none match any keep-list pattern), but + /// the lane bug resurrected all 300 at end-of-stream. Post-fix, `daemon + /// == false` means none of these lines are ever bracket-parsed at all, + /// so they fall through the same keep-list check as base and are + /// dropped identically. + #[test] + fn timestamp_tag_does_not_mint_phantom_lanes_in_plain_mvn() { + let mut i = String::from("[INFO] Scanning for projects...\n"); + for n in 0..300 { + i.push_str(&format!("[2026-08-29 10:00:00] [INFO] Building segment {n}\n")); + } + i.push_str("[INFO] BUILD SUCCESS\n"); + let o = filter_surefire(&i, false); + assert!( + !o.contains("Building segment"), + "timestamp-tagged app log lines are dropped, exactly like base \ + (none match any keep-list pattern); got:\n{o}" + ); + } + + /// Cold-preclear finding #2 (upstream PR #3199, third review round): a + /// module whose *only* failures are integration-test (Failsafe) ones — + /// never Surefire — must still get a fresh budget for its own summary. + /// child-a exhausts cap=2 in the Surefire phase (3 failures, 1 dropped); + /// child-b's failsafe banner (a *different* goal string from child-a's + /// surefire banner) is the phase-marker boundary that resets the budget + /// before child-b's own (first-ever-sighting, never-repeating) header is + /// processed — so its one integration-test failure survives instead of + /// sharing Surefire's already-spent budget. + #[test] + fn failsafe_only_module_gets_fresh_budget_via_phase_marker() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] --- surefire:3.5.5:test (default-test) @ child-a ---\n\ + [child-a] [INFO] Running com.example.rtk.AMultiFailTest\n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.AMultiFailTest\n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.one -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a1 boom\n\ + \tat com.example.rtk.AMultiFailTest.one(AMultiFailTest.java:10)\n\ + [child-a] [INFO] \n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.two -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a2 boom\n\ + \tat com.example.rtk.AMultiFailTest.two(AMultiFailTest.java:20)\n\ + [child-a] [INFO] \n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.three -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a3 boom\n\ + \tat com.example.rtk.AMultiFailTest.three(AMultiFailTest.java:30)\n\ + [child-a] [INFO] \n\ + [child-a] [INFO] Results:\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] AMultiFailTest.one:10 a1 boom\n\ + [child-a] [ERROR] AMultiFailTest.two:20 a2 boom\n\ + [child-a] [ERROR] AMultiFailTest.three:30 a3 boom\n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [child-b] [INFO] --- failsafe:3.5.5:integration-test (default-integration-test) @ child-b ---\n\ + [child-b] [INFO] Running com.example.rtk.BIT\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.BIT\n\ + [child-b] [ERROR] com.example.rtk.BIT.only -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: b1 boom\n\ + \tat com.example.rtk.BIT.only(BIT.java:5)\n\ + [child-b] [INFO] \n\ + [child-b] [INFO] Results:\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] BIT.only:5 b1 boom\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_package_with_cap(i, 2, true); + assert!( + o.contains("AMultiFailTest.one:10 a1 boom") && o.contains("AMultiFailTest.two:20 a2 boom"), + "child-a's first two entries fill its Surefire-phase budget; got:\n{o}" + ); + assert!( + !o.contains("AMultiFailTest.three:30 a3 boom"), + "child-a's third entry is capped; got:\n{o}" + ); + assert!( + o.contains("BIT.only:5 b1 boom"), + "child-b's Failsafe-only entry gets a fresh budget from the phase-marker \ + boundary, not zero from Surefire's already-spent one; got:\n{o}" + ); + assert_eq!( + o.matches("… +1 more failures").count(), + 1, + "only child-a reports a drop (its own third entry); child-b's fresh \ + budget covers its one entry with no tail; got:\n{o}" + ); + } + + /// Cold-preclear finding (upstream PR #3199, fourth review round): same + /// scenario as [`failsafe_only_module_gets_fresh_budget_via_phase_marker`], + /// but with Maven ≤3.8 / mvnd 0.x's banner spelling + /// (`maven-surefire-plugin:2.22.2:test` / `maven-failsafe-plugin:2.22.2: + /// integration-test`, not the bare `surefire:`/`failsafe:` shorthand). + /// Pre-fix, `TEST_PLUGIN_BANNER` didn't match either banner at all, so + /// `observe_plugin_banner` never fired and child-b's Failsafe-only entry + /// silently inherited child-a's already-spent budget (kept 0, not 1) — + /// the exact failure the phase marker exists to prevent. + #[test] + fn failsafe_only_module_gets_fresh_budget_with_old_plugin_banner_spelling() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ child-a ---\n\ + [child-a] [INFO] Running com.example.rtk.AMultiFailTest\n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.AMultiFailTest\n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.one -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a1 boom\n\ + \tat com.example.rtk.AMultiFailTest.one(AMultiFailTest.java:10)\n\ + [child-a] [INFO] \n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.two -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a2 boom\n\ + \tat com.example.rtk.AMultiFailTest.two(AMultiFailTest.java:20)\n\ + [child-a] [INFO] \n\ + [child-a] [ERROR] com.example.rtk.AMultiFailTest.three -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: a3 boom\n\ + \tat com.example.rtk.AMultiFailTest.three(AMultiFailTest.java:30)\n\ + [child-a] [INFO] \n\ + [child-a] [INFO] Results:\n\ + [child-a] [ERROR] Failures: \n\ + [child-a] [ERROR] AMultiFailTest.one:10 a1 boom\n\ + [child-a] [ERROR] AMultiFailTest.two:20 a2 boom\n\ + [child-a] [ERROR] AMultiFailTest.three:30 a3 boom\n\ + [child-a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [child-b] [INFO] --- maven-failsafe-plugin:2.22.2:integration-test (default-integration-test) @ child-b ---\n\ + [child-b] [INFO] Running com.example.rtk.BIT\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.BIT\n\ + [child-b] [ERROR] com.example.rtk.BIT.only -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: b1 boom\n\ + \tat com.example.rtk.BIT.only(BIT.java:5)\n\ + [child-b] [INFO] \n\ + [child-b] [INFO] Results:\n\ + [child-b] [ERROR] Failures: \n\ + [child-b] [ERROR] BIT.only:5 b1 boom\n\ + [child-b] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_package_with_cap(i, 2, true); + assert!( + o.contains("AMultiFailTest.one:10 a1 boom") && o.contains("AMultiFailTest.two:20 a2 boom"), + "child-a's first two entries fill its Surefire-phase budget; got:\n{o}" + ); + assert!( + !o.contains("AMultiFailTest.three:30 a3 boom"), + "child-a's third entry is capped; got:\n{o}" + ); + assert!( + o.contains("BIT.only:5 b1 boom"), + "old-spelling `maven-failsafe-plugin:...` banner still fires the phase-marker \ + reset, so child-b's Failsafe-only entry gets a fresh budget; got:\n{o}" + ); } /// Reviewer probe (upstream PR #3199 finding 2): an application log line @@ -4260,7 +4866,7 @@ mod tests { \tat com.example.rtk.MyTest.testFoo(MyTest.java:10)\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!( o.contains("app started with config=/etc/foo.yml"), "app log line kept in the committed block; got:\n{o}" @@ -4287,7 +4893,7 @@ mod tests { \tat com.example.rtk.FailTest.bar(FailTest.java:5)\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!( o.contains("[child-a] [ERROR] Tests run: 1, Failures: 1"), "failing module's close kept; got:\n{o}" @@ -4358,12 +4964,12 @@ mod tests { #[test] fn surefire_bracket_leading_trail_line_survives() { - assert_bracket_leading_trail_line_survives(filter_surefire); + assert_bracket_leading_trail_line_survives(filter_surefire_daemon); } #[test] fn package_bracket_leading_trail_line_survives() { - assert_bracket_leading_trail_line_survives(filter_package); + assert_bracket_leading_trail_line_survives(filter_package_daemon); } /// Reviewer probe (upstream PR #3199 finding 3): two `[tag] [ERROR] …` @@ -4388,7 +4994,7 @@ mod tests { \tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\ \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!( o.contains("at com.example.rtk.RealTest.foo(RealTest.java:5)"), "the single user frame survives; got:\n{o}" @@ -4425,7 +5031,7 @@ mod tests { \tat com.example.rtk.SlowTest.foo(SlowTest.java:5)\n\ [child-a] [INFO] \n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); let running = o.find("Running com.example.rtk.SlowTest").expect("Running kept"); let server = o .find("[Server] [ERROR] connection refused") @@ -4464,7 +5070,7 @@ mod tests { [child-a] [INFO] Running com.example.rtk.NextTest\n\ [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.NextTest\n\ [INFO] BUILD FAILURE\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!( o.contains("at com.example.rtk.SlowTest.foo(SlowTest.java:5)"), "user frame survives; got:\n{o}" @@ -4493,7 +5099,7 @@ mod tests { [Server] [ERROR] connection refused\n\ [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.05 s -- in com.example.rtk.SlowTest\n\ [INFO] BUILD SUCCESS\n"; - let o = filter_surefire(i); + let o = filter_surefire(i, true); assert!( !o.contains("[Server]") && !o.contains("connection refused"), "app-log line inside a green-closed block must be discarded with the rest \ @@ -4535,9 +5141,10 @@ mod tests { let mut lanes = Lanes::new(); let mut classes = FailingClassCap::new(MAX_MVN_FAILING_CLASSES); + let mut summary = FailuresSummaryCap::new(MAX_MVN_FAILING_CLASSES, true); let mut out = String::new(); for line in i.lines() { - drive_surefire_line(&mut lanes, line, &mut classes, &mut out); + drive_surefire_line(&mut lanes, line, &mut classes, &mut summary, true, &mut out); } assert!( lanes.lanes.len() <= MAX_LANES, @@ -4545,7 +5152,7 @@ mod tests { lanes.lanes.len() ); - let o = filter_surefire(&i); + let o = filter_surefire(&i, true); assert!(!o.is_empty(), "capped run still produces output"); assert!( o.contains("Running com.example.rtk.OverflowTest") From 323897b2f463783c19ace1c732d09907fae26fbd Mon Sep 17 00:00:00 2001 From: kylehgc Date: Sat, 29 Aug 2026 20:49:21 -0400 Subject: [PATCH 13/14] fix(mvn): require Surefire's own emission shape to mint a Running lane Review response for upstream #3199 (KuSh round 4): is_lane_opener's RUNNING arm now requires 'Running ' -- Surefire emits "Running " + clazz.getName() and a class name cannot contain a space, an invariant KuSh verified across all 22 Running lines in all 16 real fixtures. A multi-word app-log line ('Running the widget pipeline for tenant acme') no longer mints a lane: on a green daemon run 200 such lines now filter to 53 bytes (was ~12KB), and inside a failing block the line rides raw-line ownership at its own input position. The former pin-test enshrining the end-of-stream flush residual is retired -- there is no residual left to document; every opener arm now requires the emitting tool's own shape (RUNNING: bare FQCN, Building: [n/m] counter, [ERROR]: file.java:[l,c] coordinate, CLOSE/banners already structural). RUNNING itself is untouched at the block-open site inside an established lane, where a module's own Running line must open its block regardless of what follows. Co-Authored-By: Claude Fable 5 --- src/cmds/jvm/mvn_cmd.rs | 188 ++++++++++++++++++++++++---------------- 1 file changed, 114 insertions(+), 74 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index eb733d2af6..aae50339f2 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -33,6 +33,17 @@ const MAX_LANES: usize = 256; /// `[INFO] Running com.example.app.FooTest` static RUNNING: LazyLock = LazyLock::new(|| Regex::new(r"^\[INFO\] Running ").unwrap()); +/// Genuine mvnd module opener shape for `Running`: Surefire emits `"Running +/// " + clazz.getName()` — a single whitespace-free dotted FQCN, never +/// whitespace (a Java class name cannot contain a space). Confirmed against +/// all 22 `Running` lines across all 16 real fixtures. Used only in +/// [`is_lane_opener`] to narrow the never-seen-tag admission case; every +/// other `RUNNING` call site (block open/reset) is untouched — an app log +/// merely shaped `[main] [INFO] Running the widget pipeline` (multi-word +/// argument) no longer qualifies as a lane opener. +static RUNNING_MODULE_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^\[INFO\] Running \S+\s*$").unwrap()); + /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. /// Tolerates the optional `<<< FAILURE!` / `<<< ERROR!` marker (3.5.5 emits /// `<<< FAILURE!` even for errors-only classes — see @@ -346,59 +357,53 @@ fn lane_rest_level(rest: &str) -> Option<&str> { /// [`Lanes::route`] uses to admit a never-seen tag as a new lane rather /// than falling back to raw-line ownership. /// -/// Reviewer finding #1 (upstream PR #3199, second review round): a blanket -/// `[ERROR]`-prefix rule (no `FILE_COORD` requirement) let *any* -/// `[ERROR]`-shaped app log on a never-seen tag (e.g. `[Server] [ERROR] -/// connection refused`) mint a lane. The claim it opens stays inert either -/// way (a continuation only ever arms on a genuine compiler diagnostic — -/// see the `FILE_COORD` guard at each `keep_continuation` arm site), but -/// *minting the lane at all* was itself observable: routed to its own fresh -/// (empty, `in_block == false`) lane instead of falling back to -/// [`Lanes::raw_owner`], such a line bypassed whichever other lane's buffered -/// block was genuinely open around it — written to `out` immediately via the -/// outside-block keep-list while that other block was still buffering, -/// re-ordering it ahead of the `Running` line it followed in the input, and -/// on a green close (block discarded, not written at all) letting it survive -/// as a stray line the whole rest of the passing block was correctly dropped -/// for. Requiring `FILE_COORD` here fixes both: a non-diagnostic `[ERROR]` -/// line on a never-seen tag is no longer an opener, so `route` falls back to -/// `raw_owner`, which (with exactly one lane's block open) buffers it into -/// *that* lane at its correct input position — preserved in order on a -/// failing close, discarded with the rest on a green one. A genuine -/// `file.java:[line,col]` compiler diagnostic is unaffected (mvnd's -/// compile-error-first-sight case: no `Running`/`Building` line ever -/// precedes it). A Surefire close whose module is never otherwise seen -/// (`[ERROR] Tests run: … <<< FAILURE!`) is unaffected too — it's admitted -/// by the separate `CLOSE.is_match` arm above, not this one. +/// Enforced contract: every arm requires the tool's own emission shape for +/// that line kind, not just a matching prefix — `RUNNING`: +/// [`RUNNING_MODULE_HEADER`]'s single whitespace-free dotted FQCN (Surefire +/// emits `"Running " + clazz.getName()`; a Java class name cannot contain a +/// space — confirmed against all 22 `Running` lines across all 16 real +/// fixtures); `Building`: [`BUILDING_MODULE_HEADER`]'s trailing `[n/m]` +/// reactor-position counter; `[ERROR]`: [`FILE_COORD`]'s `file.java:[l,c]` +/// coordinate; `CLOSE`/`MODULE_BANNER`/`PLUGIN_BANNER` are already +/// structurally specific. An app log merely shaped like one of these (a +/// multi-word `[main] [INFO] Running the widget pipeline`, a `[pool-1] +/// [INFO] Building segment N of the data pipeline`, a `[Server] [ERROR] +/// connection refused`) never qualifies — it falls back to +/// [`Lanes::raw_owner`] like any other raw line instead of minting a +/// phantom lane. /// -/// The `RUNNING` arm is *not* narrowed the way `[ERROR]`/`Building` are: a -/// genuine mvnd `Running` line carries no extra marker beyond `[INFO] -/// Running ` (confirmed against every real fixture — unlike -/// `Building`'s trailing `[n/m]` reactor-position counter, there is nothing -/// fixture-grounded to gate on), so an app log merely shaped `[main] [INFO] -/// Running the widget pipeline` still mints a tagged lane in daemon mode. -/// Contract for that lane: it is bounded to the lines that opened and fed -/// it — its unconfirmed tag's block never closes (no genuine `CLOSE` line -/// ever arrives for it), so it flushes verbatim, in its own input order, -/// at end-of-stream (i.e. after everything else, including the build -/// footer). Over-keep and reorder of exactly those lines, never loss and -/// never touching any other lane's content — see -/// `app_log_running_line_mints_tagged_lane_and_flushes_at_end_of_stream`. +/// Reviewer finding #1 (upstream PR #3199, second review round) established +/// this pattern for the `[ERROR]` arm: a blanket `[ERROR]`-prefix rule (no +/// `FILE_COORD` requirement) let *any* `[ERROR]`-shaped app log on a +/// never-seen tag (e.g. `[Server] [ERROR] connection refused`) mint a lane. +/// The claim it opens stays inert either way (a continuation only ever arms +/// on a genuine compiler diagnostic — see the `FILE_COORD` guard at each +/// `keep_continuation` arm site), but *minting the lane at all* was itself +/// observable: routed to its own fresh (empty, `in_block == false`) lane +/// instead of falling back to [`Lanes::raw_owner`], such a line bypassed +/// whichever other lane's buffered block was genuinely open around it — +/// written to `out` immediately via the outside-block keep-list while that +/// other block was still buffering, re-ordering it ahead of the `Running` +/// line it followed in the input, and on a green close (block discarded, not +/// written at all) letting it survive as a stray line the whole rest of the +/// passing block was correctly dropped for. Requiring `FILE_COORD` here +/// fixes both: a non-diagnostic `[ERROR]` line on a never-seen tag is no +/// longer an opener, so `route` falls back to `raw_owner`, which (with +/// exactly one lane's block open) buffers it into *that* lane at its correct +/// input position — preserved in order on a failing close, discarded with +/// the rest on a green one. A genuine `file.java:[line,col]` compiler +/// diagnostic is unaffected (mvnd's compile-error-first-sight case: no +/// `Running`/`Building` line ever precedes it). A Surefire close whose +/// module is never otherwise seen (`[ERROR] Tests run: … <<< FAILURE!`) is +/// unaffected too — it's admitted by the separate `CLOSE.is_match` arm +/// above, not this one. /// /// Lane growth itself is bounded by [`MAX_LANES`] regardless, so an /// adversarial run of uniquely-tagged opener-shaped lines can mint at most /// that many lanes, keeping `route`'s scan O(`MAX_LANES`) regardless of how /// many distinct tags the input contains. -/// -/// Cold-preclear finding (upstream PR #3199, fourth review round): the -/// `Building` arm used a bare `starts_with` — any `[pool-1] [INFO] Building -/// segment N of the data pipeline` app log qualified. This function is only -/// ever consulted for a *never-seen* tag (see [`Lanes::route`]), i.e. always -/// a candidate *tagged* lane, never root — so it can require the real mvnd -/// module shape unconditionally: [`BUILDING_MODULE_HEADER`]'s trailing -/// `[n/m]` reactor-position counter. fn is_lane_opener(core: &str) -> bool { - RUNNING.is_match(core) + RUNNING_MODULE_HEADER.is_match(core) || CLOSE.is_match(core) || MODULE_BANNER.is_match(core) || PLUGIN_BANNER.is_match(core) @@ -1896,39 +1901,74 @@ mod tests { ); } - /// Cold-preclear pin (upstream PR #3199, fourth review round): unlike - /// `[ERROR]`/`Building`, `is_lane_opener`'s `RUNNING` arm has no - /// fixture-grounded shape to narrow on, so an app log merely shaped - /// `[main] [INFO] Running the widget pipeline` still mints a tagged lane - /// in daemon mode. Enforced contract (see `is_lane_opener`'s doc - /// comment): that lane's unconfirmed tag never gets a genuine `CLOSE` - /// line, so its block flushes verbatim, in its own order, at - /// end-of-stream — after every other lane's content, including the - /// build footer. Bounded to exactly that one line: nothing else - /// reorders, nothing is lost, no other lane's content is touched. + /// KuSh's exact probe (resolving the round-5 RUNNING residual, upstream + /// PR #3199, fifth review round): 200 multi-word `[main] [INFO] Running + /// …` app-log lines inside an otherwise-green module. Pre-fix (`RUNNING` + /// itself as the opener test), each one minted its own phantom lane and + /// flushed at end-of-stream — 3.1% savings on the equivalent full-size + /// probe. Post-fix (`RUNNING_MODULE_HEADER`'s whitespace-free-FQCN + /// requirement), none of them are lane-openers, so they fall back to + /// `raw_owner` and are dropped like any other unkeyed `[INFO]` line + /// outside a keep-list — 98%+ savings. #[test] - fn app_log_running_line_mints_tagged_lane_and_flushes_at_end_of_stream() { - let i = "[INFO] Scanning for projects...\n\ - [child-a] [INFO] Running com.example.rtk.APassTest\n\ - [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.APassTest\n\ - [main] [INFO] Running the widget pipeline\n\ - [INFO] BUILD SUCCESS\n"; - let o = filter_surefire(i, true); - assert_eq!( - o.matches("Running the widget pipeline").count(), - 1, - "the app-log line survives exactly once, not lost or duplicated; got:\n{o}" + fn app_log_running_lines_dropped_in_green_daemon_run() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.APassTest\n\ + [child-a] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.rtk.APassTest\n", + ); + for n in 0..200 { + i.push_str(&format!( + "[main] [INFO] Running the widget pipeline for tenant acme {n}\n" + )); + } + i.push_str("[INFO] BUILD SUCCESS\n"); + let o = filter_surefire(&i, true); + assert!( + !o.contains("widget pipeline"), + "multi-word Running-shaped app log lines are dropped, not kept via a \ + phantom lane; got:\n{o}" ); - let pipeline = o.find("Running the widget pipeline").expect("line kept"); - let footer = o.find("BUILD SUCCESS").expect("footer kept"); assert!( - footer < pipeline, - "the unconfirmed tag's block flushes at end-of-stream, after the footer \ - (bounded over-keep + reorder of exactly this one line); got:\n{o}" + o.len() < 500, + "savings recover once the phantom lanes stop minting (input was {} bytes); \ + got {} bytes:\n{o}", + i.len(), + o.len() ); + } + + /// Same multi-word Running-shaped app-log line, but landing inside a + /// module's genuinely open (failing) block instead of a green run: with + /// no fixture-grounded shape to open its own lane on, it falls back to + /// `Lanes::raw_owner`, which routes it to the one uniquely open block — + /// same rule any other raw app-log line follows — so it's buffered into + /// that block and survives at its own input position (between the + /// `Running` line it followed and the failing close), not lost and not + /// reordered to end-of-stream. + #[test] + fn app_log_running_line_rides_raw_owner_inside_failing_block() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.AFailTest\n\ + [main] [INFO] Running the widget pipeline for tenant acme\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.example.rtk.AFailTest\n\ + [child-a] [ERROR] com.example.rtk.AFailTest.foo -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.rtk.AFailTest.foo(AFailTest.java:10)\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i, true); + let running = o.find("Running com.example.rtk.AFailTest").expect("Running kept"); + let pipeline = o + .find("Running the widget pipeline for tenant acme") + .expect("app-log line kept, buffered inside the block"); + let close = o + .find("<<< FAILURE! -- in com.example.rtk.AFailTest") + .expect("close line kept"); assert!( - o.contains("Scanning for projects"), - "unrelated content is untouched; got:\n{o}" + running < pipeline && pipeline < close, + "app-log line stays at its own input position inside the block \ + (Running, then the app log, then the close), not lost or moved \ + to end-of-stream; got:\n{o}" ); } From 37663ac11f89d838d8e25e5b340e0e848737ea45 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 30 Aug 2026 02:43:41 +0200 Subject: [PATCH 14/14] fix(mvn): gate the Building keeper on arrival, phase-key the failures budget, tag-aware quiet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the mvnd lane layer, each verified by A/B probe against the merge base and pinned by a regression test. Plain `mvn` is untouched: every change is daemon-gated, and the output is byte-identical to the merge base across all fixtures and entry points at every cap. - The `[INFO] Building ` keeper was gated on the lane a line was *routed to* (`idx != ROOT_LANE`), but an unrecognized tag falls back to `raw_owner`, which lands on the root lane when nothing is open — switching the gate off exactly where it was needed. Ten `[pool-1] [INFO] Building segment N …` app logs survived a green run at 0% savings. Gate on how the line arrived (tag-prefixed) instead, which is what the check was always about: a line carrying a module tag can never be plain `mvn`'s own single-module header. - The failures-summary budget keyed its phase on the full versioned goal (`surefire:3.5.5:test (default-test)`). Version and execution id are per-module configuration, so two modules that merely pinned different Surefire versions looked like two phases and each collected a fresh budget: cap=2 kept 4. Key on the plugin family, which also folds the `maven-surefire-plugin` spelling into the same phase. A genuine Surefire -> Failsafe transition still resets. - `filter_quiet` was the only filter with no prefix awareness: every `[child-a] [ERROR] …` line missed the close-line, subline, summary and boilerplate checks and fell through to the keep-anything-else net, making `mvnd -q` full passthrough with framework frames and help boilerplate retained. Classify on the unprefixed shape, emit the line as it arrived. The heuristic is daemon-gated for the same reason `split_lane` is: `[tag] [LEVEL] …` is byte-identical whether the tag is an mvnd module or an application thread name, so running it on plain `mvn` would classify — and drop — ordinary test diagnostics as Maven's own boilerplate. Co-Authored-By: Claude Opus 5 --- src/cmds/jvm/mvn_cmd.rs | 423 ++++++++++++++++++++++++++++++++-------- 1 file changed, 344 insertions(+), 79 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index aae50339f2..8b6456c8d1 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -76,22 +76,39 @@ static PLUGIN_BANNER: LazyLock = LazyLock::new(|| Regex::new(r"^\[INFO\] --- .* @ .* ---$").unwrap()); /// Surefire/Failsafe plugin execution banner specifically — captures the -/// goal identifier (`plugin:version:goal (execution-id)`, e.g. -/// `surefire:3.5.5:test (default-test)` vs `failsafe:3.5.5:integration-test -/// (default-integration-test)`) so a phase transition between the two can be -/// detected independent of lane/module identity (see -/// [`FailuresSummaryCap::observe_plugin_banner`]). Deliberately narrower -/// than [`PLUGIN_BANNER`], which matches *any* plugin's banner -/// (`compiler:...`, `resources:...`, `clean:...`, …) — those never open a -/// failures summary and must never trigger a budget reset. -/// Widened per cold-preclear finding (upstream PR #3199, fourth review -/// round): Maven ≤3.8 / mvnd 0.x spell the plugin coordinate -/// `maven-surefire-plugin:2.22.2` / `maven-failsafe-plugin:2.22.2`, not the -/// bare `surefire:`/`failsafe:` shorthand newer Maven emits — missing that -/// spelling silently disabled the generation reset on older daemons, the -/// exact failure mode the phase marker exists to prevent. +/// *plugin family* (`surefire` or `failsafe`) from +/// `[INFO] --- :: () @ ---`, +/// so a unit-test → integration-test transition can be detected independent +/// of lane/module identity (see +/// [`FailuresSummaryCap::observe_plugin_banner`]). +/// +/// The family is the whole key on purpose: version, goal and execution id +/// are all per-module configuration, so keying on them would make two +/// modules that merely pin different Surefire versions — or name their +/// executions differently — look like separate phases and each collect a +/// fresh budget. Both coordinate spellings normalize to the same family, so +/// `maven-surefire-plugin:2.22.2:test` (Maven ≤3.8 / mvnd 0.x) and +/// `surefire:3.5.5:test` are one phase, not two. +/// +/// The accepted cost is the other direction: a build that runs two *Surefire* +/// executions (unit tests, then integration tests without Failsafe) shares one +/// budget across both, so the second execution's entries can collapse into its +/// `… +N more failures` tail. The count is still reported; over-reporting a +/// phase boundary that isn't one was the worse failure, since it silently +/// multiplied the reactor-wide cap. +/// +/// Neither key bounds a parallel `verify`, where one module can reach +/// Failsafe while another is still in Surefire: the families alternate and +/// each flip starts a fresh budget, so the cap multiplies. Keying on the goal +/// string flips at least as often, so this is no worse than before — but the +/// reactor-wide cap is not a guarantee under interleaved phases. +/// +/// Deliberately narrower than [`PLUGIN_BANNER`], which matches *any* +/// plugin's banner (`compiler:…`, `resources:…`, `clean:…`, …) — those never +/// open a failures summary and must never trigger a budget reset. static TEST_PLUGIN_BANNER: LazyLock = LazyLock::new(|| { - Regex::new(r"^\[INFO\] --- ((?:maven-)?(?:surefire|failsafe)(?:-plugin)?:\S+ \([^)]*\)) @ .* ---$").unwrap() + Regex::new(r"^\[INFO\] --- (?:maven-)?(surefire|failsafe)(?:-plugin)?:\S+ \([^)]*\) @ .* ---$") + .unwrap() }); /// A genuine mvnd reactor module header: `Building @@ -343,7 +360,8 @@ fn lane_rest_level(rest: &str) -> Option<&str> { /// Whether `core` (the tag-stripped remainder of a candidate lane-keyed /// line) is a genuine Maven/mvnd module-establishing line: a test class -/// starting (`RUNNING`), a plugin or module banner, a `Building` line, a +/// starting ([`RUNNING_MODULE_HEADER`]), a plugin or module banner, a +/// `Building` line, a /// per-class result (`CLOSE`), or a genuine compiler diagnostic — a /// `file.java:[line,col]`-coordinate `[ERROR]` line ([`FILE_COORD`]; compiler /// errors are always the first line mvnd emits for a module that fails to @@ -455,17 +473,19 @@ fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { *in_reactor_summary } -/// `gate_building`: cold-preclear finding (upstream PR #3199, fourth review -/// round) — a bare `[INFO] Building ` keeper let `[pool-1] [INFO] Building -/// segment N of the data pipeline` app logs on a *tagged* mvnd lane through -/// wholesale (probe: 2000 such lines in a green run, ~0.1% savings). The -/// root lane's own `Building` header (plain `mvn`, single-module — never -/// reactor-numbered) must stay on the unconditional check, so the caller -/// passes `gate_building = daemon && idx != ROOT_LANE`: `true` narrows the -/// `[INFO] Building ` keeper to [`BUILDING_MODULE_HEADER`]'s real -/// `[n/m]`-numbered shape; `false` (root, or plain `mvn`) keeps the original -/// unconditional check. `Building war:`/`jar:`/`ear:` (artifact-packaging -/// lines, a different shape entirely) are never gated. +/// `gate_building` narrows the `[INFO] Building ` keeper to +/// [`BUILDING_MODULE_HEADER`]'s reactor-numbered `[n/m]` shape. Callers pass +/// `daemon && is_tag_prefixed(line, core)`: a line that arrived under a +/// module tag can never be plain `mvn`'s own single-module `Building` header, +/// so requiring the real shape there rejects `[pool-1] [INFO] Building +/// segment N of the data pipeline` app logs while leaving untagged headers +/// (plain `mvn`, and mvnd's own unprefixed output) on the unconditional +/// check. `Building war:`/`jar:`/`ear:` are artifact-packaging lines, a +/// different shape entirely, and are never gated. +fn is_tag_prefixed(line: &str, core: &str) -> bool { + core.len() != line.len() +} + fn keep_outside_block(line: &str, gate_building: bool) -> bool { // Help boilerplate must be rejected before the `[ERROR]` catch-all below // (non-quiet parity with `filter_quiet`'s boilerplate stripping). @@ -843,9 +863,9 @@ struct FailuresSummaryCap { /// plugin-banner phase transition ([`FailuresSummaryCap:: /// observe_plugin_banner`]). daemon: bool, - /// mvnd only: the goal identifier (`plugin:version:goal (execution-id)`) - /// captured from the most recent Surefire/Failsafe plugin banner. `None` - /// until the first banner is seen. + /// mvnd only: the plugin family (`surefire` / `failsafe`) captured from + /// the most recent test-plugin banner. `None` until the first banner is + /// seen. phase: Option, } @@ -913,22 +933,20 @@ impl FailuresSummaryCap { } } - /// mvnd only (no-op for plain `mvn`): `goal` is the captured - /// `plugin:version:goal (execution-id)` from a Surefire/Failsafe plugin - /// banner (see [`TEST_PLUGIN_BANNER`]). A goal that differs from the - /// currently tracked phase is an unambiguous generation boundary — - /// every module's own banner within one phase carries the *same* goal - /// string (module identity isn't part of the capture), so repeated - /// banners for different modules in the same phase never trigger a - /// reset; only a genuine Surefire → Failsafe (or vice versa) transition - /// does, regardless of whether any lane's summary header happens to - /// repeat across it. - fn observe_plugin_banner(&mut self, goal: &str) { + /// mvnd only (no-op for plain `mvn`): `family` is the plugin family + /// captured from a Surefire/Failsafe banner (see + /// [`TEST_PLUGIN_BANNER`]). Entering a family other than the one + /// currently tracked is a genuine unit → integration phase boundary and + /// starts a fresh reactor-wide budget; every module's banner within one + /// phase carries the same family, so repeated banners across modules + /// never reset, regardless of the versions or execution ids those + /// modules configure. + fn observe_plugin_banner(&mut self, family: &str) { if !self.daemon { return; } - if self.phase.as_deref() != Some(goal) { - self.phase = Some(goal.to_string()); + if self.phase.as_deref() != Some(family) { + self.phase = Some(family.to_string()); self.emitted = 0; } } @@ -1323,7 +1341,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize, daemon: bool) -> String { // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); - if reactor_keep || keep_outside_block(core, daemon && idx != ROOT_LANE) { + if reactor_keep || keep_outside_block(core, daemon && is_tag_prefixed(line, core)) { let lane = lanes.get(idx); // Pre-emit this lane's own summary tail when we're about to // write its own AGG (never another lane's — see the attribution @@ -1437,7 +1455,8 @@ pub fn filter_compile(raw: &str, daemon: bool) -> String { // see the cold-preclear finding on that function's doc comment. if BUILD_FOOT.is_match(core) || (core.starts_with("[INFO] Building ") - && (!(daemon && idx != ROOT_LANE) || BUILDING_MODULE_HEADER.is_match(core))) + && (!(daemon && is_tag_prefixed(line, core)) + || BUILDING_MODULE_HEADER.is_match(core))) || core.starts_with("[INFO] Total time:") || core.starts_with("[INFO] Finished at:") || core.starts_with("[INFO] Scanning ") @@ -1563,7 +1582,10 @@ fn filter_package_with_cap(raw: &str, cap: usize, daemon: bool) -> String { // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(core, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. - if reactor_keep || MODULE_BANNER.is_match(core) || keep_outside_block(core, daemon && idx != ROOT_LANE) { + if reactor_keep + || MODULE_BANNER.is_match(core) + || keep_outside_block(core, daemon && is_tag_prefixed(line, core)) + { let lane = lanes.get(idx); summary.handle_aggregate(core, &mut lane.dropped, &mut out, &mut lane.in_summary); summary.handle_header(core, &mut lane.in_summary, &mut lane.dropped); @@ -1627,6 +1649,35 @@ fn filter_package_with_cap(raw: &str, cap: usize, daemon: bool) -> String { // ── Quiet-mode filter ─────────────────────────────────────────────────────── +/// Strip an mvnd module prefix (`[module] `) so quiet-mode classification +/// sees the same line shape it does for unprefixed output. Module tags are +/// artifactIds — non-empty and whitespace-free — so a bracketed fragment +/// that holds whitespace, or that is itself a log level, is left alone. +/// Callers always emit the *original* line; this only decides how the line +/// is classified. +/// +/// Gated on `daemon` for the same reason [`split_lane`] is: `[tag] [LEVEL] …` +/// is byte-identical whether `tag` is an mvnd module or an application thread +/// name from `[%thread] [%level]` logging, and only the daemon emits the +/// former. Applying the heuristic to plain `mvn` would let ordinary test +/// stdout be classified — and therefore dropped — as Maven's own boilerplate. +fn strip_module_tag(line: &str, daemon: bool) -> &str { + if !daemon { + return line; + } + let Some(rest) = line.strip_prefix('[') else { + return line; + }; + let Some(end) = rest.find("] ") else { + return line; + }; + let tag = &rest[..end]; + if tag.is_empty() || tag.chars().any(char::is_whitespace) || is_log_level(tag) { + return line; + } + &rest[end + 2..] +} + /// Filter for `mvn -q` invocations. /// /// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard @@ -1642,7 +1693,7 @@ fn filter_package_with_cap(raw: &str, cap: usize, daemon: bool) -> String { /// `[ERROR] Failed to execute goal` terminator. Drops framework stack /// frames and the post-failure boilerplate block (`See …`, `[Help 1]`, /// `Re-run Maven`, `To see the full stack trace`, etc.). -pub fn filter_quiet(raw: &str) -> String { +pub fn filter_quiet(raw: &str, daemon: bool) -> String { let stripped = strip_ansi(raw); if stripped.trim().is_empty() { return String::new(); @@ -1652,18 +1703,22 @@ pub fn filter_quiet(raw: &str) -> String { let mut failure_trail = false; for line in stripped.lines() { + // mvnd prefixes each parallel module's own log lines with `[module] `; + // classify on the unprefixed shape, emit the line as it arrived. + let core = strip_module_tag(line, daemon); + // Surefire close-line for a failed class — keep + enter failure trail. - if CLOSE.is_match(line) { + if CLOSE.is_match(core) { out.push_str(line); out.push('\n'); failure_trail = - line.contains("<<< FAILURE!") || line.contains("<<< ERROR!"); + core.contains("<<< FAILURE!") || core.contains("<<< ERROR!"); continue; } // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` // (or `<<< ERROR!` for thrown exceptions). - if is_per_test_subline(line) { + if is_per_test_subline(core) { out.push_str(line); out.push('\n'); failure_trail = true; @@ -1672,12 +1727,20 @@ pub fn filter_quiet(raw: &str) -> String { // Failure-trail body: exception class, user-code frames; drop framework frames. if failure_trail { - if line.trim().is_empty() { + // A module's own blank line terminates its trail. mvnd's + // terminator carries the module prefix and is emitted as it + // arrived; plain `mvn`'s is a bare blank line, and base parity + // means emitting exactly that — including when the terminator is + // whitespace-only rather than empty. + if core.trim().is_empty() { + if daemon { + out.push_str(line); + } out.push('\n'); failure_trail = false; continue; } - let t = line.trim_start(); + let t = core.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { continue; } @@ -1687,11 +1750,11 @@ pub fn filter_quiet(raw: &str) -> String { } // Failure summary keepers. - if line.starts_with("[ERROR] Tests run:") - || line.starts_with("[ERROR] Failures:") - || line.starts_with("[ERROR] Errors:") - || line.starts_with("[ERROR] ") - || line.starts_with("[ERROR] Failed to execute goal") + if core.starts_with("[ERROR] Tests run:") + || core.starts_with("[ERROR] Failures:") + || core.starts_with("[ERROR] Errors:") + || core.starts_with("[ERROR] ") + || core.starts_with("[ERROR] Failed to execute goal") { out.push_str(line); out.push('\n'); @@ -1700,7 +1763,7 @@ pub fn filter_quiet(raw: &str) -> String { // Drop post-failure help boilerplate and bare `[ERROR]` dividers // (shared with the non-quiet filters — see BOILER_PREFIXES). - if is_boilerplate(line) { + if is_boilerplate(core) { continue; } @@ -1790,7 +1853,7 @@ fn run_tool(args: &[String], daemon: bool, verbose: u8) -> Result { new_mvn_command(args, daemon), tool, &args_display, - filter_quiet, + |raw: &str| filter_quiet(raw, daemon), RunOptions::with_tee("mvn_quiet"), ); } @@ -1972,6 +2035,215 @@ mod tests { ); } + /// A real Surefire class-start line still opens its module's lane, so the + /// narrowing above cannot cost a genuine reactor module its routing. + #[test] + fn real_running_line_still_mints_a_lane() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] Running com.example.rtk.AFailTest\n\ + [child-a] [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.02 s <<< FAILURE! -- in com.example.rtk.AFailTest\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i, true); + assert!( + o.contains("Running com.example.rtk.AFailTest"), + "a genuine class-start line opens the lane and is kept with its failing \ + block; got:\n{o}" + ); + } + + /// An app log shaped like a module header is gated on how the line + /// *arrived* (tag-prefixed), not on which lane it was routed to: an + /// unrecognized tag falls back to `raw_owner`, which can land on the root + /// lane, and a gate keyed on the destination would switch itself off + /// exactly there. + #[test] + fn daemon_building_applog_dropped_with_no_block_open() { + let mut i = String::from("[INFO] Scanning for projects...\n[INFO] Building web 2.1.0 [1/1]\n"); + for n in 0..10 { + i.push_str(&format!( + "[pool-1] [INFO] Building segment {n} of the data pipeline\n" + )); + } + i.push_str("[INFO] BUILD SUCCESS\n"); + let o = filter_surefire(&i, true); + assert_eq!( + o.matches("of the data pipeline").count(), + 0, + "tag-prefixed `Building` app logs never carry the reactor counter and \ + are dropped even when no lane's block is open; got:\n{o}" + ); + assert!( + o.contains("[INFO] Building web 2.1.0 [1/1]"), + "the real reactor-numbered module header survives; got:\n{o}" + ); + } + + /// The failures budget is keyed to the plugin *family*, so version and + /// execution-id differences between modules — both per-module + /// configuration — stay inside one phase and share one budget. + #[test] + fn summary_cap_survives_per_module_plugin_coordinates() { + let block = |tag: &str, banner: &str, p: &str| { + let mut s = format!("[{tag}] [INFO] --- {banner} @ {tag} ---\n"); + s.push_str(&format!("[{tag}] [ERROR] Failures: \n")); + for n in 0..3 { + s.push_str(&format!("[{tag}] [ERROR] {p}{n}:1{n} boom {p}{n}\n")); + } + s.push_str(&format!( + "[{tag}] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n" + )); + s + }; + for (label, b_banner) in [ + ("same coordinates", "surefire:3.5.5:test (default-test)"), + ("different version", "surefire:3.2.2:test (default-test)"), + ("different execution id", "surefire:3.5.5:test (unit-tests)"), + ("legacy spelling", "maven-surefire-plugin:2.22.2:test (default-test)"), + ] { + let i = format!( + "[INFO] Scanning for projects...\n{}{}[INFO] BUILD FAILURE\n", + block("a", "surefire:3.5.5:test (default-test)", "A"), + block("b", b_banner, "B"), + ); + let o = filter_package_with_cap(&i, 2, true); + assert_eq!( + o.matches("boom ").count(), + 2, + "{label}: one phase, one reactor-wide budget; got:\n{o}" + ); + } + } + + /// A genuine Surefire -> Failsafe transition is still a phase boundary and + /// still earns its own budget. + #[test] + fn summary_cap_resets_on_surefire_to_failsafe() { + let i = "[INFO] Scanning for projects...\n\ + [a] [INFO] --- surefire:3.5.5:test (default-test) @ a ---\n\ + [a] [ERROR] Failures: \n\ + [a] [ERROR] A0:10 boom A0\n\ + [a] [ERROR] A1:11 boom A1\n\ + [a] [ERROR] A2:12 boom A2\n\ + [a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [a] [INFO] --- failsafe:3.5.5:integration-test (default-integration-test) @ a ---\n\ + [a] [ERROR] Failures: \n\ + [a] [ERROR] C0:20 boom C0\n\ + [a] [ERROR] C1:21 boom C1\n\ + [a] [ERROR] C2:22 boom C2\n\ + [a] [ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_package_with_cap(i, 2, true); + assert_eq!( + o.matches("boom ").count(), + 4, + "unit and integration phases each get their own budget; got:\n{o}" + ); + } + + /// Quiet mode classifies a module-prefixed reactor line exactly as it + /// classifies the same line unprefixed — mvnd's `[module] ` prefix is + /// presentation, not signal. + #[test] + fn quiet_mode_classifies_module_prefixed_lines() { + let plain = "[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.1 s <<< FAILURE! -- in com.example.a.T\n\ + [ERROR] com.example.a.T.x -- Time elapsed: 0.02 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boom\n\ + \tat org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:38)\n\ + \tat com.example.a.T.x(T.java:42)\n\ + \n\ + [ERROR] -> [Help 1]\n"; + let tagged: String = plain + .lines() + .map(|l| format!("[child-a] {l}")) + .collect::>() + .join("\n"); + let o = filter_quiet(&tagged, true); + assert!( + o.contains("com.example.a.T.x(T.java:42)"), + "user-code frame kept; got:\n{o}" + ); + assert!( + !o.contains("AssertionUtils.fail"), + "framework frame dropped; got:\n{o}" + ); + assert!(!o.contains("[Help 1]"), "boilerplate dropped; got:\n{o}"); + // Strongest form of the claim: strip the prefix back off and the two + // runs must agree line for line, not merely in line count. + let unprefixed: String = o + .lines() + .map(|l| l.strip_prefix("[child-a] ").unwrap_or(l).trim_end()) + .collect::>() + .join("\n"); + let expected: String = filter_quiet(plain, false) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_eq!( + unprefixed, expected, + "prefixed and unprefixed runs keep the same lines" + ); + } + + /// Plain `mvn` never emits a module prefix, so the tag heuristic must not + /// run there: `[%thread] [%level]` application logging is byte-identical + /// to a tagged Maven line, and classifying it would let ordinary test + /// diagnostics be dropped as Maven's own boilerplate. + #[test] + fn quiet_mode_keeps_thread_tagged_app_logs_for_plain_mvn() { + let i = "[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.1 s <<< FAILURE! -- in com.example.T\n\ + [ERROR] com.example.T.x -- Time elapsed: 0.02 s <<< FAILURE!\n\ + java.lang.AssertionError: boom\n\ + \tat com.example.T.x(T.java:42)\n\ + \n\ + [main] [ERROR] See https://docs.example.com/troubleshoot for details\n\ + [main] [ERROR] Re-run Maven with the flag we documented\n\ + [worker-1] [ERROR] For more information about the widget cache\n\ + [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test\n"; + let o = filter_quiet(i, false); + for want in [ + "See https://docs.example.com/troubleshoot", + "Re-run Maven with the flag we documented", + "For more information about the widget cache", + ] { + assert!( + o.contains(want), + "application diagnostics survive plain `mvn -q`; missing {want:?} in:\n{o}" + ); + } + } + + /// Maven prints a plugin's banner before any of that plugin's output, so + /// a module's lane is always minted by its `--- surefire:… @ mod ---` + /// banner before the first `Running` line arrives. That is what keeps the + /// [`RUNNING_MODULE_HEADER`] narrowing from costing a class its block: by + /// the time a `Running` line of any shape shows up, its tag is already a + /// known lane and `is_lane_opener` is no longer consulted for it. + #[test] + fn plugin_banner_mints_the_lane_before_any_running_line() { + let i = "[INFO] Scanning for projects...\n\ + [child-a] [INFO] --- surefire:3.5.5:test (default-test) @ child-a ---\n\ + [child-a] [INFO] Running Regression Tests\n\ + [child-a] [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.4 s <<< FAILURE! -- in Regression Tests\n\ + [child-a] [ERROR] com.example.T.x -- Time elapsed: 0.02 s <<< FAILURE!\n\ + [child-a] java.lang.AssertionError: boom\n\ + [child-a] \tat com.example.T.x(T.java:42)\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i, true); + for want in [ + "Running Regression Tests", + "Tests run: 3, Failures: 1", + "java.lang.AssertionError: boom", + "T.java:42", + ] { + assert!( + o.contains(want), + "a banner-minted lane keeps its failing class whole, whatever \ + shape the `Running` line takes; missing {want:?} in:\n{o}" + ); + } + } + // Thin `daemon`-fixed wrappers so the bulk of the test suite (written // before the `daemon` gate — cold-preclear finding, upstream PR #3199, // third review round) keeps its original `fn(&str) -> String` / @@ -4538,8 +4810,8 @@ mod tests { /// Green `mvn -q test` emits zero bytes; filter must return empty. #[test] fn quiet_green_run_is_empty() { - assert_eq!(filter_quiet(""), ""); - assert_eq!(filter_quiet(" \n\n \n"), ""); + assert_eq!(filter_quiet("", false), ""); + assert_eq!(filter_quiet(" \n\n \n", false), ""); } /// Failure under `-q`: keep close-line, exception, user frame, summary, @@ -4547,7 +4819,7 @@ mod tests { #[test] fn quiet_fail_strips_framework_and_boilerplate() { let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); - let o = filter_quiet(i); + let o = filter_quiet(i, false); // Kept: failure signal. assert!( @@ -4615,7 +4887,7 @@ mod tests { #[test] fn savings_mvn_quiet_fail() { let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); - let o = filter_quiet(i); + let o = filter_quiet(i, false); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 50.0, @@ -4632,7 +4904,7 @@ mod tests { #[test] fn quiet_unknown_error_line_kept_as_safety_net() { let i = "[ERROR] Some unexpected error output we don't classify\n"; - let o = filter_quiet(i); + let o = filter_quiet(i, false); assert!( o.contains("Some unexpected error output"), "unclassified ERROR line preserved; got:\n{}", @@ -4694,21 +4966,12 @@ mod tests { // ── daemon gate: plain `mvn` never routes through the lane layer ──────── - /// Cold-preclear finding (upstream PR #3199, third review round), SLF4J - /// probe: an SLF4J/Logback `[%thread] [%level] %msg` layout produces - /// lines syntactically identical to a real mvnd module line. Critically, - /// `is_lane_opener`'s `RUNNING` arm is intentionally permissive (any - /// `[INFO] Running …`, no `FILE_COORD` guard like the `[ERROR]` arm - /// has) — an app log line that merely happens to start with "Running" - /// (`[main] [INFO] Running the widget pipeline`) is exactly as - /// opener-shaped as a genuine mvnd module's own `Running` line. Pre-fix, - /// this minted a phantom `main` lane on *plain* `mvn` output (`daemon == - /// false`), opened a block that never closed, and detached the - /// failure's own context lines so they printed after the build footer, - /// reading as belonging to nothing. Post-fix, `daemon == false` - /// short-circuits `split_lane` before any bracket parsing happens at - /// all, so this is structurally impossible: the context lines stay - /// exactly where base would keep them — inside the failing block, + /// An SLF4J/Logback `[%thread] [%level] %msg` layout produces lines + /// syntactically identical to a real mvnd module line, so plain `mvn` must + /// never reach the lane layer at all: `daemon == false` short-circuits + /// `split_lane` before any bracket parsing happens, which makes the whole + /// phantom-lane class structurally impossible there. A failure's context + /// lines stay exactly where base keeps them — inside the failing block, /// before the footer. #[test] fn slf4j_thread_tag_does_not_mint_phantom_lane_in_plain_mvn() { @@ -5205,3 +5468,5 @@ mod tests { + +