From 6b30fdd9fc78f910219f69ab4b73d5df440c14a5 Mon Sep 17 00:00:00 2001 From: KuaaMU <138859253+KuaaMU@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:14:32 +0800 Subject: [PATCH 01/19] fix(filters): remove max_lines cap from helm filter that truncates template output The helm filter had `max_lines = 40` which silently truncates output from commands like `helm template` that legitimately produce hundreds of lines of YAML manifests. This caused CI/CD pipelines to apply incomplete sets of manifests (only first ~40 lines). Other filter settings (strip_ansi, strip_lines_matching for blanks and glog warnings, truncate_lines_at for individual line length) provide sufficient output hygiene without capping total line count. Added regression test verifying multi-document YAML output is preserved in full without truncation. Fixes #1626 --- src/filters/helm.toml | 76 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/filters/helm.toml b/src/filters/helm.toml index 306607898..790ddd3ec 100644 --- a/src/filters/helm.toml +++ b/src/filters/helm.toml @@ -7,7 +7,6 @@ strip_lines_matching = [ "^W\\d{4}", ] truncate_lines_at = 120 -max_lines = 40 [[tests.helm]] name = "strips blank lines, preserves release info" @@ -27,3 +26,78 @@ expected = "NAME: my-release\nLAST DEPLOYED: Mon Jan 15 10:30:00 2024\nNAMESPACE name = "strips glog W-prefix warnings" input = "W0115 10:30:00 warning message from internal\nNAME: my-chart\nSTATUS: deployed" expected = "NAME: my-chart\nSTATUS: deployed" + +[[tests.helm]] +name = "helm template output is not truncated" +input = """ +# Source: my-chart/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-ns +--- +# Source: my-chart/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: my-secret +--- +# Source: my-chart/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-cm +--- +# Source: my-chart/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc +--- +# Source: my-chart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: my-svc +--- +# Source: my-chart/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deploy +""" +expected = """# Source: my-chart/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-ns +--- +# Source: my-chart/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: my-secret +--- +# Source: my-chart/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-cm +--- +# Source: my-chart/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc +--- +# Source: my-chart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: my-svc +--- +# Source: my-chart/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deploy""" From 6c4950e8258c424ea34a371833ead6bbcdaedc0a Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 17 May 2026 10:25:15 -0300 Subject: [PATCH 02/19] feat(mvn)!: Rust module replacing TOML filter, adds test phase support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stateless src/filters/mvn-build.toml (4 phases, 50-line cap) with a Rust module under src/cmds/jvm/mvn_cmd.rs handling all common Maven lifecycle phases with stateful Surefire/Failsafe block collapse. Why TOML couldn't do this: the dominant noise on a healthy Maven project is expected-exception stack traces inside passing tests (assertThrows). Collapsing these requires deciding to drop a block based on its closing `Tests run: N, Failures: 0, Errors: 0` line — a stateful decision the TOML DSL cannot express. Measured savings on synthetic full-shape fixtures (~1100 lines each, gzipped): - mvn test: 3 422 tok → 36 tok (98.9% savings) - mvn install: 3 460 tok → 85 tok (97.5% savings) Coverage: | Phase | Filter | |------------------------------------|----------------------------------------| | test, integration-test | filter_surefire (block collapse) | | compile, test-compile | filter_compile (ERROR + indent + dedup)| | package, install, verify, deploy | filter_package (compile + surefire) | | clean, site, plugin goals, version | passthrough | Key behaviours: - ANSI strip first in every filter (real Maven output contains colour escapes). - English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, return ANSI-stripped raw input unchanged — protects non-English locales. - Verbose bypass: `-X`, `--debug`, `-e`, `--errors` skip filtering. - Stack-frame deny-list strips framework frames (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.); user-code frames (any other prefix) preserved. - Duration normalisation (`Time elapsed: 2.341 s` → `Time elapsed: T s`) for deterministic test output. - Wrapper detection: `./mvnw` / `mvnw.cmd` via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. Hook regex changes (src/discover/rules.rs): - Adds wrapper prefixes (./mvnw, mvnw.cmd, mvnw). - Adds test, integration-test, verify, deploy to the alternation. - Drops clean + site so bare invocations bypass RTK entirely (0 overhead). - Lazy quantifier skips flags before the goal (`mvn -B clean install` now rewrites correctly; previously failed because the regex required the goal as the first token after `mvn`). - Drops subcmd_savings (lazy match captures first phase, which would mis-tier `clean install` to clean); uses flat 82.0. Tests: - 33 unit tests in mvn_cmd.rs (phase detection, footer guard, framework deny, duration normalisation, ERROR continuation, WARNING dedup, install/jar lines). - 2 inline savings assertions on gzipped synthetic full fixtures (~3 KB each) using flate2 already in Cargo.toml. Run as part of standard `cargo test --all`. - 12 classifier/rewrite tests in src/discover/registry.rs under new Maven section. Files changed: - ADD src/cmds/jvm/mvn_cmd.rs (~810 LoC incl. tests) - ADD src/cmds/jvm/README.md (documents whitelist omission decision) - ADD tests/fixtures/mvn_*_raw.txt (6 inline) + 2 .gz synthetic full fixtures - MOD src/main.rs (1 use, 1 Commands variant, 1 dispatch arm) - MOD src/discover/rules.rs (replace mvn entry) - MOD src/discover/registry.rs (12 classifier tests) - MOD src/core/toml_filter.rs (drop mvn-build from expected-filters list, adjust the 2 count assertions) - DEL src/filters/mvn-build.toml Fixtures are entirely synthetic — generic `com.example.app.*` package names and `com.example:myapp` Maven coordinates. Same SHAPE as real Maven output (block structure, ANSI codes, framework stack frames, BUILD footer, plugin banners) without any project-identifying content. Integrity-check whitelist: Commands::Mvn is intentionally omitted from is_operational_command at src/main.rs:2455-2501, matching the gradle precedent (Commands::Gradlew also omitted). The whitelist is opt-in by design per the comment at L2452-2454 — filter modules invoked through an already-verified hook do not need a second integrity check on their own dispatch path. Documented in src/cmds/jvm/README.md. Out-of-scope (follow-ups noted in module + README): - Parallel mode `-T2C` (interleaved blocks across threads). - Plugin goals (`mvn dependency:tree`, `mvn versions:*`). - `mvnw.bat` legacy wrapper (Maven Wrapper 3.x emits `.cmd`). BREAKING CHANGE: `rtk mvn ` output format changed. Previously: TOML filter strips INFO/Downloading lines and caps at 50 lines. Now: state-machine filter with Surefire block collapse + locale guard + verbose bypass. Different output shape; significantly better savings tier; behaviour is otherwise a superset (all previously-handled cases still handled, plus test/verify/etc.). --- Cargo.lock | 2 +- src/cmds/jvm/README.md | 38 + src/cmds/jvm/mvn_cmd.rs | 808 ++++++++++++++++++ src/core/toml_filter.rs | 11 +- src/discover/registry.rs | 129 +++ src/discover/rules.rs | 6 +- src/filters/mvn-build.toml | 44 - src/main.rs | 12 +- tests/fixtures/mvn_clean_raw.txt | 15 + .../fixtures/mvn_compile_error_slice_raw.txt | 25 + tests/fixtures/mvn_install_full_raw.txt.gz | Bin 0 -> 3456 bytes tests/fixtures/mvn_install_slice_raw.txt | 39 + tests/fixtures/mvn_locale_fr_raw.txt | 15 + tests/fixtures/mvn_no_pom_raw.txt | 8 + tests/fixtures/mvn_test_fail_slice_raw.txt | 39 + tests/fixtures/mvn_test_pass_full_raw.txt.gz | Bin 0 -> 2978 bytes tests/fixtures/mvn_test_pass_slice_raw.txt | 47 + 17 files changed, 1183 insertions(+), 55 deletions(-) create mode 100644 src/cmds/jvm/README.md create mode 100644 src/cmds/jvm/mvn_cmd.rs delete mode 100644 src/filters/mvn-build.toml create mode 100644 tests/fixtures/mvn_clean_raw.txt create mode 100644 tests/fixtures/mvn_compile_error_slice_raw.txt create mode 100644 tests/fixtures/mvn_install_full_raw.txt.gz create mode 100644 tests/fixtures/mvn_install_slice_raw.txt create mode 100644 tests/fixtures/mvn_locale_fr_raw.txt create mode 100644 tests/fixtures/mvn_no_pom_raw.txt create mode 100644 tests/fixtures/mvn_test_fail_slice_raw.txt create mode 100644 tests/fixtures/mvn_test_pass_full_raw.txt.gz create mode 100644 tests/fixtures/mvn_test_pass_slice_raw.txt diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8..79fa30e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md new file mode 100644 index 000000000..67706cefe --- /dev/null +++ b/src/cmds/jvm/README.md @@ -0,0 +1,38 @@ +# JVM ecosystem filters + +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 | + +## Maven (`mvn_cmd.rs`) + +Phase routing (`detect_phase`): + +| Phase | Goals | Filter | +|-------------|--------------------------------------------------------|-------------------------| +| `Test` | `test`, `integration-test` (Failsafe = Surefire shape) | `filter_surefire` | +| `Compile` | `compile`, `test-compile` | `filter_compile` | +| `Package` | `package`, `install`, `verify`, `deploy` | `filter_package` | +| `Passthrough` | `clean`, `site`, `dependency:*`, `--version`, `--help`, empty, any unrecognised goal | none | + +Key behaviours: + +- **ANSI strip first** in every filter — real Maven output contains colour escapes. +- **English-footer guard** — if neither `BUILD SUCCESS` nor `BUILD FAILURE` appears as a trimmed line suffix, return the ANSI-stripped raw input unchanged. Protects non-English locales. +- **Verbose bypass** — `-X`, `--debug`, `-e`, `--errors` skip filtering (`run_passthrough`). User asked for detail; respect it. +- **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). +- **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. +- **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. + +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. + +### 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. + +## Gradle (`gradlew_cmd.rs`) + +See module docs and the gradle PR (`feat/gradlew-android-support`) for rationale. Streaming filter chosen because Gradle output is task-line-based, not block-based — unlike Maven Surefire. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs new file mode 100644 index 000000000..0cadc370f --- /dev/null +++ b/src/cmds/jvm/mvn_cmd.rs @@ -0,0 +1,808 @@ +//! Apache Maven filter — Surefire/Failsafe block collapse, compile error/warning +//! dedup, package/install pipeline with mode-toggle. +//! +//! Replaces the previous `src/filters/mvn-build.toml` filter with a Rust module +//! capable of state-machine parsing (block collapse, continuation tracking, +//! mode toggle) that TOML DSL cannot express. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::{resolved_command, strip_ansi}; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +// ── Shared regex patterns ──────────────────────────────────────────────────── + +lazy_static! { + /// `[INFO] Running com.example.app.FooTest` + static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); + + /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. + /// Tolerates the optional `<<< FAILURE!` marker between duration and ` - in `. + static ref CLOSE: Regex = Regex::new( + r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)? - in (.+)$" + ).unwrap(); + + /// Final BUILD footer. + static ref BUILD_FOOT: Regex = Regex::new(r"^\[(?:INFO|ERROR)\] BUILD (?:SUCCESS|FAILURE)$").unwrap(); + + /// `[INFO] Results:` separator before the aggregate. + static ref RESULTS: Regex = Regex::new(r"^\[INFO\] Results:\s*$").unwrap(); + + /// Aggregate counts line (no `Time elapsed`, no ` - in `). + static ref AGG: Regex = Regex::new( + r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: \d+, Errors: \d+, Skipped: \d+\s*$" + ).unwrap(); + + /// Plugin banner line: `[INFO] --- plugin:goal (id) @ module ---`. + static ref PLUGIN_BANNER: Regex = Regex::new(r"^\[INFO\] --- .* @ .* ---$").unwrap(); + + /// Module banner with project name in brackets. + static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); + + /// Duration normaliser for deterministic snapshots. + static ref TIME_DURATION: Regex = Regex::new(r"Time elapsed: [0-9.]+ s").unwrap(); + + /// Total time line — also normalised. + static ref TOTAL_TIME: Regex = Regex::new(r"^\[INFO\] Total time:\s+[0-9.]+ s").unwrap(); + + /// Compile-error coordinate substring to strip when deduping warnings/errors. + static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); +} + +// ── Phase detection ───────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MvnPhase { + Test, // test, integration-test (Failsafe = Surefire shape) + Compile, // compile, test-compile + Package, // package, install, verify, deploy + Passthrough, // clean, site, plugin goals, version/help, empty +} + +/// Scan args left-to-right, skip flags + `-D…` system props, pick the LAST +/// remaining token. If empty, plugin-form (`:`), or `clean`/`site` → Passthrough. +pub fn detect_phase(args: &[String]) -> MvnPhase { + let last = args + .iter() + .filter(|a| !a.starts_with('-')) + .map(|s| s.as_str()) + .next_back() + .unwrap_or(""); + + if last.is_empty() || last.contains(':') { + return MvnPhase::Passthrough; + } + match last { + "clean" | "site" | "site-deploy" => MvnPhase::Passthrough, + "test" | "integration-test" => MvnPhase::Test, + "compile" | "test-compile" => MvnPhase::Compile, + "package" | "install" | "verify" | "deploy" => MvnPhase::Package, + _ => MvnPhase::Passthrough, + } +} + +// ── Stack-frame deny-list ──────────────────────────────────────────────────── + +const FRAMEWORK_FRAME_PREFIXES: &[&str] = &[ + "at org.junit.", + "at junit.", + "at org.apache.maven.surefire.", + "at sun.reflect.", + "at jdk.internal.reflect.", + "at jdk.proxy", + "at java.base/", + "at java.lang.reflect.", + "at java.util.", +]; + +fn is_framework_frame(trimmed: &str) -> bool { + FRAMEWORK_FRAME_PREFIXES + .iter() + .any(|p| trimmed.starts_with(p)) +} + +// ── English-footer guard ──────────────────────────────────────────────────── + +fn has_english_footer(stripped: &str) -> bool { + stripped.lines().any(|l| { + let t = l.trim(); + t.ends_with(" BUILD SUCCESS") || t.ends_with(" BUILD FAILURE") + }) +} + +// ── Duration normaliser ───────────────────────────────────────────────────── + +fn normalise(s: &str) -> String { + let s = TIME_DURATION.replace_all(s, "Time elapsed: T s").into_owned(); + TOTAL_TIME + .replace_all(&s, "[INFO] Total time: T s") + .into_owned() +} + +// ── Outside-block keep list (shared by surefire + package) ────────────────── + +fn keep_outside_block(line: &str) -> bool { + RESULTS.is_match(line) + || AGG.is_match(line) + || BUILD_FOOT.is_match(line) + || 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] Scanning ") + || line.starts_with("[INFO] Installing ") + || line.starts_with("[ERROR] Failures:") + || line.starts_with("[ERROR] Errors:") + || (line.starts_with("[ERROR]") && !line.starts_with("[ERROR] Tests run:")) + || line.starts_with("[INFO] Building war:") + || line.starts_with("[INFO] Building jar:") + || line.starts_with("[INFO] Building ear:") +} + +// ── Surefire block filter ─────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. +/// +/// State machine: when a `[INFO] Running ` line is seen, start buffering +/// the block. When the close line arrives, decide: +/// - Failures == 0 && Errors == 0 → drop the block silently. +/// - Else → emit the block with framework frames stripped, durations normalised. +/// +/// 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 { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block_lines: Vec = Vec::new(); + let mut block_running: Option = None; + let mut in_block = false; + + for line in stripped.lines() { + if PLUGIN_BANNER.is_match(line) { + continue; + } + + if RUNNING.is_match(line) { + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + block_lines.clear(); + block_running = Some(line.to_string()); + in_block = true; + continue; + } + + if in_block { + if let Some(caps) = CLOSE.captures(line) { + 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 { + emit_block(&mut out, &block_running, &block_lines); + out.push_str(&normalise(line)); + out.push('\n'); + } + block_lines.clear(); + block_running = None; + in_block = false; + continue; + } + block_lines.push(line.to_string()); + continue; + } + + if keep_outside_block(line) { + out.push_str(&normalise(line)); + out.push('\n'); + } + } + + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + out +} + +fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { + if let Some(r) = running { + out.push_str(&normalise(r)); + out.push('\n'); + } + for l in lines { + out.push_str(&normalise(l)); + out.push('\n'); + } +} + +fn emit_block(out: &mut String, running: &Option, lines: &[String]) { + if let Some(r) = running { + out.push_str(&normalise(r)); + out.push('\n'); + } + for l in lines { + let t = l.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(&normalise(l)); + out.push('\n'); + } +} + +// ── Compile filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn compile` / `test-compile`. +/// +/// Keeps module banners, `[INFO] Building …`, `[INFO] BUILD …`, totals, finish +/// 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 { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut keep_continuation = false; + let mut seen_warnings: HashSet = HashSet::new(); + + for line in stripped.lines() { + if MODULE_BANNER.is_match(line) { + 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 ") + { + out.push_str(&normalise(line)); + out.push('\n'); + keep_continuation = false; + continue; + } + if line.starts_with("[ERROR]") { + out.push_str(line); + out.push('\n'); + keep_continuation = true; + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let norm = FILE_COORD + .replace_all(&line["[WARNING] ".len().min(line.len())..], "") + .to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + // Drop everything else + keep_continuation = false; + } + + out +} + +// ── Package filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn package`/`install`/`verify`/`deploy`. +/// +/// Mode toggle: starts in `Compile` mode, switches to `Surefire` when a +/// `[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 { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block_lines: Vec = Vec::new(); + let mut block_running: Option = None; + let mut in_block = false; + let mut keep_continuation = false; + let mut seen_warnings: HashSet = HashSet::new(); + + for line in stripped.lines() { + if PLUGIN_BANNER.is_match(line) { + continue; + } + + if RUNNING.is_match(line) { + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + block_lines.clear(); + block_running = Some(line.to_string()); + in_block = true; + keep_continuation = false; + continue; + } + + if in_block { + if let Some(caps) = CLOSE.captures(line) { + 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 { + emit_block(&mut out, &block_running, &block_lines); + out.push_str(&normalise(line)); + out.push('\n'); + } + block_lines.clear(); + block_running = None; + in_block = false; + continue; + } + block_lines.push(line.to_string()); + continue; + } + + // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. + if MODULE_BANNER.is_match(line) || keep_outside_block(line) { + out.push_str(&normalise(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:"); + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let norm = FILE_COORD + .replace_all(&line["[WARNING] ".len().min(line.len())..], "") + .to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + keep_continuation = false; + } + + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + out +} + +// ── Wrapper detection ─────────────────────────────────────────────────────── + +fn mvn_binary() -> &'static str { + if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + ".\\mvnw.cmd" + } else { + "mvn" + } + } else if Path::new("./mvnw").exists() { + "./mvnw" + } else { + "mvn" + } +} + +fn new_mvn_command(args: &[String]) -> Command { + let mut cmd = if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + Command::new(".\\mvnw.cmd") + } else { + resolved_command("mvn") + } + } else if Path::new("./mvnw").exists() { + Command::new("./mvnw") + } else { + resolved_command("mvn") + }; + cmd.args(args); + cmd +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn run(args: &[String], 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); + } + + let phase = detect_phase(args); + let tool = mvn_binary(); + let args_display = args.join(" "); + + match phase { + MvnPhase::Test => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_surefire, + RunOptions::with_tee("mvn_test"), + ), + MvnPhase::Compile => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_compile, + RunOptions::with_tee("mvn_compile"), + ), + MvnPhase::Package => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_package, + RunOptions::with_tee("mvn_package"), + ), + MvnPhase::Passthrough => { + let osargs: Vec = args.iter().map(OsString::from).collect(); + runner::run_passthrough(tool, &osargs, verbose) + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::GzDecoder; + use std::io::Read; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + fn gunzip(bytes: &[u8]) -> String { + let mut s = String::new(); + GzDecoder::new(bytes) + .read_to_string(&mut s) + .expect("gunzip"); + s + } + + fn s>(it: impl IntoIterator) -> Vec { + it.into_iter().map(Into::into).collect() + } + + // ── Phase detection ────────────────────────────────────────────────────── + + #[test] + fn phase_test() { + assert_eq!(detect_phase(&s(["test"])), MvnPhase::Test); + } + #[test] + fn phase_integration_test() { + assert_eq!(detect_phase(&s(["integration-test"])), MvnPhase::Test); + } + #[test] + fn phase_compile() { + assert_eq!(detect_phase(&s(["compile"])), MvnPhase::Compile); + } + #[test] + fn phase_test_compile() { + assert_eq!(detect_phase(&s(["test-compile"])), MvnPhase::Compile); + } + #[test] + fn phase_install() { + assert_eq!(detect_phase(&s(["install"])), MvnPhase::Package); + } + #[test] + fn phase_package() { + assert_eq!(detect_phase(&s(["package"])), MvnPhase::Package); + } + #[test] + fn phase_verify() { + assert_eq!(detect_phase(&s(["verify"])), MvnPhase::Package); + } + #[test] + fn phase_deploy() { + assert_eq!(detect_phase(&s(["deploy"])), MvnPhase::Package); + } + #[test] + fn phase_clean_install_is_pkg() { + assert_eq!(detect_phase(&s(["clean", "install"])), MvnPhase::Package); + } + #[test] + fn phase_flags_before_goal() { + assert_eq!( + detect_phase(&s(["-B", "-DskipTests", "test"])), + MvnPhase::Test + ); + } + #[test] + fn phase_clean_only_passthrough() { + assert_eq!(detect_phase(&s(["clean"])), MvnPhase::Passthrough); + } + #[test] + fn phase_site_passthrough() { + assert_eq!(detect_phase(&s(["site"])), MvnPhase::Passthrough); + } + #[test] + fn phase_plugin_goal_passthrough() { + assert_eq!( + detect_phase(&s(["dependency:tree"])), + MvnPhase::Passthrough + ); + } + #[test] + fn phase_empty_passthrough() { + let v: Vec = Vec::new(); + assert_eq!(detect_phase(&v), MvnPhase::Passthrough); + } + #[test] + fn phase_version_long() { + assert_eq!(detect_phase(&s(["--version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_short() { + assert_eq!(detect_phase(&s(["-v"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_java_style() { + assert_eq!(detect_phase(&s(["-version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_help() { + assert_eq!(detect_phase(&s(["--help"])), MvnPhase::Passthrough); + } + + // ── Surefire filter ────────────────────────────────────────────────────── + + #[test] + fn filter_surefire_pass_output_compact() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + // Passing fixture has 2 blocks; both should be dropped. + assert!(!o.contains("FooService.execute")); + assert!(!o.contains("ConsultarCidadeUseCaseTest")); + // Output should be a small fraction of input. + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 60.0, + "pass-fixture savings >=60%, got {:.1}%", + savings + ); + } + + #[test] + fn filter_surefire_fail_keeps_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("BUILD FAILURE")); + assert!(o.contains("Failures: 1")); + } + + #[test] + fn surefire_drops_passing_block() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("AppException: input must not be null"), + "passing-test stack dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + assert!( + o.contains("Tests run: 10, Failures: 0"), + "aggregate preserved; got:\n{}", + o + ); + } + + #[test] + fn surefire_preserves_failing_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("Failures: 1"), + "failing aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example."), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped in failing block; got:\n{}", + o + ); + } + + #[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); + assert!(o.contains("-----< com.example:myapp >-----")); + } + + #[test] + fn surefire_normalises_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"; + let o = filter_surefire(i); + assert!( + o.contains("Time elapsed: T s"), + "duration normalised; got:\n{}", + o + ); + assert!(!o.contains("2.341"), "raw duration removed; got:\n{}", o); + } + + #[test] + fn footer_guard_french_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_locale_fr_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("BUILD ÉCHEC"), + "footer-guard must pass through non-English output; got:\n{}", + o + ); + // Confirm we did NOT filter — input length preserved (modulo ANSI strip, which is a no-op here) + assert_eq!( + o.lines().count(), + i.lines().count(), + "footer-guard returns raw input" + ); + } + + #[test] + fn footer_guard_no_pom_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_no_pom_raw.txt"); + let o = filter_surefire(i); + // No BUILD footer → passthrough; user sees the `[ERROR] no POM` line. + assert!( + o.contains("there is no POM"), + "no-pom error preserved; got:\n{}", + o + ); + } + + // ── Compile filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_compile_error_compact() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_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 >= 30.0, + "compile-error fixture is small; >=30% savings, got {:.1}%", + savings + ); + } + + #[test] + fn compile_preserves_error_continuation() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_compile(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved"); + assert!( + o.contains("symbol: variable bar"), + "indented continuation preserved" + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved"); + } + + #[test] + fn compile_dedupes_warnings() { + let i = "[INFO] -----< x >-----\n\ + [WARNING] /a.java:[1,2] uses deprecated API\n\ + [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 warns = o.matches("[WARNING]").count(); + assert_eq!(warns, 2, "dedup by normalised message; got:\n{}", o); + } + + // ── Package filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_package_install_compact() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_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 >= 50.0, + "install-slice savings >=50%, got {:.1}%", + savings + ); + } + + #[test] + fn package_keeps_install_lines() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Installing"), + "install line preserved; got:\n{}", + o + ); + assert!( + o.contains("Building jar:"), + "jar line preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + } + + // ── Token-savings (FULL gzipped fixtures) ─────────────────────────────── + + #[test] + 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 savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 90.0, + "mvn test ≥90% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } + + #[test] + 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 savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 85.0, + "mvn install ≥85% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } +} diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 06060d22d..74b61026d 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1582,7 +1582,6 @@ match_command = "^make\\b" "markdownlint", "mix-compile", "mix-format", - "mvn-build", "ping", "pio-run", "poetry-install", @@ -1621,8 +1620,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 59, - "Expected exactly 59 built-in filters, got {}. \ + 58, + "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1679,11 +1678,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 59 existing filters still present + 1 new = 60 + // All 58 existing filters still present + 1 new = 59 assert_eq!( filters.len(), - 60, - "Expected 60 filters after concat (59 built-in + 1 new)" + 59, + "Expected 59 filters after concat (58 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a..bebcbb22c 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3072,6 +3072,135 @@ mod tests { ); } + // --- Maven --- + + #[test] + fn test_classify_mvn_test() { + assert!(matches!( + classify_command("mvn test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_integration_test() { + assert!(matches!( + classify_command("mvn integration-test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_flags_before_goal() { + assert!(matches!( + classify_command("mvn -B -DskipTests=false clean install"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_wrapper() { + assert!(matches!( + classify_command("./mvnw verify"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_cmd_wrapper() { + assert!(matches!( + classify_command("mvnw.cmd package"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_clean_bypassed() { + // `clean` deliberately excluded from the alternation to avoid 0-overhead fork. + assert!(!matches!( + classify_command("mvn clean"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_site_bypassed() { + assert!(!matches!( + classify_command("mvn site"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_plugin_goal_bypassed() { + assert!(!matches!( + classify_command("mvn dependency:tree"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_bare_bypassed() { + assert!(!matches!( + classify_command("mvn"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_version_bypassed() { + assert!(!matches!( + classify_command("mvn --version"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_rewrite_mvn_clean_install() { + assert_eq!( + rewrite_command_no_prefixes("mvn -B clean install", &[]), + Some("rtk mvn -B clean install".into()) + ); + } + + #[test] + fn test_rewrite_mvnw_test() { + assert_eq!( + rewrite_command_no_prefixes("./mvnw test", &[]), + Some("rtk mvn test".into()) + ); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..6bb5741c8 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -690,11 +690,11 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^mvn\s+(compile|package|clean|install)\b", + pattern: r"^(?:\./mvnw|mvnw\.cmd|mvnw|mvn)\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", rtk_cmd: "rtk mvn", - rewrite_prefixes: &["mvn"], + rewrite_prefixes: &["./mvnw", "mvnw.cmd", "mvnw", "mvn"], category: "Build", - savings_pct: 70.0, + savings_pct: 82.0, subcmd_savings: &[], subcmd_status: &[], }, diff --git a/src/filters/mvn-build.toml b/src/filters/mvn-build.toml deleted file mode 100644 index 430a7f0cd..000000000 --- a/src/filters/mvn-build.toml +++ /dev/null @@ -1,44 +0,0 @@ -[filters.mvn-build] -description = "Compact Maven build output" -match_command = "^mvn\\s+(compile|package|clean|install)\\b" -strip_ansi = true -strip_lines_matching = [ - "^\\[INFO\\] ---", - "^\\[INFO\\] Building\\s", - "^\\[INFO\\] Downloading\\s", - "^\\[INFO\\] Downloaded\\s", - "^\\[INFO\\]\\s*$", - "^\\s*$", - "^Downloading:", - "^Downloaded:", - "^Progress", -] -max_lines = 50 -on_empty = "mvn: ok" - -[[tests.mvn-build]] -name = "strips INFO noise, preserves errors and summary" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] -[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol - symbol: method foo() -[INFO] BUILD FAILURE -[INFO] Total time: 2.543 s -""" -expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s" - -[[tests.mvn-build]] -name = "successful build keeps BUILD SUCCESS line" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] -[INFO] BUILD SUCCESS -[INFO] Total time: 4.123 s -[INFO] Finished at: 2024-01-15T10:30:00Z -""" -expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z" diff --git a/src/main.rs b/src/main.rs index 25c70dcb0..533e999c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use cmds::js::{ lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd, vitest_cmd, }; -use cmds::jvm::gradlew_cmd; +use cmds::jvm::{gradlew_cmd, mvn_cmd}; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; @@ -735,6 +735,14 @@ enum Commands { args: Vec, }, + /// Apache Maven wrapper with compact output (test, integration-test, compile, package, install, verify, deploy) + #[command(name = "mvn")] + Mvn { + /// 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 { @@ -2147,6 +2155,8 @@ fn run_cli() -> Result { Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?, + Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?, + Commands::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 diff --git a/tests/fixtures/mvn_clean_raw.txt b/tests/fixtures/mvn_clean_raw.txt new file mode 100644 index 000000000..1cdcecbe1 --- /dev/null +++ b/tests/fixtures/mvn_clean_raw.txt @@ -0,0 +1,15 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Deleting /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.853 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_compile_error_slice_raw.txt b/tests/fixtures/mvn_compile_error_slice_raw.txt new file mode 100644 index 000000000..73119425f --- /dev/null +++ b/tests/fixtures/mvn_compile_error_slice_raw.txt @@ -0,0 +1,25 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- +[INFO] Changes detected - recompiling the module! +[INFO] Compiling 12 source files to /path/to/project/target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] /path/to/project/src/main/java/com/example/app/Foo.java:[42,5] cannot find symbol + symbol: variable bar + location: class Foo +[ERROR] /path/to/project/src/main/java/com/example/app/Bar.java:[10,5] ';' expected +[INFO] 2 errors +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 4.567 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_install_full_raw.txt.gz b/tests/fixtures/mvn_install_full_raw.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..97e62cccb632002de8bf4a416b33ac6f200ea3a2 GIT binary patch literal 3456 zcma)7cT|(-8gEs!O^ftug9_5LO4WWc95E;es5B}n_G&k~6q=fj9ZqtJ_?pNvUJ0lhzKKbq(g+4ZLlVvhrjv)NwR6|T5mM*a;` z3DcV5Oi;wF%Y3c9=K+qPoQX_~IhYYC-T%^D5ngscIUTe4_Q!_u+sW(EsAi{9K;c7AwPY4q>wwxgUuJ*#Zo;)`7_3RL>066YU#sBY6m z3AG5-WlWV+9 zMFxM?T8=9}*lt;ShJ8<(<+^miXK}PRV>Y3pmghB{Jo~{i9o6SiyQHw0O1YZ9q+|Xg zm93?e)D)D>R4rj}%E=qoDXB(Y#xAf)1DQoXJR@}9OTET2xYKZ^An81uv<*lenyh0l zZ6zo-%ZCiS|<UpeiQ(|Aa9K4G9A_9;IE*eMJ-1m$u@Bb>rW;>a#}r zXa5Ya&vob35*8cIKAy2(bNODP^KPvMj~`!Ho^Q8v;ZqScrJQ-kz=^+(i8^!d=R5z3$E>FchuK9%_iNi%<_@lt3q}D#j^TpK_lg=NgUGI)j(4MJFeOoANT5+JE_7fM3ZWx`|SI-KI_r{>TQn zEIQXbns?%|iKkZX#(BrSr^ujrgBRr_+Asal_wj{; zb0xd1++fJHvti;+=lG4|)uP>JBs-yt6gzxZ?^uq`1y^(KJoA*`V75EDr5La0ONro) zTw`lBq9(&euR^s^OsLXy&Z`m3_{)N4?p z9!DYC88%WkF1%_$JzZw^K&ctM@KO@r#>utpsNj!D8FwNaqQI4j z`^9@44a*!vp?<#Ap}nEu;!tb<>H@TTut3Z4pd_>jH^c}Lv=SHIy=2(f2)q**mpL*| zU(kaQFsV-EmY`8*pQ5>p&K<@|K z3G%{OL=inn97;AdsTpA87s|TxWVsw!Zmuj>z_|Jpr)7=P`T?g^CA<1VCOP8SKbv2_ zWL&+?xN3OUq76g!^j&!5Cs_9shq`&!A_q-1;k0O4^{iD9 z4)u)*^`!s8&5r5sOq6@#1UOW%ph_m5-cMiegQ5ERE&PEMoakjFe~CjKA}tJ8%CigOG zEPFUV2Pf5J2(O*egF1HX{=V8l$H3dgRrm4^Inm~jJae|xgC`2|({%@?`A!*^N&>&I z#ad+`boL_ky@1VkUNz=4neBIMF&k2f`B#SqC4;tDU0-0j!zrVY{K8O$*s-2#7r7y&}q26WKW4>|LrVVycrUG5jZ*Nw8tDyJeo>rEXt&`ELMd- z4VfYfit;oqkrB~=?I&O*vN-SPX^rL_#D-(Y`=@{PYWpio4;adtiXuy~f#g2Y2CyQx z_*j5_+~@fSt`3Mk$jk|MI=ZSP7VA&?^6o>H^_zzweP+>9l6VKKZW=Ic0A{!`1HW*@ zS}_nhIHW!u*j%JWJLRNeRx`yHU1IvKc8WVwjM!+8BquU`zXgvvGGptVG|W1CH>1OS zN}h)g@hKlzpe~3c-DkFWfoD6IPYj)rnq`2mCwQ}->G?n{>J9v|s4ecPIGBbZf+Ial zbU7%y4L@H?NJDb~Ya+Ohpdvq|IQ3FJS!!vzokRpVXCa4HeBpKY6qg8Mx|s##p!GQ@ zrDvHBe?Q?3KSaul3OU}}%_J%ci{k^Z2cis_19B`WYA-NBxA*f%d`@Iz*5agh;9%(? zdDf6*K~YTVW(rxL-#MtmkdUUy0sM$yN;mTtjvDC7`WDMMBN7?p1VIjRd|{JnJsHG^ znFUp#bug47S&@^lBIi6g1bUE2NQ>YCI5Jor20iG zE#dM78&=`RJvocYDz5^28sS(e9NPrP61WIWE&|O(=yMUvYz|5&p4{iup@$UZq#lus z?5zabc;KEYFmM!~+mDYK#K-)Fk5LQ6XQ}-$i=JPPsYH-o1H=75#%rK54r==iI&v90 z5(_;FTsEg~OKEUlmE3*lC(l0~gTAYsk7_wTUgP}O&@FDQn}&{?#s)Wy_!T;VD|FJ2 z$2P;qbh(HKE@B54L6!h$G0-LkZi<0FmSdDkxv(sdsza!3I)zS%!;q2FWTY(_DXs>6 zE5V#9@Y^cTldE?8?cae@!Q>)HVqjPTWQc*vFsLmMIuZgM35Fh#)fwtp$xw7?;~+ja zk)TmS&`2d{aLGs=GSZifJV{1&!|M9*Ky`g)rKFD$sI_@mkt4XB&;PlmeWHTgKzAe$D!rtM(U$ejR22y_U66d~}y zRjnmn9k#B3mYk0oRHd>6BI{x*A<)Mip8(E=&yG1J%yJ0uMh>Q zn2RankwcaQb~#9_!`rvw+w1Y|hJ=Q-1gs7LyMciH-(+ld3hx4-Gyz6it zfkV^e(9j&3K8N;lrHnl*WrQfYgoghsr0N^^`w)24h`IsQ87lUHe9u9{x8ZY_;cbcV zw(IaV)`~M6wKGbyhPFn0xh6qVK|Fvi?`$(9XzCIV6qI+~tjAj|!^~`ne6~`@-UD$M zj?Sx|k=)6k(!o1+N!nfa$ZKR{dHKlc2=lP6XFFc!qiltJ`~;UUkwSR=UX2I;jTSrL z&6^qLaJIEJYjR5wKfBB0L<8TWfEu>bc(=ef@2!Q=f(TMvLCtx0u{P3i`0)Q2YWoYG zY)X}sJZ+Y1XNu!SNBF@zuZd=9!qHZNx45r3enwgN)@Q=BIdxle)O4p|d!J-OCvs7= z+kgF&%6WFReXa4PXlkTCmhET$;$EHMoNPg&D<2e$b~&4vlwyME%q`(@)r)qA?Uc2I wpQRJCJ`ZttYl~!`GfuXgGV6$#;p^`%%s$tj>to3~mp)zNcPp&N=+jUB3)1C3AOHXW literal 0 HcmV?d00001 diff --git a/tests/fixtures/mvn_install_slice_raw.txt b/tests/fixtures/mvn_install_slice_raw.txt new file mode 100644 index 000000000..539919bd2 --- /dev/null +++ b/tests/fixtures/mvn_install_slice_raw.txt @@ -0,0 +1,39 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- +[INFO] Compiling 12 source files to /path/to/project/target/classes +[WARNING] /path/to/project/src/main/java/com/example/app/Foo.java: Some input files use or override a deprecated API. +[WARNING] /path/to/project/src/main/java/com/example/app/Bar.java: Some input files use unchecked or unsafe operations. +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] Running com.example.app.FooServiceTest +Jan 15, 2026 10:30:00 AM com.example.app.FooService execute +SEVERE: null +com.example.app.AppException: input must not be null + at com.example.app.FooService.execute(FooService.java:36) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.util.ArrayList.forEach(ArrayList.java:1259) +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] --- jar:3.4.0:jar (default-jar) @ myapp --- +[INFO] Building jar: /path/to/project/target/myapp-1.0.0.jar +[INFO] +[INFO] --- install:3.1.1:install (default-install) @ myapp --- +[INFO] Installing /path/to/project/target/myapp-1.0.0.jar to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.jar +[INFO] Installing /path/to/project/pom.xml to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.pom +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 49.550 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_locale_fr_raw.txt b/tests/fixtures/mvn_locale_fr_raw.txt new file mode 100644 index 000000000..ab6628439 --- /dev/null +++ b/tests/fixtures/mvn_locale_fr_raw.txt @@ -0,0 +1,15 @@ +[INFO] Recherche des projets... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Construction de myapp 1.0.0 +[INFO] de pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Suppression de /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD ÉCHEC +[INFO] ------------------------------------------------------------------------ +[INFO] Temps total: 0.713 s +[INFO] Termine a: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_no_pom_raw.txt b/tests/fixtures/mvn_no_pom_raw.txt new file mode 100644 index 000000000..ae838f4ca --- /dev/null +++ b/tests/fixtures/mvn_no_pom_raw.txt @@ -0,0 +1,8 @@ +[INFO] Scanning for projects... +[ERROR] The goal you specified requires a project to execute but there is no POM in this directory (/tmp). Please verify you invoked Maven from the correct directory. -> [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/MissingProjectException diff --git a/tests/fixtures/mvn_test_fail_slice_raw.txt b/tests/fixtures/mvn_test_fail_slice_raw.txt new file mode 100644 index 000000000..b44af338a --- /dev/null +++ b/tests/fixtures/mvn_test_fail_slice_raw.txt @@ -0,0 +1,39 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.app.FooServiceTest +org.opentest4j.AssertionFailedError: expected: <3> but was: <2> + at com.example.app.FooServiceTest.shouldCountThree(FooServiceTest.java:42) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) + at java.lang.reflect.Method.invoke(Method.java:498) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) + at java.util.ArrayList.forEach(ArrayList.java:1259) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) +[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in com.example.app.FooServiceTest +[INFO] Running com.example.app.BarServiceTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] FooServiceTest.shouldCountThree:42 expected: <3> but was: <2> +[ERROR] Tests run: 10, Failures: 1, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.124 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_test_pass_full_raw.txt.gz b/tests/fixtures/mvn_test_pass_full_raw.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..2c1b36e3bb955122f1b2139df4db968f96c0dac5 GIT binary patch literal 2978 zcma)6YdDk%8&*PAJ6V;{*9>hD*-Y8pm>G&?krkzA*6cXs*pkCL#yErsYcsrKsccM6 zlN=*7W`t}QZ)h+%=j?SDIpmNeCf^u;_RsfS{d)d9*Zthj{oMDXLfgHY-XQC_Q_`F2 z<4AGyr#Sju_4jvl54e6ELkXft%njQ6_M0lkF3#|BM@&wV4j;o#q27K?;~sk~`S4u1 z$?Pmwy2i=vnEMfnoobcSY)4{}t!d{ghW7UnKW!vr-`4nD6|EWK^qawA{S3S0^YRTc zPHS@6^YigFPF}KG%}0_|PvMW28DYKYwVOwA2Evs#*Jm8dmJ;q9`HGd#rslntamRZt z=>|nBH2$mN9UHa)BwfmPF#@qHaUrYS^dR?FD4^1@3$34F^mrPK~F&Xv_8>?f3 zT-E!&?&Tg(N_Z*BA@sq$mH^h@7m_@~39kxfCfg?5g|jN$H_t+zISOZ=jQRtF^Fa&D zWzW^UY7Y*uZ<3G(of98^Uk?dPGjR17CS9vywEI+=2#*V^?-O{FyfR`+K>4-1&wqW~ z91<3A`Q&F(j>$|zpYGIkLi6%Fmcm})cc<(1D_g1SK6g(X(llL}i%0DvdQb9V3|dny za(!t50ff)QVqe9claD&cEMjL#+DeD}D(3l}zP|aLP4fybdJM|bYirD+_N@x0$XCKW zi%)O5I(7x$A9R@*Y+hAQRp)PXTon$UR=)Bk5yuSw#%Uhixp_i3-sHHW9UZoy&Q zI2Sg>y{2P5hmbZwwl3*ND=4Ccgj}|y*?pn{_Cp4zi{rXd>J8d`b$kda=3ZdG%w~2m zt#-(Umt0VpKywc9|FC**&UVj)%Zz>MfwZ;vkAizUOzd^mL)JAnCa=QvLhR6UNA~G@ z=e_~*{UOD`3H`93+rxvsqZQ%7!;DYfH{Y^D0D`@)*IeMn^bf1Ls?QI7aw+8u2bL(V zq%3`!m~Zg5=CwWVE4y*!{-n=4$C`H@RlmHoTwcP3rdMkg*p-iXCb_mH)1EnWR@FL$ z=ih5t3Rzz*d|Pq8*{+o2t^2T1wQ#&Tm>+;NB*>L@-$Jo6^H`ZMF|k1LVZKzBV(f87 zv8d!pc`lB7zMvOL1$+LqgXQF<)oU#W66MYoOBH&)NF|=~1pcm@7L`b1rs9QS#VE7x zTW44!5yU@Pieq^(KR=E^^%&W(0l{kZjt4uIg{y zzMs57%gR-(k08buv6OJ0+Hpi0AJ8%_Ajbd5QrcF4yIV~6^TwF-coSFSFw(^f(+HB{ znUl_;3|grs(!~z0|t6YC=Q%s`Vc((2^DgF#gNv5;Tm71AS8SS8$B5*@9Q>GIg z9$gAKn7z2Fbi(SSu}~eRB^7|MCt*27VX71_SE@*^N%JGSRwq zk$(C$e(@@Y5GNNA+Czz%TsC|R^H~Qi z^rmd|=WVceNcbkaP(aB0MN5tf_T9BD+G4f5J^kNgjfiPA8mtH!p`(&SJ1%sLNnUUF`GHCoCf9D48{T}>UIk;dO# zo~ko^c1wC=Z5#w?XVqB6$b0B?JN*L}dHKHwHwv^Kf(Y%j32cbgfQ*alwy z6Rlo_R!>3aa;OYEl|i90Y^V$;NDN3715&C98R*)Mo`!?Dvv^T>X4lpbdW4w`0)0^8MwN$_||VJj5!L!p2C1%1M}@bHy^md z2c)+gb_vlA+>x9R@Y@PPzhrPm8|3yyD+}~Q*8T!%1VYaLC8!`GC{BO}_=FDn??xl_ z&`2aYg+n3XDI^MoWJ4htiV}cDBX(?S7;PL%AsGocz0(|yfYXozzs`cQnQ(SCyz}1@ zX0+KNRnRH&cnlSf!Qe5#G>0PK2n3uw0Vm?$$1{_Nqk|UzbFe7O6mj(4Gc~^mZ_2-m zs}8CC4ASgDngwV<64?QV!Q(KRI85SpLUH1R?uZymh82!LTnxljgVc&qDIf~@0EN7Z zLcSE6CIYo(8s)HG?|u|;qT7Me4glZ-w=JL?W5~n|GBJUq#O}u#iUWFKVxbR^Erp%g z@K_nF&?E9SNNWXY&7j=u_VWU_o>xZmXg#cO3*v$hHwIF}D5oHlk!WS)0cGUK*4PON zWi#AGP`T!4Pb{Rii#|owPp?`CQby9J(E8~!3qi$oFm6Evm|OpsBwd`Rvs0&5M;EIT zpuIv30#Bci%;e1_9_;d+r7L1LtsM8x)P!z+;------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.app.FooServiceTest +Jan 15, 2026 10:30:00 AM com.example.app.FooService execute +SEVERE: null +com.example.app.AppException: input must not be null + at com.example.app.FooService.execute(FooService.java:36) + at com.example.app.FooServiceTest.lambda$rejectsNullInput$0(FooServiceTest.java:42) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:55) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:37) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) + at com.example.app.FooServiceTest.rejectsNullInput(FooServiceTest.java:42) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) + at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.lang.reflect.Method.invoke(Method.java:498) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) + at java.util.ArrayList.forEach(ArrayList.java:1259) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] Running com.example.app.BarServiceTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.124 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ From cc152cd2ac9bfd5085b1b178a78088c11a37b0ae Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 12:35:45 -0300 Subject: [PATCH 03/19] fix(mvn): match Surefire 3.x close lines, preserve failure stack trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOSE regex previously required ` - in ` (single-dash, Surefire 2.x). Surefire 3.x emits ` -- in ` (double-dash), so close lines never matched on Maven 3.9 default toolchains: blocks stayed open, every new `[INFO] Running …` flushed the prior block as kept, and real-world savings dropped to ~27% on commons-cli. Changes: - CLOSE separator widened to `\s+--?\s+in ` (matches 2.x and 3.x). - CLOSE prefix widened to `INFO|ERROR|WARNING` (3.x emits WARNING for classes whose only tests are skipped). - `filter_surefire` and `filter_package` gain a `failure_trail` flag: Surefire 3.x emits the exception class and stack frames *after* the CLOSE line; keep them (stripping framework frames) until the next blank line. Without this, the failing-test signal was silently dropped. Fixtures replaced with output from a real `apache/commons-cli` build in `maven:3.9-eclipse-temurin-21`. Synthetic 2.x shape removed; 2.x compat locked by a dedicated unit test using the single-dash separator. Measured token savings on commons-cli fixtures: - `mvn test` (full): 1896 -> 38 tokens (98.0%) - `mvn install` (full): 2021 -> 78 tokens (96.1%) Refs: pszymkowiak review on PR #1956 --- src/cmds/jvm/mvn_cmd.rs | 146 ++++++++++++++++-- .../fixtures/mvn_compile_error_slice_raw.txt | 30 ++-- tests/fixtures/mvn_install_full_raw.txt.gz | Bin 3456 -> 3257 bytes tests/fixtures/mvn_install_slice_raw.txt | 55 ++++--- tests/fixtures/mvn_test_fail_slice_raw.txt | 49 +++--- tests/fixtures/mvn_test_pass_full_raw.txt.gz | Bin 2978 -> 2898 bytes tests/fixtures/mvn_test_pass_slice_raw.txt | 46 ++---- 7 files changed, 226 insertions(+), 100 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 0cadc370f..a12358268 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -22,9 +22,11 @@ lazy_static! { static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. - /// Tolerates the optional `<<< FAILURE!` marker between duration and ` - in `. + /// Tolerates the optional `<<< FAILURE!` marker. Separator is `-` (Surefire 2.x) + /// or `--` (Surefire 3.x). Prefix INFO/ERROR/WARNING (3.x emits WARNING for + /// classes with only skipped tests). static ref CLOSE: Regex = Regex::new( - r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)? - in (.+)$" + r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)?\s+--?\s+in (.+)$" ).unwrap(); /// Final BUILD footer. @@ -151,7 +153,10 @@ fn keep_outside_block(line: &str) -> bool { /// State machine: when a `[INFO] Running ` line is seen, start buffering /// the block. When the close line arrives, decide: /// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, durations normalised. +/// - Else → emit the block with framework frames stripped, durations normalised, +/// then enter a failure-trail mode that preserves the exception line and +/// user-code frames Surefire 3.x emits *after* the close line (until the +/// next blank line ends the trail). /// /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). @@ -165,6 +170,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut block_lines: Vec = Vec::new(); let mut block_running: Option = None; let mut in_block = false; + let mut failure_trail = false; for line in stripped.lines() { if PLUGIN_BANNER.is_match(line) { @@ -178,6 +184,7 @@ pub fn filter_surefire(raw: &str) -> String { block_lines.clear(); block_running = Some(line.to_string()); in_block = true; + failure_trail = false; continue; } @@ -189,6 +196,7 @@ pub fn filter_surefire(raw: &str) -> String { emit_block(&mut out, &block_running, &block_lines); out.push_str(&normalise(line)); out.push('\n'); + failure_trail = true; } block_lines.clear(); block_running = None; @@ -199,6 +207,21 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + if failure_trail { + if line.is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + if keep_outside_block(line) { out.push_str(&normalise(line)); out.push('\n'); @@ -321,6 +344,7 @@ pub fn filter_package(raw: &str) -> String { let mut block_running: Option = None; let mut in_block = false; let mut keep_continuation = false; + let mut failure_trail = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { @@ -336,6 +360,7 @@ pub fn filter_package(raw: &str) -> String { block_running = Some(line.to_string()); in_block = true; keep_continuation = false; + failure_trail = false; continue; } @@ -347,6 +372,7 @@ pub fn filter_package(raw: &str) -> String { emit_block(&mut out, &block_running, &block_lines); out.push_str(&normalise(line)); out.push('\n'); + failure_trail = true; } block_lines.clear(); block_running = None; @@ -357,6 +383,21 @@ pub fn filter_package(raw: &str) -> String { continue; } + if failure_trail { + if line.is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if MODULE_BANNER.is_match(line) || keep_outside_block(line) { out.push_str(&normalise(line)); @@ -581,14 +622,13 @@ mod tests { fn filter_surefire_pass_output_compact() { let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); let o = filter_surefire(i); - // Passing fixture has 2 blocks; both should be dropped. - assert!(!o.contains("FooService.execute")); - assert!(!o.contains("ConsultarCidadeUseCaseTest")); - // Output should be a small fraction of input. + // 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")); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( - savings >= 60.0, - "pass-fixture savings >=60%, got {:.1}%", + savings >= 50.0, + "pass-fixture savings >=50%, got {:.1}%", savings ); } @@ -611,8 +651,8 @@ mod tests { o ); assert!( - !o.contains("AppException: input must not be null"), - "passing-test stack dropped; got:\n{}", + !o.contains("Running org.apache.commons.cli.ConverterTests"), + "passing-test Running line dropped; got:\n{}", o ); assert!( @@ -621,7 +661,7 @@ mod tests { o ); assert!( - o.contains("Tests run: 10, Failures: 0"), + o.contains("Tests run: 977, Failures: 0"), "aggregate preserved; got:\n{}", o ); @@ -642,7 +682,7 @@ mod tests { o ); assert!( - o.contains("at com.example."), + o.contains("at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure"), "user-code frame preserved; got:\n{}", o ); @@ -653,6 +693,62 @@ mod tests { ); } + /// 2.x compat: CLOSE regex must still match the single-dash separator emitted + /// by Surefire 2.x. Locks the `--?` regex against accidental tightening. + #[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); + // CLOSE matched → passing block dropped silently. + assert!( + !o.contains("Running x.Foo"), + "2.x ` - in ` close-line matched; passing block dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// 3.x WARNING-prefixed close line (class with only skipped tests) must + /// match CLOSE so the block is dropped (no failures, no errors). + #[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); + assert!( + !o.contains("Running x.Skip"), + "[WARNING] close-line matched; block dropped; got:\n{}", + o + ); + } + + /// 3.x failure-trail: after a CLOSE with `<<< FAILURE!`, the exception + /// class and user-code frames Surefire emits *outside* the block must be + /// preserved until the next blank line. + #[test] + fn surefire_preserves_3x_failure_trail() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.Foo\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in x.Foo\n\ + [ERROR] x.Foo.bar -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat x.Foo.bar(Foo.java:25)\n\ + \tat org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("AssertionFailedError"), "exception preserved; got:\n{}", o); + assert!(o.contains("at x.Foo.bar"), "user frame preserved; got:\n{}", o); + assert!( + !o.contains("at org.junit."), + "framework frame stripped in trail; got:\n{}", + o + ); + } + #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; @@ -776,6 +872,30 @@ mod tests { // ── Token-savings (FULL gzipped fixtures) ─────────────────────────────── + #[test] + #[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_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); + println!( + "mvn_test_pass_full: {} -> {} tokens ({:.1}% savings)", + pf_in_tok, pf_out_tok, pf_s + ); + + let inst = gunzip(include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz")); + let inst_out = filter_package(&inst); + 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); + println!( + "mvn_install_full: {} -> {} tokens ({:.1}% savings)", + inst_in_tok, inst_out_tok, inst_s + ); + } + #[test] fn savings_mvn_test_pass_full() { let bytes = include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz"); diff --git a/tests/fixtures/mvn_compile_error_slice_raw.txt b/tests/fixtures/mvn_compile_error_slice_raw.txt index 73119425f..bffb7bb9f 100644 --- a/tests/fixtures/mvn_compile_error_slice_raw.txt +++ b/tests/fixtures/mvn_compile_error_slice_raw.txt @@ -1,25 +1,33 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- -[INFO] Changes detected - recompiling the module! -[INFO] Compiling 12 source files to /path/to/project/target/classes +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[WARNING] COMPILATION WARNING : +[INFO] ------------------------------------------------------------- +[WARNING] source value 8 is obsolete and will be removed in a future release +[WARNING] target value 8 is obsolete and will be removed in a future release +[WARNING] To suppress warnings about obsolete options, use -Xlint:-options. +[INFO] 3 warnings +[INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------- [ERROR] COMPILATION ERROR : [INFO] ------------------------------------------------------------- -[ERROR] /path/to/project/src/main/java/com/example/app/Foo.java:[42,5] cannot find symbol +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol symbol: variable bar - location: class Foo -[ERROR] /path/to/project/src/main/java/com/example/app/Bar.java:[10,5] ';' expected -[INFO] 2 errors + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error [INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 4.567 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 30.631 s +[INFO] Finished at: 2026-05-21T14:59:46Z [INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure diff --git a/tests/fixtures/mvn_install_full_raw.txt.gz b/tests/fixtures/mvn_install_full_raw.txt.gz index 97e62cccb632002de8bf4a416b33ac6f200ea3a2..eb894cfcfac2155c6bf284790636227f4ea233ee 100644 GIT binary patch literal 3257 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 literal 3456 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 diff --git a/tests/fixtures/mvn_install_slice_raw.txt b/tests/fixtures/mvn_install_slice_raw.txt index 539919bd2..c4bb76cfd 100644 --- a/tests/fixtures/mvn_install_slice_raw.txt +++ b/tests/fixtures/mvn_install_slice_raw.txt @@ -1,39 +1,44 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- -[INFO] Compiling 12 source files to /path/to/project/target/classes -[WARNING] /path/to/project/src/main/java/com/example/app/Foo.java: Some input files use or override a deprecated API. -[WARNING] /path/to/project/src/main/java/com/example/app/Bar.java: Some input files use unchecked or unsafe operations. -[INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- -[INFO] Running com.example.app.FooServiceTest -Jan 15, 2026 10:30:00 AM com.example.app.FooService execute -SEVERE: null -com.example.app.AppException: input must not be null - at com.example.app.FooService.execute(FooService.java:36) - at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at java.util.ArrayList.forEach(ArrayList.java:1259) -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 47 source files with javac [debug parameters release 11] to target/classes +[INFO] +[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ commons-cli --- +[INFO] Recompiling the module because of changed dependency. +[INFO] Compiling 52 source files with javac [debug parameters release 11] to target/test-classes +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest [INFO] [INFO] Results: [INFO] -[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 [INFO] -[INFO] --- jar:3.4.0:jar (default-jar) @ myapp --- -[INFO] Building jar: /path/to/project/target/myapp-1.0.0.jar +[INFO] --- jar:3.5.0:jar (default-jar) @ commons-cli --- +[INFO] Building jar: target/commons-cli-1.11.1-SNAPSHOT.jar [INFO] -[INFO] --- install:3.1.1:install (default-install) @ myapp --- -[INFO] Installing /path/to/project/target/myapp-1.0.0.jar to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.jar -[INFO] Installing /path/to/project/pom.xml to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.pom +[INFO] --- install:3.1.4:install (default-install) @ commons-cli --- +[INFO] Installing pom.xml to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.pom +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.jar +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT-sources.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT-sources.jar [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 49.550 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:29 min +[INFO] Finished at: 2026-05-21T15:13:00Z [INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_test_fail_slice_raw.txt b/tests/fixtures/mvn_test_fail_slice_raw.txt index b44af338a..4af9af21a 100644 --- a/tests/fixtures/mvn_test_fail_slice_raw.txt +++ b/tests/fixtures/mvn_test_fail_slice_raw.txt @@ -1,39 +1,46 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- -[INFO] Running com.example.app.FooServiceTest -org.opentest4j.AssertionFailedError: expected: <3> but was: <2> - at com.example.app.FooServiceTest.shouldCountThree(FooServiceTest.java:42) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at java.lang.reflect.Method.invoke(Method.java:498) - at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) - at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) - at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) - at java.util.ArrayList.forEach(ArrayList.java:1259) - at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) -[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in com.example.app.FooServiceTest -[INFO] Running com.example.app.BarServiceTest -[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] Running org.apache.commons.cli.SolrCliTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in org.apache.commons.cli.SolrCliTest +[INFO] Running org.apache.commons.cli.RtkInducedFailTest +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest +[ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE! +org.opentest4j.AssertionFailedError: expected: but was: + 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:182) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145) + at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25) + 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 org.apache.commons.cli.PatternOptionBuilderTest +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in org.apache.commons.cli.PatternOptionBuilderTest [INFO] [INFO] Results: [INFO] [ERROR] Failures: -[ERROR] FooServiceTest.shouldCountThree:42 expected: <3> but was: <2> -[ERROR] Tests run: 10, Failures: 1, Errors: 0, Skipped: 0 +[ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: +[INFO] +[ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 3.124 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:05 min +[INFO] Finished at: 2026-05-21T14:57:09Z [INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures. diff --git a/tests/fixtures/mvn_test_pass_full_raw.txt.gz b/tests/fixtures/mvn_test_pass_full_raw.txt.gz index 2c1b36e3bb955122f1b2139df4db968f96c0dac5..c44504db94a27e03cec10588fdfb2e7208ae33ca 100644 GIT binary patch literal 2898 zcmV-Y3$64YiwFpoAP;H+17mDyEp%mbbS-dUb8{|pXm4_KE_8Tw0M(uAbDOvpz`yse z(CjC7ZjpKVHM=`Eb(%JJlC)DN_jYb)KPXtSX#p`vq{;p3uLK4^0!C1fyP4S{et}!I>YM3^dLvfh34qXxiB&7MP7NMA5YG~;CL6gRtU_^pL z1iVyD00!eJ`2H3k1`OTiM??C$d&O*CtjXp`vnIU^zd{Zen4`@g$Kz4wgZ!3+9?SmK z?ny9opkYI6+n{9)-Hy0{P%v=dIKRmwv-9x>#Qp9ulE0=nHZ%;V?>1>!Q@2`ZEE)_f zXy&>w#{0U+U$xS|-$6cwT;H4R9D_g0Ptfw~y-hqnRq$Cl2r$pi1@o&*UOQ7*Kf}|OVdN((kGr4rofhMOFw9>lNn#gs z*OPV+EpC?j@y}@KXL|7wy^(@};cP6+92tGN(lY$~oo($mXtt$Hg*6O3&cLm9#{Ft% z1CYhOEl?Ktdew=9WbTdi+yOxwT=yGsts)Xyy++&vIXvRpn?a7IhKh)*9|k{NLBy>R zDk5&4aTxYgr7!q-SH?rg-^A&PU^kdp#cVSBnoVW+IOO$&i{mZ!2%6=6TH2?~6}KYe ziYyW%)3Wi~kTDi?eJe~J01T$vgmLPq3|!7Z_;8d3u2RX|safg*ms123r`N!B zAZIslIgX0JWkF*`qy_Tv#)rt%Z>}{H7sSHe9_?FZuLg}_y zOPiI2qXs;pO32O6)W;{Op`63|#Zg~Fl{1JCZ5JV0X;vwF4^>Uc20UP3<%Q>TY^zjS zma}d-?SgZ!M@xH@8Chd0Fc`r}QOsDst_D>ZUt=n6f_dgN*1NBUU9!Y$lyE%qjSTO_ zVxzXke#E2Ft{X|?mQmfG#;%T2kIvDA4>XE&y!!;@8*+(D#39TMp-W;s)FBsZA2w@Q zbD81&1OoYC4iOE~|E6#%UN!#OpA;>BYLrXM*S7nW%3_QQk>X92y!PgIM3rD*6c2^0 z9##%s8j?6AW+kSSbsRCKP83kBZ=UoQ6)tqjHPH=1jiK@M&#udvf1(U(IkkC7Xz7Ke zIQ?VE8wxjKEBA(`3N*A5>jN*z()JCQ7AGO|0=$WQ`nU=Z#j7hX#DHQ37y&$cdqnAl zX2UfJ@Gzk`9>Ty(L8-?`{M-@NA4^QxgXiNvh7n@xAtOWA_lArUVYazG#821-+Ltf0 zf4mFPy^qhILlk)KnYiXv<{U~M90Hoe_`!?uOk|+WjB!a|u*452@tG#xy$1i>eUb=z zd>)sx6bBlLyfkBW_L|acxz9SMIf!^rn_Eub3lT5re6m{c9Hazt+j9b?HNz4rbc)R# zx@sNLHHz_4OPJ@=L)_iI6X>xy8W`|gJ})C})vCl#42RSc9;4*m@AsZSo$c_1c@}Gp zx}!*^n?fyz%N+3rxwKE6K7RsDESy=z!kKb+c~1ySpP}sC_^kB=_9%xV*1C&Q($P;i zr|Hp2Lr*=Y8{qMTw20caqgtg$bBg{}hnjb_;+1bqe`CQGXs-v5KgD9a=mF;H5?tW~mB1)VxIoK=@TE(KzsOy!LUhewt z79OK&kL9nDv}TJ#Ms5eAswLQ8r3TD+c@21->GuHljtU=u5=OuhGwd?X9mV9)TjCgq z6{!tl2Am6m=^l*8N9=*b0sPk;fA?wg?$hRMb-pIs5R%gz>SVw71Q}^NJdx!WlfqVB zx#u?U!o-S>6KJvt6!+D|BGOn@M!)eCVnNv1Fzz;~Q<*e~@C|t}+1po)3fn(0b{wv} z5DyJ#n9wZm)UwV#HkNfmwfn`Kv87mn`E#jq)_k#Ey$YLhiOHI8)#%|%7RO2DF~R3`oH@OJiL^}Zppq5tl-81Bp~s>EvKX;gwWoVyC< zfwWEW8Zvjr&ZP5ltV=WFal^?gF&mCM-$&5#9pn%rM^RifW1l1riL-`mnkAxR|q9Dd_Trm#Q zYD4Rn0VA<`^9p3*C=h=OFLZHwbdIST zdubQ{%tgBV`0DtTnq8{I!bdIb!;sVKOU1}(6+fXM@-h6fW`Qqd=`9DMPihTal0|Yv zXOY~@9k&~gpCvEwwRqdNLB$#SsstIM%b0c^Ll9@~-ReIZ<$ z*)p9fEw_ZOkEoT><*5y`DkQa}c18`T_oYiyo1;-x0BT8X81mEy(i>1a9qZb#n^3p2 z5YsU`*0pUmpmy3>h%xDSGU^Q`E5lhe>-4ro*;I6#u?xOFlzG0pGGf6hMNT;9Cjw+k5;XOu03 z)K^w70OYXK(mu>4L;9;4nxe~ zHS)!(DPG=vzA7#;14;NjBtJs2frb&6_%nW0a!kguLWYmR6`cEs(i_4q#92F17SEL7 z0G{fCO{djwv1J}$XT0@57{%WKukNJ5;+>?@12COCg_oR6K^@=P?iY*NC}m{^_o3jas)| zk5M-c9Pu=crzOiX#*?l4gjKFsw0-WN*SeZIh}tovzfMqRLK7(yYV}mVgg}aXo~4!9 zIkYSy*=?UC*E*YW*|oqy!0U1GyBC!)bkcJ3M7@8hkY2<57E#ab#=nfq7UXI*{Q^J= z`z&510S?(EJ(^)zz4?}70Cnt9NVS7W!$Tg=+k+6viq#D0v?@_B*%^vf@Ol{@th zt7JfmF=5KR4xJsg@8()#8N(hK9qs6<{(nf^U0$7o#oc`VZn5Y~$Sq;W2jW+9W&r5M wjB7ja!o2r_7x#3{i+05LRMyRrraPKx+#2@Gv1gY4-_EiA55bX@@xo^S0IJitEdT%j literal 2978 zcma)6YdDk%8&*PAJ6V;{*9>hD*-Y8pm>G&?krkzA*6cXs*pkCL#yErsYcsrKsccM6 zlN=*7W`t}QZ)h+%=j?SDIpmNeCf^u;_RsfS{d)d9*Zthj{oMDXLfgHY-XQC_Q_`F2 z<4AGyr#Sju_4jvl54e6ELkXft%njQ6_M0lkF3#|BM@&wV4j;o#q27K?;~sk~`S4u1 z$?Pmwy2i=vnEMfnoobcSY)4{}t!d{ghW7UnKW!vr-`4nD6|EWK^qawA{S3S0^YRTc zPHS@6^YigFPF}KG%}0_|PvMW28DYKYwVOwA2Evs#*Jm8dmJ;q9`HGd#rslntamRZt z=>|nBH2$mN9UHa)BwfmPF#@qHaUrYS^dR?FD4^1@3$34F^mrPK~F&Xv_8>?f3 zT-E!&?&Tg(N_Z*BA@sq$mH^h@7m_@~39kxfCfg?5g|jN$H_t+zISOZ=jQRtF^Fa&D zWzW^UY7Y*uZ<3G(of98^Uk?dPGjR17CS9vywEI+=2#*V^?-O{FyfR`+K>4-1&wqW~ z91<3A`Q&F(j>$|zpYGIkLi6%Fmcm})cc<(1D_g1SK6g(X(llL}i%0DvdQb9V3|dny za(!t50ff)QVqe9claD&cEMjL#+DeD}D(3l}zP|aLP4fybdJM|bYirD+_N@x0$XCKW zi%)O5I(7x$A9R@*Y+hAQRp)PXTon$UR=)Bk5yuSw#%Uhixp_i3-sHHW9UZoy&Q zI2Sg>y{2P5hmbZwwl3*ND=4Ccgj}|y*?pn{_Cp4zi{rXd>J8d`b$kda=3ZdG%w~2m zt#-(Umt0VpKywc9|FC**&UVj)%Zz>MfwZ;vkAizUOzd^mL)JAnCa=QvLhR6UNA~G@ z=e_~*{UOD`3H`93+rxvsqZQ%7!;DYfH{Y^D0D`@)*IeMn^bf1Ls?QI7aw+8u2bL(V zq%3`!m~Zg5=CwWVE4y*!{-n=4$C`H@RlmHoTwcP3rdMkg*p-iXCb_mH)1EnWR@FL$ z=ih5t3Rzz*d|Pq8*{+o2t^2T1wQ#&Tm>+;NB*>L@-$Jo6^H`ZMF|k1LVZKzBV(f87 zv8d!pc`lB7zMvOL1$+LqgXQF<)oU#W66MYoOBH&)NF|=~1pcm@7L`b1rs9QS#VE7x zTW44!5yU@Pieq^(KR=E^^%&W(0l{kZjt4uIg{y zzMs57%gR-(k08buv6OJ0+Hpi0AJ8%_Ajbd5QrcF4yIV~6^TwF-coSFSFw(^f(+HB{ znUl_;3|grs(!~z0|t6YC=Q%s`Vc((2^DgF#gNv5;Tm71AS8SS8$B5*@9Q>GIg z9$gAKn7z2Fbi(SSu}~eRB^7|MCt*27VX71_SE@*^N%JGSRwq zk$(C$e(@@Y5GNNA+Czz%TsC|R^H~Qi z^rmd|=WVceNcbkaP(aB0MN5tf_T9BD+G4f5J^kNgjfiPA8mtH!p`(&SJ1%sLNnUUF`GHCoCf9D48{T}>UIk;dO# zo~ko^c1wC=Z5#w?XVqB6$b0B?JN*L}dHKHwHwv^Kf(Y%j32cbgfQ*alwy z6Rlo_R!>3aa;OYEl|i90Y^V$;NDN3715&C98R*)Mo`!?Dvv^T>X4lpbdW4w`0)0^8MwN$_||VJj5!L!p2C1%1M}@bHy^md z2c)+gb_vlA+>x9R@Y@PPzhrPm8|3yyD+}~Q*8T!%1VYaLC8!`GC{BO}_=FDn??xl_ z&`2aYg+n3XDI^MoWJ4htiV}cDBX(?S7;PL%AsGocz0(|yfYXozzs`cQnQ(SCyz}1@ zX0+KNRnRH&cnlSf!Qe5#G>0PK2n3uw0Vm?$$1{_Nqk|UzbFe7O6mj(4Gc~^mZ_2-m zs}8CC4ASgDngwV<64?QV!Q(KRI85SpLUH1R?uZymh82!LTnxljgVc&qDIf~@0EN7Z zLcSE6CIYo(8s)HG?|u|;qT7Me4glZ-w=JL?W5~n|GBJUq#O}u#iUWFKVxbR^Erp%g z@K_nF&?E9SNNWXY&7j=u_VWU_o>xZmXg#cO3*v$hHwIF}D5oHlk!WS)0cGUK*4PON zWi#AGP`T!4Pb{Rii#|owPp?`CQby9J(E8~!3qi$oFm6Evm|OpsBwd`Rvs0&5M;EIT zpuIv30#Bci%;e1_9_;d+r7L1LtsM8x)P!z+;------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- -[INFO] Running com.example.app.FooServiceTest -Jan 15, 2026 10:30:00 AM com.example.app.FooService execute -SEVERE: null -com.example.app.AppException: input must not be null - at com.example.app.FooService.execute(FooService.java:36) - at com.example.app.FooServiceTest.lambda$rejectsNullInput$0(FooServiceTest.java:42) - at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:55) - at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:37) - at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) - at com.example.app.FooServiceTest.rejectsNullInput(FooServiceTest.java:42) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) - at java.lang.reflect.Method.invoke(Method.java:498) - at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) - at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) - at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) - at java.util.ArrayList.forEach(ArrayList.java:1259) - at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) - at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) - at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest -[INFO] Running com.example.app.BarServiceTest -[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.help.TextStyleTest +[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.240 s -- in org.apache.commons.cli.help.TextStyleTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.ConverterTests +[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in org.apache.commons.cli.ConverterTests +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest [INFO] [INFO] Results: [INFO] -[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 3.124 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:14 min +[INFO] Finished at: 2026-05-21T14:54:30Z [INFO] ------------------------------------------------------------------------ From 77e28d096e99d540a602a86ba923c23f2f025baf Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 12:38:06 -0300 Subject: [PATCH 04/19] refactor(mvn): drop production duration normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalise()` rewrote real `Time elapsed: N s` and `Total time: N s` values to `T s` in the shipped output. That was only ever useful for keeping snapshot tests deterministic and never belonged in production: test perf signal is exactly the kind of data the LLM needs to spot a regression, and there are no insta snapshots in this module to protect. - Remove `normalise()` and its `TIME_DURATION` / `TOTAL_TIME` regexes. - All `filter_surefire`, `filter_compile`, `filter_package` emit sites pass the raw line through. - Renamed and inverted `surefire_normalises_durations` → `surefire_preserves_real_durations`: asserts `2.341 s` and `Total time: 4.567 s` survive filtering. Refs: pszymkowiak review on PR #1956 (E.1) --- src/cmds/jvm/mvn_cmd.rs | 66 ++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index a12358268..d42f1b31c 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -46,12 +46,6 @@ lazy_static! { /// Module banner with project name in brackets. static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); - /// Duration normaliser for deterministic snapshots. - static ref TIME_DURATION: Regex = Regex::new(r"Time elapsed: [0-9.]+ s").unwrap(); - - /// Total time line — also normalised. - static ref TOTAL_TIME: Regex = Regex::new(r"^\[INFO\] Total time:\s+[0-9.]+ s").unwrap(); - /// Compile-error coordinate substring to strip when deduping warnings/errors. static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } @@ -117,15 +111,6 @@ fn has_english_footer(stripped: &str) -> bool { }) } -// ── Duration normaliser ───────────────────────────────────────────────────── - -fn normalise(s: &str) -> String { - let s = TIME_DURATION.replace_all(s, "Time elapsed: T s").into_owned(); - TOTAL_TIME - .replace_all(&s, "[INFO] Total time: T s") - .into_owned() -} - // ── Outside-block keep list (shared by surefire + package) ────────────────── fn keep_outside_block(line: &str) -> bool { @@ -153,10 +138,11 @@ fn keep_outside_block(line: &str) -> bool { /// State machine: when a `[INFO] Running ` line is seen, start buffering /// the block. When the close line arrives, decide: /// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, durations normalised, -/// then enter a failure-trail mode that preserves the exception line and -/// user-code frames Surefire 3.x emits *after* the close line (until the -/// next blank line ends the trail). +/// - Else → emit the block with framework frames stripped, then enter a +/// failure-trail mode that preserves the exception line and user-code +/// frames Surefire 3.x emits *after* the close line (until the next +/// blank line ends the trail). Raw durations are preserved — the +/// user/LLM needs them. /// /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). @@ -194,7 +180,7 @@ pub fn filter_surefire(raw: &str) -> String { let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { emit_block(&mut out, &block_running, &block_lines); - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); failure_trail = true; } @@ -223,7 +209,7 @@ pub fn filter_surefire(raw: &str) -> String { } if keep_outside_block(line) { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); } } @@ -236,18 +222,18 @@ pub fn filter_surefire(raw: &str) -> String { fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { if let Some(r) = running { - out.push_str(&normalise(r)); + out.push_str(r); out.push('\n'); } for l in lines { - out.push_str(&normalise(l)); + out.push_str(l); out.push('\n'); } } fn emit_block(out: &mut String, running: &Option, lines: &[String]) { if let Some(r) = running { - out.push_str(&normalise(r)); + out.push_str(r); out.push('\n'); } for l in lines { @@ -255,7 +241,7 @@ fn emit_block(out: &mut String, running: &Option, lines: &[String]) { if t.starts_with("at ") && is_framework_frame(t) { continue; } - out.push_str(&normalise(l)); + out.push_str(l); out.push('\n'); } } @@ -291,7 +277,7 @@ pub fn filter_compile(raw: &str) -> String { || line.starts_with("[INFO] Finished at:") || line.starts_with("[INFO] Scanning ") { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); keep_continuation = false; continue; @@ -370,7 +356,7 @@ pub fn filter_package(raw: &str) -> String { let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { emit_block(&mut out, &block_running, &block_lines); - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); failure_trail = true; } @@ -400,7 +386,7 @@ pub fn filter_package(raw: &str) -> String { // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if MODULE_BANNER.is_match(line) || keep_outside_block(line) { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") && !line.starts_with("[ERROR] Tests run:") @@ -756,16 +742,30 @@ mod tests { assert!(o.contains("-----< com.example:myapp >-----")); } + /// Production must ship raw `Time elapsed` and `Total time` durations + /// untouched — the LLM/user needs the actual numbers to diagnose perf + /// regressions. Earlier revisions normalised these to `T s`; that was + /// only ever needed for deterministic snapshots and never belonged in + /// the production path. #[test] - fn surefire_normalises_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"; + 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); assert!( - o.contains("Time elapsed: T s"), - "duration normalised; got:\n{}", + o.contains("2.341 s"), + "raw close-line duration preserved; got:\n{}", + o + ); + assert!( + o.contains("Total time: 4.567 s"), + "raw total time preserved; got:\n{}", + o + ); + assert!( + !o.contains("Time elapsed: T s"), + "no normalisation in production; got:\n{}", o ); - assert!(!o.contains("2.341"), "raw duration removed; got:\n{}", o); } #[test] From 97dbf98cc6cb6ee7d5a3ba4d29450924142df4ee Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 17:50:01 -0300 Subject: [PATCH 05/19] feat(mvn): filter mvn -q quiet-mode output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `mvn -q`, Maven 3.x suppresses all `[INFO]` lines: no `BUILD SUCCESS` footer, no `[INFO] Running` markers, no module banners. The existing surefire/compile/package filters all key off the English-footer guard and `[INFO] Running`/CLOSE state machine, so under `-q` they fell through and shipped the raw output unchanged (0% savings on PR #1956 reviewer's measurement). Reviewer (@pszymkowiak on PR #1956) listed `-q` handling in the path-to-merge as "worth a note or handling". This commit implements the handling. Behavior captured from `mvn -q test` on Maven 3.9.9 + Surefire 3.5.5 with JUnit 5: - Green run emits ZERO bytes — filter returns empty output. - Failure run emits ~27 lines: per-class CLOSE line, exception + stack trace, `[ERROR] Failures:` summary, aggregate `[ERROR] Tests run: N, Failures: F, ...`, and the `[ERROR] Failed to execute goal` terminator, followed by a ~10-line `[ERROR] See .../[Help 1].../Re-run Maven .../To see the full stack trace .../For more information ...` boilerplate block pointing at log files and help URLs. `filter_quiet` keeps the failure-signal lines and the user-code stack frames; drops the framework frames (existing deny-list) and the post-failure boilerplate block. Routing: `is_quiet` checks argv for `-q` or `--quiet`. When set, `run()` short-circuits the phase-based filter selection and routes non-passthrough phases to `filter_quiet`. Passthrough phases (`clean`, `site`, plugin goals) remain passthrough under `-q`. Measured savings on the captured fail fixture: 51.1% (174 -> 85 tokens). Green run: 0 -> 0 (no overhead). Safety net: unclassified `[ERROR]` lines are kept rather than dropped — better to leak a line than hide signal. Tests added (7): - `quiet_detects_short_flag` / `quiet_detects_long_flag` / `quiet_does_not_match_unrelated_flags` for `is_quiet`. - `quiet_green_run_is_empty` — empty input contract. - `quiet_fail_strips_framework_and_boilerplate` — kept-list and drop-list assertions on the real fixture. - `savings_mvn_quiet_fail` — >=50% savings target on the fixture. - `quiet_unknown_error_line_kept_as_safety_net` — unclassified `[ERROR]` not silently dropped. New fixture: `tests/fixtures/mvn_quiet_fail_raw.txt` (27 lines, 770 bytes) captured from a real `mvn -q test` run against a JUnit 5 / Surefire 3.5.5 project with one failing test. --- src/cmds/jvm/mvn_cmd.rs | 259 +++++++++++++++++++++++++- tests/fixtures/mvn_quiet_fail_raw.txt | 27 +++ 2 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/mvn_quiet_fail_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index d42f1b31c..e47068993 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -50,6 +50,17 @@ lazy_static! { static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } +// ── Quiet-mode detection ──────────────────────────────────────────────────── + +/// `mvn -q` / `mvn --quiet` suppresses all `[INFO]` lines: no `BUILD SUCCESS` +/// footer, no `[INFO] Running` markers, no module banners. A passing run emits +/// **zero bytes**; a failing run emits only `[ERROR]`-prefixed lines plus the +/// stack trace. The standard filters key off `[INFO]` markers and the footer +/// guard, so they can't fire here — `filter_quiet` handles this case instead. +fn is_quiet(args: &[String]) -> bool { + args.iter().any(|a| a == "-q" || a == "--quiet") +} + // ── Phase detection ───────────────────────────────────────────────────────── #[derive(Debug, PartialEq, Clone, Copy)] @@ -419,6 +430,107 @@ pub fn filter_package(raw: &str) -> String { out } +// ── Quiet-mode filter ─────────────────────────────────────────────────────── + +/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — +/// pure noise pointing at log files and help URLs, no signal for the user/LLM. +const QUIET_BOILER_PREFIXES: &[&str] = &[ + "[ERROR] See ", + "[ERROR] -> [Help", + "[ERROR] To see the full stack trace", + "[ERROR] Re-run Maven", + "[ERROR] For more information", + "[ERROR] [Help", +]; + +/// Filter for `mvn -q` invocations. +/// +/// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard +/// `filter_surefire` / `filter_compile` / `filter_package` pipelines (which +/// key off the English `BUILD SUCCESS` footer and `[INFO] Running` markers) +/// can't fire. This filter handles the residual `-q` output shape: +/// +/// - Green run: input is empty → output is empty (0 → 0, no overhead). +/// - Failure run: keeps the Surefire close-line (`[ERROR] Tests run: … +/// <<< FAILURE! -- in FQN`), the per-test failure subline, exception class, +/// user-code stack frames, the failure summary block (`[ERROR] Failures:`, +/// indented entries, aggregate `Tests run: N, Failures: F, …`), and the +/// `[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 { + let stripped = strip_ansi(raw); + if stripped.trim().is_empty() { + return String::new(); + } + + let mut out = String::new(); + let mut failure_trail = false; + + for line in stripped.lines() { + // Surefire close-line for a failed class — keep + enter failure trail. + if CLOSE.is_match(line) { + out.push_str(line); + out.push('\n'); + failure_trail = line.contains("<<< FAILURE!"); + continue; + } + + // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!`. + if line.starts_with("[ERROR] ") && line.contains("<<< FAILURE!") { + out.push_str(line); + out.push('\n'); + failure_trail = true; + continue; + } + + // Failure-trail body: exception class, user-code frames; drop framework frames. + if failure_trail { + if line.trim().is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + + // 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") + { + out.push_str(line); + out.push('\n'); + continue; + } + + // Drop post-failure help boilerplate. + if QUIET_BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) { + continue; + } + + // Drop empty `[ERROR]` / `[ERROR] ` divider lines Maven emits between blocks. + if line.trim_end() == "[ERROR]" { + continue; + } + + // Safety net: keep anything else (unexpected output under `-q` is rare; + // do not silently drop signal we haven't classified). + out.push_str(line); + out.push('\n'); + } + + out +} + // ── Wrapper detection ─────────────────────────────────────────────────────── fn mvn_binary() -> &'static str { @@ -463,10 +575,29 @@ pub fn run(args: &[String], verbose: u8) -> Result { return runner::run_passthrough(mvn_binary(), &osargs, verbose); } - let phase = detect_phase(args); let tool = mvn_binary(); let args_display = args.join(" "); + // Quiet mode: standard footer guard can't fire (no `BUILD SUCCESS` line + // under `-q`). Route to `filter_quiet` for any non-passthrough phase so + // failure output gets framework frames + help boilerplate stripped. + if is_quiet(args) { + let phase = detect_phase(args); + if matches!(phase, MvnPhase::Passthrough) { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(tool, &osargs, verbose); + } + return runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_quiet, + RunOptions::with_tee("mvn_quiet"), + ); + } + + let phase = detect_phase(args); + match phase { MvnPhase::Test => runner::run_filtered( new_mvn_command(args), @@ -925,4 +1056,130 @@ mod tests { count_tokens(&o) ); } + + // ── Quiet mode (`mvn -q`) ──────────────────────────────────────────────── + + #[test] + fn quiet_detects_short_flag() { + assert!(is_quiet(&s(["-q", "test"]))); + assert!(is_quiet(&s(["test", "-q"]))); + assert!(is_quiet(&s(["-B", "-q", "-DskipFoo", "install"]))); + } + + #[test] + fn quiet_detects_long_flag() { + assert!(is_quiet(&s(["--quiet", "test"]))); + } + + #[test] + fn quiet_does_not_match_unrelated_flags() { + assert!(!is_quiet(&s(["-Q", "test"]))); + assert!(!is_quiet(&s(["-quiet", "test"]))); + assert!(!is_quiet(&s(["-B", "test"]))); + } + + /// 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"), ""); + } + + /// Failure under `-q`: keep close-line, exception, user frame, summary, + /// goal terminator. Drop framework frames + help boilerplate block. + #[test] + fn quiet_fail_strips_framework_and_boilerplate() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + + // Kept: failure signal. + assert!( + o.contains("Tests run: 1, Failures: 1, Errors: 0, Skipped: 0"), + "close-line preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at x.FailTest.this_will_fail"), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failures:"), + "failure summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0"), + "aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + + // Dropped: framework frames. + assert!( + !o.contains("at org.junit."), + "junit frame stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "java.base frame stripped; got:\n{}", + o + ); + + // Dropped: help boilerplate. + assert!( + !o.contains("To see the full stack trace"), + "help boilerplate stripped; got:\n{}", + o + ); + assert!( + !o.contains("[Help 1] http"), + "help link stripped; got:\n{}", + o + ); + assert!( + !o.contains("See /tmp/") && !o.contains("See dump files"), + "log-pointer lines stripped; got:\n{}", + o + ); + } + + /// Savings target on the real `mvn -q test` fail fixture. + #[test] + fn savings_mvn_quiet_fail() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "mvn -q fail ≥50% savings, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(i), + count_tokens(&o) + ); + } + + /// Safety net: if the `[ERROR]` line isn't on the known keep/drop lists, + /// the filter must NOT silently drop it. Better to leak a line than to + /// hide signal. + #[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); + assert!( + o.contains("Some unexpected error output"), + "unclassified ERROR line preserved; got:\n{}", + o + ); + } } diff --git a/tests/fixtures/mvn_quiet_fail_raw.txt b/tests/fixtures/mvn_quiet_fail_raw.txt new file mode 100644 index 000000000..ef5ecfbaf --- /dev/null +++ b/tests/fixtures/mvn_quiet_fail_raw.txt @@ -0,0 +1,27 @@ +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.069 s <<< FAILURE! -- in x.FailTest +[ERROR] x.FailTest.this_will_fail -- Time elapsed: 0.030 s <<< FAILURE! +org.opentest4j.AssertionFailedError: deliberate failure for rtk -q filter testing ==> 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 x.FailTest.this_will_fail(FailTest.java:9) + 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] Failures: +[ERROR] FailTest.this_will_fail:9 deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2> +[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project mvnq: There are test failures. +[ERROR] +[ERROR] See /tmp/mvnq/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 97bd2a7787c459e2df396bb3d28b9660b35c5ec6 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 12:05:01 -0300 Subject: [PATCH 06/19] fix(mvn): preserve compile-error continuation in filter_surefire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `mvn test` fails at the compile step before Surefire runs, the `[ERROR]` block's indented `symbol:` / `location:` / `^` continuation lines were being dropped. `filter_surefire` had no `keep_continuation` state, unlike `filter_compile:302` and `filter_package:408`. Add the missing state machine: reset in the RUNNING branch, set on each kept `[ERROR]` line (except `Tests run:` / `Failures:` / `Errors:` headers — same predicate as `filter_package:402-405`), and pass through indented continuation lines before the outside-block keep-list check. Fixture `mvn_test_compile_fail_slice_raw.txt` captures the `mvn test` slice for a project with a deliberate `cannot find symbol` in a `src/main/java` source. New tests: - `surefire_keeps_compile_continuation_on_test_phase` — the bug. - `package_still_keeps_compile_error_continuation_after_refactor` — drift guard on the `install`/`verify` path. --- src/cmds/jvm/mvn_cmd.rs | 56 +++++++++++++++++++ .../mvn_test_compile_fail_slice_raw.txt | 29 ++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/fixtures/mvn_test_compile_fail_slice_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index e47068993..a7599af77 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -168,6 +168,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut block_running: Option = None; let mut in_block = false; let mut failure_trail = false; + let mut keep_continuation = false; for line in stripped.lines() { if PLUGIN_BANNER.is_match(line) { @@ -182,6 +183,7 @@ pub fn filter_surefire(raw: &str) -> String { block_running = Some(line.to_string()); in_block = true; failure_trail = false; + keep_continuation = false; continue; } @@ -219,9 +221,20 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if keep_outside_block(line) { 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:"); + continue; } } @@ -866,6 +879,49 @@ mod tests { ); } + /// `mvn test` whose compile step fails before Surefire runs must still + /// keep the `[ERROR]` block's indented `symbol:` / `location:` continuation + /// lines. Regression guard for the P0 reviewer ask: `filter_surefire` + /// previously dropped them because it had no `keep_continuation` flag. + #[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); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + } + + /// Regression guard on the package path so the install/verify route does + /// not silently drift the other way after the `filter_surefire` continuation + /// fix. Uses the existing compile-error slice — `filter_package` is the + /// `install`-phase entry point and must keep the same continuation lines. + #[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); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + } + #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; diff --git a/tests/fixtures/mvn_test_compile_fail_slice_raw.txt b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt new file mode 100644 index 000000000..ac5c7e051 --- /dev/null +++ b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt @@ -0,0 +1,29 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol + symbol: variable bar + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.214 s +[INFO] Finished at: 2026-05-21T15:02:31Z +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure +[ERROR] /home/runner/commons-cli/src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol +[ERROR] symbol: variable bar +[ERROR] location: class org.apache.commons.cli.CompileBreaker +[ERROR] -> [Help 1] From 5459c6d227987bead282367775bde94485402975 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 12:10:14 -0300 Subject: [PATCH 07/19] refactor(mvn): extract shared SurefireBlock state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter_surefire` and `filter_package` had a ~30-line duplicated state machine for Surefire/Failsafe per-class blocks (RUNNING / in-block buffer / CLOSE branching / failure-trail). The duplication is what let the P0 continuation bug land — the fix had to be applied in only one of the two filters. Introduce `SurefireBlock` owning that inner machine: PLUGIN_BANNER skip, RUNNING (with prior-block flush on truncated output), in-block buffering, CLOSE detection, and the post-close failure-trail with framework-frame stripping. Each `step()` returns `SurefireStep::{Consumed, FailingClose, Passthrough}`. The `FailingClose` arm defers the actual write to the caller via `commit_failing()` — this is the seam Commit D will use to enforce the `mvn_max_failures` cap without duplicating the cap logic. Outside-block keep logic stays inside each filter unchanged (`MODULE_BANNER || keep_outside_block`, `[WARNING]` dedup, the `keep_continuation` flag from Commit A). Byte-identical filter output — all existing `surefire_*` / `package_*` / token-savings tests pass. The previous free helpers `flush_block_as_keep` and `emit_block` are removed; their behaviour now lives in `SurefireBlock::flush_open_block_as_keep` and `SurefireBlock::commit_failing`. --- src/cmds/jvm/mvn_cmd.rs | 305 +++++++++++++++++++++++----------------- 1 file changed, 174 insertions(+), 131 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index a7599af77..884691e2d 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -144,81 +144,193 @@ fn keep_outside_block(line: &str) -> bool { // ── Surefire block filter ─────────────────────────────────────────────────── -/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. -/// -/// State machine: when a `[INFO] Running ` line is seen, start buffering -/// the block. When the close line arrives, decide: -/// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, then enter a -/// failure-trail mode that preserves the exception line and user-code -/// frames Surefire 3.x emits *after* the close line (until the next -/// blank line ends the trail). Raw durations are preserved — the -/// user/LLM needs them. +/// Shared state machine driving the inner Surefire block + failure-trail +/// behaviour for `filter_surefire` and `filter_package`. Each filter wraps it +/// with its own outside-block keep logic (`[WARNING]` dedup, module-banner +/// keep, `keep_continuation` for compile-error continuations, etc.) which is +/// applied on the [`SurefireStep::Passthrough`] arm. /// -/// 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 { - let stripped = strip_ansi(raw); - if !has_english_footer(&stripped) { - return stripped; - } +/// Inner machine responsibilities: +/// - `[INFO] --- … @ … ---` plugin banner skip +/// - `[INFO] Running ` opens a buffered block (flushes any prior open +/// block as keep — happens on truncated output) +/// - in-block buffering until the next CLOSE line +/// - CLOSE with `Failures > 0` or `Errors > 0` → yields +/// [`SurefireStep::FailingClose`] so the outer loop can decide whether to +/// emit (Commit D uses this seam to enforce `mvn_max_failures`) +/// - failure-trail handling for the exception/user-frame trail Surefire 3.x +/// emits **after** the close line, terminated by a blank line. Framework +/// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the +/// buffered block and the trail; user-code frames are preserved. +struct SurefireBlock { + block_lines: Vec, + block_running: Option, + in_block: bool, + failure_trail: bool, +} - let mut out = String::new(); - let mut block_lines: Vec = Vec::new(); - let mut block_running: Option = None; - let mut in_block = false; - let mut failure_trail = false; - let mut keep_continuation = false; +enum SurefireStep { + /// Inner machine consumed the line; outer loop should `continue;`. + Consumed, + /// A CLOSE line with `Failures > 0` or `Errors > 0` was reached. Outer + /// loop decides whether to commit (via [`SurefireBlock::commit_failing`]). + FailingClose { + running: Option, + lines: Vec, + close: String, + }, + /// Inner machine did not handle the line; outer loop applies its own + /// outside-block keep logic. + Passthrough, +} - for line in stripped.lines() { +impl SurefireBlock { + fn new() -> Self { + Self { + block_lines: Vec::new(), + block_running: None, + in_block: false, + failure_trail: false, + } + } + + fn step(&mut self, line: &str, out: &mut String) -> SurefireStep { if PLUGIN_BANNER.is_match(line) { - continue; + return SurefireStep::Consumed; } if RUNNING.is_match(line) { - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); + if self.in_block { + self.flush_open_block_as_keep(out); } - block_lines.clear(); - block_running = Some(line.to_string()); - in_block = true; - failure_trail = false; - keep_continuation = false; - continue; + self.block_lines.clear(); + self.block_running = Some(line.to_string()); + self.in_block = true; + self.failure_trail = false; + return SurefireStep::Consumed; } - if in_block { + if self.in_block { if let Some(caps) = CLOSE.captures(line) { 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 { - emit_block(&mut out, &block_running, &block_lines); - out.push_str(line); - out.push('\n'); - failure_trail = true; + 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.to_string(), + }; } - block_lines.clear(); - block_running = None; - in_block = false; - continue; + self.block_lines.clear(); + self.block_running = None; + self.in_block = false; + return SurefireStep::Consumed; } - block_lines.push(line.to_string()); - continue; + self.block_lines.push(line.to_string()); + return SurefireStep::Consumed; } - if failure_trail { + if self.failure_trail { if line.is_empty() { out.push('\n'); - failure_trail = false; - continue; + self.failure_trail = false; + return SurefireStep::Consumed; } let t = line.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { - continue; + return SurefireStep::Consumed; } out.push_str(line); out.push('\n'); - continue; + return SurefireStep::Consumed; + } + + SurefireStep::Passthrough + } + + /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with + /// framework frames stripped), then `close`. Enables `failure_trail` so + /// the post-close exception/user-frame trail is preserved. + fn commit_failing( + &mut self, + out: &mut String, + running: Option<&str>, + lines: &[String], + close: &str, + ) { + if let Some(r) = running { + out.push_str(r); + out.push('\n'); + } + for l in lines { + let t = l.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(l); + out.push('\n'); + } + out.push_str(close); + out.push('\n'); + self.failure_trail = true; + } + + /// End-of-stream flush: if a block opened and never closed (truncated + /// output), surface what we have rather than dropping it silently. + fn finish(&mut self, out: &mut String) { + if self.in_block { + self.flush_open_block_as_keep(out); + } + } + + fn flush_open_block_as_keep(&mut self, out: &mut String) { + if let Some(r) = self.block_running.take() { + out.push_str(&r); + out.push('\n'); + } + for l in self.block_lines.drain(..) { + out.push_str(&l); + out.push('\n'); + } + self.in_block = false; + } +} + +/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. +/// +/// Drives [`SurefireBlock`] for the inner block/trail machine; applies the +/// outside-block keep-list with `keep_continuation` for indented compile-error +/// continuations (`symbol:` / `location:` after a `[ERROR] cannot find symbol` +/// line). +/// +/// 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 { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block = SurefireBlock::new(); + let mut keep_continuation = false; + + for line in stripped.lines() { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + keep_continuation = false; + continue; + } + SurefireStep::Passthrough => {} } if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { @@ -238,38 +350,10 @@ pub fn filter_surefire(raw: &str) -> String { } } - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } + block.finish(&mut out); out } -fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { - if let Some(r) = running { - out.push_str(r); - out.push('\n'); - } - for l in lines { - out.push_str(l); - out.push('\n'); - } -} - -fn emit_block(out: &mut String, running: &Option, lines: &[String]) { - if let Some(r) = running { - out.push_str(r); - out.push('\n'); - } - for l in lines { - let t = l.trim_start(); - if t.starts_with("at ") && is_framework_frame(t) { - continue; - } - out.push_str(l); - out.push('\n'); - } -} - // ── Compile filter ────────────────────────────────────────────────────────── /// Buffered single-pass filter for `mvn compile` / `test-compile`. @@ -350,62 +434,23 @@ pub fn filter_package(raw: &str) -> String { } let mut out = String::new(); - let mut block_lines: Vec = Vec::new(); - let mut block_running: Option = None; - let mut in_block = false; + let mut block = SurefireBlock::new(); let mut keep_continuation = false; - let mut failure_trail = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { - if PLUGIN_BANNER.is_match(line) { - continue; - } - - if RUNNING.is_match(line) { - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } - block_lines.clear(); - block_running = Some(line.to_string()); - in_block = true; - keep_continuation = false; - failure_trail = false; - continue; - } - - if in_block { - if let Some(caps) = CLOSE.captures(line) { - 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 { - emit_block(&mut out, &block_running, &block_lines); - out.push_str(line); - out.push('\n'); - failure_trail = true; - } - block_lines.clear(); - block_running = None; - in_block = false; - continue; - } - block_lines.push(line.to_string()); - continue; - } - - if failure_trail { - if line.is_empty() { - out.push('\n'); - failure_trail = false; - continue; - } - let t = line.trim_start(); - if t.starts_with("at ") && is_framework_frame(t) { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + keep_continuation = false; continue; } - out.push_str(line); - out.push('\n'); - continue; + SurefireStep::Passthrough => {} } // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. @@ -437,9 +482,7 @@ pub fn filter_package(raw: &str) -> String { keep_continuation = false; } - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } + block.finish(&mut out); out } From 460910267b8a8876f4459ee93cbef074a61c39b6 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 13:48:26 -0300 Subject: [PATCH 08/19] fix(mvn): keep multi-module Reactor Summary rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a reactor build (parent pom with ``), Maven appends a `Reactor Summary for ` block at the end listing each module's status: [INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: [INFO] [INFO] multi-module-skeleton ...... SUCCESS [ 0.353 s] [INFO] child-a .................... SUCCESS [ 1.676 s] [INFO] child-b .................... FAILURE [ 0.940 s] `filter_surefire` / `filter_package` were dropping these rows because `keep_outside_block` only keeps `[INFO]` lines that match a specific prefix (`Building`, `Installing`, `Total time:`, etc.). The per-module status rows match none of them. Add a `REACTOR_SUMMARY` regex and `reactor_summary_keep` helper that toggles a flag on the header and clears it on `BUILD SUCCESS` / `BUILD FAILURE`. While the flag is set the helper returns `true` so the rows survive — including `[WARNING]` (activated-profile notices) and the in-summary horizontal rule. Caller invokes the helper before `keep_outside_block` so the clears-flag side effect runs regardless of `||` short-circuit. Fixtures captured against a hand-rolled 2-module skeleton checked in under `tests/fixtures/multi-module-skeleton/` (parent pom + child-a + child-b with `Empty.java` stubs). The fail fixture is captured by temporarily replacing child-b's `Empty.java` with one that references a `BogusType` symbol. Reproducible from the committed skeleton. --- src/cmds/jvm/mvn_cmd.rs | 105 +++++++++++++++++- .../multi-module-skeleton/child-a/pom.xml | 11 ++ .../child-a/src/main/java/Empty.java | 1 + .../multi-module-skeleton/child-b/pom.xml | 11 ++ .../child-b/src/main/java/Empty.java | 1 + tests/fixtures/multi-module-skeleton/pom.xml | 17 +++ tests/fixtures/mvn_reactor_fail_slice_raw.txt | 87 +++++++++++++++ tests/fixtures/mvn_reactor_pass_slice_raw.txt | 79 +++++++++++++ 8 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/multi-module-skeleton/child-a/pom.xml create mode 100644 tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java create mode 100644 tests/fixtures/multi-module-skeleton/child-b/pom.xml create mode 100644 tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java create mode 100644 tests/fixtures/multi-module-skeleton/pom.xml create mode 100644 tests/fixtures/mvn_reactor_fail_slice_raw.txt create mode 100644 tests/fixtures/mvn_reactor_pass_slice_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 884691e2d..3e302f86b 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -46,6 +46,10 @@ lazy_static! { /// Module banner with project name in brackets. static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); + /// Reactor summary header that opens the per-module pass/fail block at + /// the end of a multi-module build. + static ref REACTOR_SUMMARY: Regex = Regex::new(r"^\[INFO\] Reactor Summary for ").unwrap(); + /// Compile-error coordinate substring to strip when deduping warnings/errors. static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } @@ -124,6 +128,27 @@ fn has_english_footer(stripped: &str) -> bool { // ── Outside-block keep list (shared by surefire + package) ────────────────── +/// Multi-module reactor summary keeper. Reads `in_reactor_summary` and toggles +/// it on `[INFO] Reactor Summary for …` (enter) and `BUILD SUCCESS`/`BUILD +/// FAILURE` (exit). Returns `true` for every line while the flag is set so the +/// per-module status rows (`[INFO] foo ...... SUCCESS [ 1.234 s]`, plain +/// `[INFO]` separators inside the summary, etc.) survive. Returns `false` +/// otherwise — the caller's outside-block keep-list still applies. +/// +/// Designed to be called **before** `keep_outside_block` so the `BUILD_FOOT` +/// clears-flag side effect always runs regardless of `||` short-circuit. +fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { + if REACTOR_SUMMARY.is_match(line) { + *in_reactor_summary = true; + return true; + } + if BUILD_FOOT.is_match(line) { + *in_reactor_summary = false; + return false; + } + *in_reactor_summary +} + fn keep_outside_block(line: &str) -> bool { RESULTS.is_match(line) || AGG.is_match(line) @@ -317,6 +342,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut out = String::new(); let mut block = SurefireBlock::new(); let mut keep_continuation = false; + let mut in_reactor_summary = false; for line in stripped.lines() { match block.step(line, &mut out) { @@ -339,7 +365,10 @@ pub fn filter_surefire(raw: &str) -> String { continue; } - if keep_outside_block(line) { + // 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) { out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -436,6 +465,7 @@ pub fn filter_package(raw: &str) -> String { let mut out = String::new(); let mut block = SurefireBlock::new(); let mut keep_continuation = false; + let mut in_reactor_summary = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { @@ -453,8 +483,11 @@ pub fn filter_package(raw: &str) -> String { SurefireStep::Passthrough => {} } + // 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); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. - if MODULE_BANNER.is_match(line) || keep_outside_block(line) { + if reactor_keep || MODULE_BANNER.is_match(line) || keep_outside_block(line) { out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -1027,6 +1060,74 @@ mod tests { ); } + // ── Multi-module reactor summary ───────────────────────────────────────── + + /// `mvn install` on a multi-module reactor build that passes everywhere + /// must preserve the `Reactor Summary for ` header and the per-module + /// `[INFO] foo ...... SUCCESS [ 1.234 s]` rows. + #[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); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-a ............................................ SUCCESS"), + "per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-b ............................................ SUCCESS"), + "second per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// `mvn install` on a multi-module reactor build where one module fails + /// must preserve the Reactor Summary with the `FAILURE` row plus the + /// `[ERROR] Failed to execute goal …` terminator that already survives + /// via `keep_outside_block`. + #[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); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("child-a ............................................ SUCCESS"), + "successful module row preserved; got:\n{}", + o + ); + assert!( + o.contains("child-b ............................................ FAILURE"), + "failing module row preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "reactor-fail slice savings >=30% (short fixture); got {:.1}%", + savings + ); + } + // ── Compile filter ─────────────────────────────────────────────────────── #[test] diff --git a/tests/fixtures/multi-module-skeleton/child-a/pom.xml b/tests/fixtures/multi-module-skeleton/child-a/pom.xml new file mode 100644 index 000000000..8aeb11df9 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-a + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java new file mode 100644 index 000000000..9a9956e0e --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/child-b/pom.xml b/tests/fixtures/multi-module-skeleton/child-b/pom.xml new file mode 100644 index 000000000..3ea22fc11 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-b + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java new file mode 100644 index 000000000..9a9956e0e --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/pom.xml b/tests/fixtures/multi-module-skeleton/pom.xml new file mode 100644 index 000000000..a40a3c786 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + pom + + child-a + child-b + + + 21 + 21 + UTF-8 + + diff --git a/tests/fixtures/mvn_reactor_fail_slice_raw.txt b/tests/fixtures/mvn_reactor_fail_slice_raw.txt new file mode 100644 index 000000000..dae81e911 --- /dev/null +++ b/tests/fixtures/mvn_reactor_fail_slice_raw.txt @@ -0,0 +1,87 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[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] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol + symbol: class BogusType + location: class Empty +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.369 s] +[INFO] child-a ............................................ SUCCESS [ 1.657 s] +[INFO] child-b ............................................ FAILURE [ 0.940 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.193 s +[INFO] Finished at: 2026-05-25T12:11:41-03:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project child-b: Compilation failure +[ERROR] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol +[ERROR] symbol: class BogusType +[ERROR] location: class Empty +[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 -rf :child-b diff --git a/tests/fixtures/mvn_reactor_pass_slice_raw.txt b/tests/fixtures/mvn_reactor_pass_slice_raw.txt new file mode 100644 index 000000000..54c01c9ca --- /dev/null +++ b/tests/fixtures/mvn_reactor_pass_slice_raw.txt @@ -0,0 +1,79 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-b --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-b --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-b --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-b --- +[INFO] Installing /tmp/rtk-reactor/child-b/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-b/target/child-b-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.353 s] +[INFO] child-a ............................................ SUCCESS [ 1.676 s] +[INFO] child-b ............................................ SUCCESS [ 0.069 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.284 s +[INFO] Finished at: 2026-05-25T12:11:31-03:00 +[INFO] ------------------------------------------------------------------------ From be6c812416610f91025df2b49735c61c5f075fb3 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 13:56:00 -0300 Subject: [PATCH 09/19] feat(mvn): cap failing-class blocks and Failures summary entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter_surefire` / `filter_package` emitted unbounded output on builds with hundreds of failures — the per-class failing blocks (with their post-close exception trail) plus every `[ERROR] ClassA.test:N …` entry in the `[ERROR] Failures:` summary were all kept. Other RTK filters (git/cargo/lint) already cap with `... +N more` tails; bring mvn in line. Add `LimitsConfig::mvn_max_failures` (default 25, matching the `grep_max_per_file: 25` convention in the same struct; `0` = opt out) and wire it into two cap sites: 1. Failing-class blocks. The shared `SurefireBlock::step()` machine yields `FailingClose` once per failing class; the outer loop now commits the first N and calls a new `drop_failing()` for the rest. `drop_failing()` enables a `drop_trail` flag so the post-close per-test subline, exception, and user frames are consumed silently until the next blank line. After the loop, if any were dropped, `\n... +N more failing test classes\n` is appended. 2. `[ERROR] Failures:` summary block. New `FailuresSummaryCap` helper tracks the header, caps `[ERROR] ` entries at the same limit, and pre-emits `\n... +N more failures\n` immediately before the aggregate `[ERROR] Tests run: …` line. Tail wording follows the existing repo convention — no `[INFO]` prefix, no parenthetical hint (`cargo_cmd.rs:1035` uses the same `\n... +N more failures\n` verbatim). Tests: - `surefire_caps_failing_blocks_emits_tail` — synthetic 5-failure fixture with `cap = 3`; asserts blocks 1-3 emitted, 4-5 dropped, tail emitted. - `surefire_cap_zero_disables_capping` — same fixture with `cap = 0`; all 5 blocks kept, no tail. - `failures_summary_block_is_capped` — 5-entry summary with `cap = 3`; asserts entries 1-3 kept, 4-5 dropped, tail emitted *before* the aggregate. Behaviour-change note: the default cap of 25 means users without a custom config get a bounded output for the first time. Existing fixtures all have <25 failures so no current test diffs. --- src/cmds/jvm/README.md | 2 + src/cmds/jvm/mvn_cmd.rs | 323 +++++++++++++++++++++++++++++++++++++++- src/core/config.rs | 6 + 3 files changed, 328 insertions(+), 3 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 67706cefe..49a3dbcd8 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -26,6 +26,8 @@ Key behaviours: - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). - **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. +- **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 `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. 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. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 3e302f86b..15e9a8ca8 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -192,6 +192,10 @@ struct SurefireBlock { block_running: Option, in_block: bool, failure_trail: bool, + /// When set together with `failure_trail`, consumes the trail (per-test + /// `<<< FAILURE!` subline, exception, user frames) without writing it to + /// `out`. Used when the caller capped a failing block via `drop_failing`. + drop_trail: bool, } enum SurefireStep { @@ -216,6 +220,7 @@ impl SurefireBlock { block_running: None, in_block: false, failure_trail: false, + drop_trail: false, } } @@ -260,14 +265,20 @@ impl SurefireBlock { if self.failure_trail { if line.is_empty() { - out.push('\n'); + if !self.drop_trail { + out.push('\n'); + } self.failure_trail = false; + self.drop_trail = false; return SurefireStep::Consumed; } let t = line.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { return SurefireStep::Consumed; } + if self.drop_trail { + return SurefireStep::Consumed; + } out.push_str(line); out.push('\n'); return SurefireStep::Consumed; @@ -276,6 +287,15 @@ impl SurefireBlock { SurefireStep::Passthrough } + /// Mark a `FailingClose` as dropped (cap exceeded). The block itself is + /// already extracted by `step()`; this sets `failure_trail` so the + /// post-close trail (per-test subline, exception, user frames) is + /// consumed and silently dropped until the next blank line. + fn drop_failing(&mut self) { + self.failure_trail = true; + self.drop_trail = true; + } + /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with /// framework frames stripped), then `close`. Enables `failure_trail` so /// the post-close exception/user-frame trail is preserved. @@ -324,6 +344,91 @@ impl SurefireBlock { } } +/// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of +/// a failing test run: +/// +/// ```text +/// [ERROR] Failures: +/// [ERROR] ClassA.testFoo:25 expected: but was: +/// [ERROR] ClassB.testBar:42 expected: but was: +/// [INFO] +/// [ERROR] Tests run: 100, Failures: 50, Errors: 0, Skipped: 0 +/// ``` +/// +/// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the +/// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds +/// with hundreds of failures this can be quite large. Cap entries at +/// `mvn_max_failures` and emit `\n... +N more failures\n` immediately before +/// the `Tests run:` aggregate when entries were dropped. +struct FailuresSummaryCap { + cap: usize, + in_summary: bool, + emitted: usize, + dropped: usize, +} + +impl FailuresSummaryCap { + fn new(cap: usize) -> Self { + Self { + cap, + in_summary: false, + emitted: 0, + dropped: 0, + } + } + + /// 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] ") { + return false; + } + if self.cap == 0 || self.emitted < self.cap { + out.push_str(line); + out.push('\n'); + self.emitted += 1; + } else { + self.dropped += 1; + } + 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) { + if line.starts_with("[ERROR] Failures:") { + self.in_summary = true; + self.emitted = 0; + self.dropped = 0; + } + } + + /// Pre-emit the `... +N more failures` tail when the aggregate + /// `[ERROR] Tests run:` line is about to be written, then close the + /// 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) { + return; + } + if self.dropped > 0 { + out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + } + self.in_summary = false; + self.emitted = 0; + self.dropped = 0; + } + + /// 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 { + out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + } + } +} + /// Buffered single-pass filter for `mvn test` / `mvn integration-test`. /// /// Drives [`SurefireBlock`] for the inner block/trail machine; applies the @@ -334,6 +439,10 @@ impl SurefireBlock { /// 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, crate::core::config::limits().mvn_max_failures) +} + +fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -343,6 +452,9 @@ pub fn filter_surefire(raw: &str) -> String { let mut block = SurefireBlock::new(); let mut keep_continuation = false; 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) { @@ -352,7 +464,13 @@ pub fn filter_surefire(raw: &str) -> String { lines, close, } => { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + if cap == 0 || emitted_failing < cap { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } keep_continuation = false; continue; } @@ -365,10 +483,21 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + // 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) { + 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) { + // Pre-emit the summary tail when we're about to write AGG. + summary.handle_aggregate(line, &mut out); + // Detect summary header so subsequent `[ERROR] ` entries get capped. + summary.handle_header(line); out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -380,6 +509,13 @@ pub fn filter_surefire(raw: &str) -> String { } block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n... +{} more failing test classes\n", + dropped_failing + )); + } out } @@ -457,6 +593,10 @@ pub fn filter_compile(raw: &str) -> String { /// 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, crate::core::config::limits().mvn_max_failures) +} + +fn filter_package_with_cap(raw: &str, cap: usize) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -467,6 +607,9 @@ pub fn filter_package(raw: &str) -> String { let mut keep_continuation = false; let mut in_reactor_summary = false; 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) { @@ -476,18 +619,31 @@ pub fn filter_package(raw: &str) -> String { lines, close, } => { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + if cap == 0 || emitted_failing < cap { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } keep_continuation = false; continue; } SurefireStep::Passthrough => {} } + // Failures-summary cap (see filter_surefire_with_cap for details). + if summary.handle_entry(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); // 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); out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -516,6 +672,13 @@ pub fn filter_package(raw: &str) -> String { } block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n... +{} more failing test classes\n", + dropped_failing + )); + } out } @@ -1060,6 +1223,160 @@ mod tests { ); } + // ── Cap: failing-class blocks ──────────────────────────────────────────── + + /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect + /// the first 3 failing blocks emitted in full and a + /// `... +2 more failing test classes` tail. + #[test] + fn surefire_caps_failing_blocks_emits_tail() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + [ERROR] x.Fail{n}.bar -- Time elapsed: 0.0{n}0 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boom{n}\n\ + \tat x.Fail{n}.bar(Fail{n}.java:25)\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + + let o = filter_surefire_with_cap(&i, 3); + + // First 3 blocks emitted with their close lines. + for n in 1..=3 { + assert!( + o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} kept; got:\n{}", + o, + n = n + ); + assert!( + o.contains(&format!("in x.Fail{}", n)), + "Fail{n} close line kept; got:\n{}", + o, + n = n + ); + } + // Blocks 4 and 5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped; got:\n{}", + o, + n = n + ); + assert!( + !o.contains(&format!("AssertionFailedError: boom{}", n)), + "Fail{n} exception dropped; got:\n{}", + o, + n = n + ); + } + assert!( + o.contains("... +2 more failing test classes"), + "tail emitted; got:\n{}", + o + ); + } + + /// Cap of 0 means no cap — same fixture but with `cap = 0` should emit + /// all five blocks without any tail. + #[test] + fn surefire_cap_zero_disables_capping() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + let o = filter_surefire_with_cap(&i, 0); + for n in 1..=5 { + assert!( + o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} kept under cap=0; got:\n{}", + o, + n = n + ); + } + assert!( + !o.contains("more failing test classes"), + "no tail under cap=0; got:\n{}", + o + ); + } + + /// `[ERROR] Failures:` summary block cap: with N>cap entries, expect the + /// first `cap` entries plus a `\n... +(N-cap) more failures\n` tail + /// emitted before the aggregate `[ERROR] Tests run:` line. + #[test] + fn failures_summary_block_is_capped() { + let mut i = String::from( + "[INFO] -----< x >-----\n\ + [INFO] Results:\n\ + [INFO]\n\ + [ERROR] Failures:\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[ERROR] ClassA.test{n}:25 expected: but was: \n", + n = n + )); + } + i.push_str( + "[INFO]\n\ + [ERROR] Tests run: 100, Failures: 5, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n", + ); + let o = filter_surefire_with_cap(&i, 3); + + // First 3 entries kept. + for n in 1..=3 { + assert!( + o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} kept; got:\n{}", + o, + n = n + ); + } + // Entries 4-5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} dropped; got:\n{}", + o, + n = n + ); + } + // Tail emitted before aggregate. + let tail_idx = o + .find("... +2 more failures") + .unwrap_or_else(|| panic!("tail must appear; got:\n{}", o)); + let agg_idx = o + .find("[ERROR] Tests run: 100") + .unwrap_or_else(|| panic!("aggregate must appear; got:\n{}", o)); + assert!( + tail_idx < agg_idx, + "tail must precede aggregate; tail@{} agg@{}; got:\n{}", + tail_idx, + agg_idx, + o + ); + } + // ── Multi-module reactor summary ───────────────────────────────────────── /// `mvn install` on a multi-module reactor build that passes everywhere diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c..9bee5d032 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -131,6 +131,11 @@ pub struct LimitsConfig { pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, + /// Max failing Surefire test classes (and `[ERROR] Failures:` summary + /// entries) emitted by the Maven filter. Excess emissions are replaced + /// by a `... +N more failing test classes` / `... +N more failures` tail. + /// Set to `0` to opt out of the cap entirely. Default: 25. + pub mvn_max_failures: usize, } impl Default for LimitsConfig { @@ -141,6 +146,7 @@ impl Default for LimitsConfig { status_max_files: 15, status_max_untracked: 10, passthrough_max_chars: 2000, + mvn_max_failures: 25, } } } From 92a9218041cd10c60bc614a34aff590e61f023b2 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 14:06:51 -0300 Subject: [PATCH 10/19] =?UTF-8?q?refactor(mvn):=20polish=20=E2=80=94=20str?= =?UTF-8?q?ip=5Fprefix,=20borrow=20buffers,=20CRLF=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small reviewer-suggested cleanups: - `[WARNING] ` prefix slicing in `filter_compile` / `filter_package` was written defensively as `&line["[WARNING] ".len().min(line.len())..]`. Both call sites are already gated by `line.starts_with("[WARNING]")` so the `min` is dead defence. Use `strip_prefix(...).unwrap_or(line)` which reads identically to the rest of the module. - `SurefireBlock` and `SurefireStep::FailingClose` now borrow lines from the post-ANSI-strip `String` (`Vec<&'a str>` / `Option<&'a str>` / `&'a str` close), tied to the `stripped` local in `filter_surefire_with_cap` / `filter_package_with_cap`. No `.to_string()` allocations on the hot per-line path; the slice fixtures' savings tests are unaffected and the gzipped full-fixture tests still hit ≥90%/≥85%. - New `surefire_handles_crlf_line_endings` and `package_handles_crlf_line_endings` tests assert byte-equality between an LF-filtered fixture and a CRLF-converted-then-LF-normalised one. Catches the class of bug where exact-equality line checks silently miss CRLF input because `str::lines()` strips `\n` but keeps trailing `\r`. --- src/cmds/jvm/mvn_cmd.rs | 76 ++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 15e9a8ca8..885f84974 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -187,9 +187,9 @@ fn keep_outside_block(line: &str) -> bool { /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the /// buffered block and the trail; user-code frames are preserved. -struct SurefireBlock { - block_lines: Vec, - block_running: Option, +struct SurefireBlock<'a> { + block_lines: Vec<&'a str>, + block_running: Option<&'a str>, in_block: bool, failure_trail: bool, /// When set together with `failure_trail`, consumes the trail (per-test @@ -198,22 +198,22 @@ struct SurefireBlock { drop_trail: bool, } -enum SurefireStep { +enum SurefireStep<'a> { /// Inner machine consumed the line; outer loop should `continue;`. Consumed, /// A CLOSE line with `Failures > 0` or `Errors > 0` was reached. Outer /// loop decides whether to commit (via [`SurefireBlock::commit_failing`]). FailingClose { - running: Option, - lines: Vec, - close: String, + running: Option<&'a str>, + lines: Vec<&'a str>, + close: &'a str, }, /// Inner machine did not handle the line; outer loop applies its own /// outside-block keep logic. Passthrough, } -impl SurefireBlock { +impl<'a> SurefireBlock<'a> { fn new() -> Self { Self { block_lines: Vec::new(), @@ -224,7 +224,7 @@ impl SurefireBlock { } } - fn step(&mut self, line: &str, out: &mut String) -> SurefireStep { + fn step(&mut self, line: &'a str, out: &mut String) -> SurefireStep<'a> { if PLUGIN_BANNER.is_match(line) { return SurefireStep::Consumed; } @@ -234,7 +234,7 @@ impl SurefireBlock { self.flush_open_block_as_keep(out); } self.block_lines.clear(); - self.block_running = Some(line.to_string()); + self.block_running = Some(line); self.in_block = true; self.failure_trail = false; return SurefireStep::Consumed; @@ -251,7 +251,7 @@ impl SurefireBlock { return SurefireStep::FailingClose { running, lines, - close: line.to_string(), + close: line, }; } self.block_lines.clear(); @@ -259,7 +259,7 @@ impl SurefireBlock { self.in_block = false; return SurefireStep::Consumed; } - self.block_lines.push(line.to_string()); + self.block_lines.push(line); return SurefireStep::Consumed; } @@ -303,7 +303,7 @@ impl SurefireBlock { &mut self, out: &mut String, running: Option<&str>, - lines: &[String], + lines: &[&str], close: &str, ) { if let Some(r) = running { @@ -333,11 +333,11 @@ impl SurefireBlock { fn flush_open_block_as_keep(&mut self, out: &mut String) { if let Some(r) = self.block_running.take() { - out.push_str(&r); + out.push_str(r); out.push('\n'); } for l in self.block_lines.drain(..) { - out.push_str(&l); + out.push_str(l); out.push('\n'); } self.in_block = false; @@ -465,7 +465,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { close, } => { if cap == 0 || emitted_failing < cap { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { block.drop_failing(); @@ -567,9 +567,8 @@ pub fn filter_compile(raw: &str) -> String { continue; } if line.starts_with("[WARNING]") { - let norm = FILE_COORD - .replace_all(&line["[WARNING] ".len().min(line.len())..], "") - .to_string(); + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); @@ -620,7 +619,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { close, } => { if cap == 0 || emitted_failing < cap { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { block.drop_failing(); @@ -658,9 +657,8 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { continue; } if line.starts_with("[WARNING]") { - let norm = FILE_COORD - .replace_all(&line["[WARNING] ".len().min(line.len())..], "") - .to_string(); + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); @@ -1223,6 +1221,38 @@ mod tests { ); } + // ── CRLF line-ending compatibility ─────────────────────────────────────── + + /// `str::lines()` splits on `\n` but keeps any trailing `\r`, so exact + /// equality / `starts_with("…")` checks can silently fail under Windows + /// line endings. Convert an LF fixture to CRLF, filter both, normalise + /// the CRLF output back to LF, and assert byte-equality. + #[test] + fn surefire_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o_lf = filter_surefire(i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_surefire(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + + #[test] + fn package_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o_lf = filter_package(i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_package(&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 f58333caa502913a3cc7e1bb7d9211c41c6e1598 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 14:07:40 -0300 Subject: [PATCH 11/19] chore(mvn): drop unrelated Cargo.lock churn from PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `develop`'s Cargo.lock has its rtk-package entry pinned at `0.36.0` while `Cargo.toml` says `0.34.3` — the lock is internally stale on develop. When `cargo build` ran on this branch it re-pinned the lock to `0.34.3` to match `Cargo.toml`, producing the diff the reviewer flagged on PR #1956. Revert the lock to `develop`'s baseline so the PR's committed diff no longer touches Cargo.lock. `cargo build` will rewrite the working-tree lock on every invocation; that's a local artifact, not part of the committed PR. --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 79fa30e31..b7796e6d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.34.3" +version = "0.36.0" dependencies = [ "anyhow", "automod", From ad2bfd3225587ab2487f122868e4ea6e5facea7d Mon Sep 17 00:00:00 2001 From: Bartlomiej Kaluza Date: Thu, 28 May 2026 11:29:22 +0200 Subject: [PATCH 12/19] fix(aws): preserve values in JSON output for unsupported subcommands The generic AWS handler (used for subcommands outside the hardcoded specialized list) called json_cmd::filter_json_string, which extracts a type-only schema and discards every value. $ rtk aws backup describe-global-settings --output json { GlobalSettings: { isCrossAccountBackupEnabled: string, isDelegatedAdministratorEnabled: string, isMpaEnabled: string } LastUpdateTime: string } Callers got the schema instead of the data they asked for. Swap to json_cmd::filter_json_compact, which preserves values while still applying depth, string-length, and array-size truncation: $ rtk aws backup describe-global-settings --output json { GlobalSettings: { isCrossAccountBackupEnabled: "false", isDelegatedAdministratorEnabled: "false", isMpaEnabled: "false" } LastUpdateTime: "2026-05-28T09:52:17.525000+02:00" } Affects every AWS subcommand not on the hardcoded list (backup, route53, kms, ssm, apigateway, ...) when output is valid JSON (explicit `--output json` or auto-injected for describe-/list-/get-/scan). --- src/cmds/cloud/aws_cmd.rs | 35 ++++++++++++++++--- .../aws_backup_describe_global_settings.json | 8 +++++ 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/aws_backup_describe_global_settings.json diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 64c18cf7c..833d2fe7b 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -215,7 +215,7 @@ fn is_structured_operation(args: &[String]) -> bool { || op == "receive-message" } -/// Generic strategy: force --output json for structured ops, compress via json_cmd schema +/// Generic strategy: force --output json for structured ops, compress via json_cmd compact (values preserved) fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { let timer = tracking::TimedExecution::start(); @@ -256,10 +256,10 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - return Ok(crate::core::utils::exit_code_from_output(&output, "aws")); } - let filtered = match json_cmd::filter_json_string(&raw, JSON_COMPRESS_DEPTH) { - Ok(schema) => { - println!("{}", schema); - schema + let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) { + Ok(compact) => { + println!("{}", compact); + compact } Err(_) => { // Fallback: print raw (maybe not JSON) @@ -2749,4 +2749,29 @@ upload: file10.txt to s3://bucket/file10.txt // Should report all 30 failures, not capped at MAX_ITEMS (20) assert!(result.text.contains("30 failed")); } + + // Regression: generic AWS path (unsupported subcommand returning JSON) must + // compress responses while preserving values, not collapse them to schema + // type names. Calls the primitive used at aws_cmd.rs run_generic line 259. + #[test] + fn test_aws_unsupported_subcommand_json_preserves_values() { + let fixture = include_str!( + "../../../tests/fixtures/aws_backup_describe_global_settings.json" + ); + let output = json_cmd::filter_json_compact(fixture, JSON_COMPRESS_DEPTH) + .expect("filter_json_compact must not error on valid AWS JSON"); + + assert!( + output.contains("\"false\""), + "values must be preserved (expected literal \"false\"), got:\n{output}" + ); + assert!( + !output.contains(": string"), + "schema-type leakage detected (\": string\" found), got:\n{output}" + ); + assert!( + output.contains("isMpaEnabled"), + "object keys must be preserved, got:\n{output}" + ); + } } diff --git a/tests/fixtures/aws_backup_describe_global_settings.json b/tests/fixtures/aws_backup_describe_global_settings.json new file mode 100644 index 000000000..8a521c647 --- /dev/null +++ b/tests/fixtures/aws_backup_describe_global_settings.json @@ -0,0 +1,8 @@ +{ + "GlobalSettings": { + "isCrossAccountBackupEnabled": "false", + "isDelegatedAdministratorEnabled": "false", + "isMpaEnabled": "false" + }, + "LastUpdateTime": "2026-05-28T09:52:17.525000+02:00" +} From a2a63e15f4d34f5de35eb7dc32eadb0b02f77327 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 31 May 2026 11:06:53 +0200 Subject: [PATCH 13/19] fix(curl): passthrough binary downloads to prevent UTF-8 corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rtk curl ` silently corrupted any binary download (tarballs, zip, png, pdf, ELF, ...) because the response body was captured via `String::from_utf8_lossy` in `core::stream::exec_capture`. Every non-UTF-8 byte got replaced with U+FFFD (3 bytes: 0xEF 0xBF 0xBD), so gzip magic 1f 8b 08 00 arrived at downstream consumers as 1f ef bf bd 08 00 and `tar -xzf` complained "not in gzip format". Now `rtk curl` runs `Command::output()` directly to keep stdout as `Vec`, then checks `std::str::from_utf8(&bytes).is_err()`. If the response is not valid UTF-8 (i.e. the lossy conversion would corrupt it), raw bytes are written through to stdout via `write_all` and tracking is recorded as passthrough (0% savings — token counts over binary content have no meaning). The text/JSON code path is unchanged. Verified live on 18 real binary formats (rtk's own release artifacts, ripgrep, bat, fd, hyperfine, gh, tokei, kubectl ELF 51MB, rustup-init 20MB, W3C PDF, ISO 9899 PDF 4MB, GitHub avatar PNG, Wikimedia GIF, WebP sample, MP3/MP4/WAV samples) — all byte-identical to raw curl. 10 text regression tests (JSON/HTML/Markdown/Cargo.toml/RFC) confirm the text path keeps its existing behavior. Closes #1087 --- src/cmds/cloud/curl_cmd.rs | 91 +++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index ed5b04a86..7073c2e42 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -5,13 +5,18 @@ //! The condensed-form-with-tee-hint path is reserved for non-JSON bodies on //! a real terminal where a human reads the output and the tee file gives the //! LLM a way to recover the raw response. +//! +//! Binary downloads (any non-UTF-8 byte sequence) are written through to +//! stdout as raw bytes, bypassing the UTF-8 lossy conversion that would +//! otherwise replace non-UTF-8 bytes with U+FFFD and corrupt the stream +//! (`#1087`). use crate::core::tee::force_tee_hint; use crate::core::tracking; -use crate::core::{stream::exec_capture, utils::resolved_command}; +use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use std::borrow::Cow; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; const MAX_RESPONSE_SIZE: usize = 500; @@ -28,22 +33,46 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: curl -s {}", args.join(" ")); } - let result = exec_capture(&mut cmd).context("Failed to run curl")?; + // Capture stdout as raw bytes (not UTF-8 String) so binary downloads + // survive intact. `String::from_utf8_lossy` would otherwise replace + // every non-UTF-8 byte with U+FFFD (3 bytes), corrupting e.g. gzip + // magic `1f 8b 08 00` into `1f ef bf bd 08 00` (#1087). + let output = cmd.output().context("Failed to run curl")?; + let exit_code = output.status.code().unwrap_or(1); // Skip filtering on failure: curl can return HTML error bodies that would // be misleading to summarize, and we want the real exit code surfaced. - if !result.success() { - let msg = if result.stderr.trim().is_empty() { - result.stdout.trim().to_string() + if !output.status.success() { + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + let msg = if stderr_str.trim().is_empty() { + stdout_str.trim().to_string() } else { - result.stderr.trim().to_string() + stderr_str.trim().to_string() }; eprintln!("FAILED: curl {}", msg); - return Ok(result.exit_code); + return Ok(exit_code); } - let exit_code = result.exit_code; - let raw = result.stdout; + // Binary detection: if the body is not valid UTF-8, `from_utf8_lossy` + // would replace every invalid byte with U+FFFD and corrupt the stream + // (gzip, zip, png, pdf, elf, ... — any binary format). Write raw bytes + // through and skip filtering. Tracking is recorded as passthrough + // (0% savings) since token counts over binary content have no meaning. + if is_binary(&output.stdout) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle + .write_all(&output.stdout) + .context("Failed to write binary response to stdout")?; + timer.track_passthrough( + &format!("curl {}", args.join(" ")), + &format!("rtk curl {}", args.join(" ")), + ); + return Ok(exit_code); + } + + let raw = String::from_utf8_lossy(&output.stdout).into_owned(); let is_tty = std::io::stdout().is_terminal(); let filtered = filter_curl_output(&raw, is_tty); @@ -62,6 +91,17 @@ pub fn run(args: &[String], verbose: u8) -> Result { Ok(exit_code) } +/// Returns `true` if `bytes` is not valid UTF-8 — which is exactly the +/// condition under which `from_utf8_lossy` would replace invalid bytes +/// with U+FFFD and corrupt downstream consumers (`#1087`). +/// +/// This is correct by construction: the only reason to passthrough raw +/// bytes is to avoid the lossy conversion, and the only bytes that suffer +/// from it are the non-UTF-8 ones. +fn is_binary(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_err() +} + fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> { let trimmed = raw.trim(); @@ -239,4 +279,35 @@ mod tests { let json_result = filter_curl_output(&json_payload, true); assert!(matches!(json_result.content, Cow::Borrowed(_))); } + + // --- is_binary tests ---------------------------------------------------- + + #[test] + fn test_is_binary_gzip_magic_is_not_utf8() { + // gzip magic 1f 8b — 0x8b is an invalid UTF-8 continuation byte + let bytes = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]; + assert!(is_binary(&bytes)); + } + + #[test] + fn test_is_binary_valid_utf8_text_is_not_binary() { + assert!(!is_binary(br#"{"key": "value"}"#)); + assert!(!is_binary(b"\nHi")); + assert!(!is_binary(b"Plain ASCII text")); + assert!(!is_binary("Héllo wörld — emojis 🚀 ✓".as_bytes())); + } + + #[test] + fn test_is_binary_empty_is_not_binary() { + // Empty input is technically valid UTF-8 and trivially safe to filter. + assert!(!is_binary(&[])); + } + + #[test] + fn test_is_binary_text_with_nul_is_not_binary() { + // NUL is valid UTF-8 (U+0000). Unusual in HTTP responses but the + // function honors UTF-8 strictly — caller can still filter such + // content safely. The bug we're fixing is only invalid UTF-8 bytes. + assert!(!is_binary(b"text with\0embedded nul")); + } } From 16d6599b4c941bb182078c51508ddc1f11225d97 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 17:41:53 +0200 Subject: [PATCH 14/19] refacto(cmds): strip decorator noise from filter output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separator lines (═══, ---) and an emoji status marker cost tokens without adding signal for the LLM — RTK output must never add noise over raw. Semantic labels are kept; the emoji is swapped for plain monochrome unicode. --- src/cmds/cloud/wget_cmd.rs | 2 +- src/cmds/dotnet/dotnet_cmd.rs | 34 +++------------------------------- src/cmds/git/git.rs | 6 +++--- src/cmds/go/go_cmd.rs | 3 --- src/cmds/go/golangci_cmd.rs | 1 - src/cmds/js/lint_cmd.rs | 3 --- src/cmds/js/next_cmd.rs | 1 - src/cmds/js/prettier_cmd.rs | 1 - src/cmds/js/prisma_cmd.rs | 1 - src/cmds/js/tsc_cmd.rs | 3 +-- src/cmds/python/mypy_cmd.rs | 1 - src/cmds/python/pip_cmd.rs | 2 -- src/cmds/python/pytest_cmd.rs | 1 - src/cmds/python/ruff_cmd.rs | 2 -- src/cmds/ruby/rspec_cmd.rs | 24 +++++++++++------------- src/cmds/rust/cargo_cmd.rs | 7 ++----- src/cmds/system/format_cmd.rs | 1 - 17 files changed, 21 insertions(+), 72 deletions(-) diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 4faed8081..09e52f24b 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -78,7 +78,7 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { total, format_size(result.stdout.len() as u64) )); - rtk_output.push_str("--- first 10 lines ---\n"); + rtk_output.push_str("first 10 lines:\n"); for line in lines.iter().take(10) { rtk_output.push_str(&format!("{}\n", truncate_line(line, 100))); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index d16f8bc6f..f90db8b02 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -368,7 +368,6 @@ fn format_dotnet_format_output( } let mut output = format!("Format: {} files need formatting", changed_count); - output.push_str("\n---------------------------------------"); const MAX_FORMAT_FILES: usize = CAP_LIST; for (index, file) in summary @@ -1056,12 +1055,6 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S } } - let sep = if !warnings.is_empty() || !errors.is_empty() { - "---------------------------------------" - } else { - "" - }; - let verdict = format!( "{} dotnet build: {} projects, {} errors, {} warnings ({})", status_icon, @@ -1076,7 +1069,7 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S // definitive verdict. Mirrors native `dotnet build`, which ends with // `Build succeeded.` / `Build FAILED.`. See issue #1574. // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [warnings, errors, sep.into(), verdict] + [warnings, errors, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1218,24 +1211,9 @@ fn format_test_output( } } - let sep = if !failed_tests_section.is_empty() - || !warnings_section.is_empty() - || !errors_section.is_empty() - { - "---------------------------------------" - } else { - "" - }; - // Status line emitted last; see format_build_output (issue #1574). // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [ - failed_tests_section, - warnings_section, - errors_section, - sep.into(), - header, - ] + [failed_tests_section, warnings_section, errors_section, header] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1311,12 +1289,6 @@ fn format_restore_output( } } - let sep = if !warnings_section.is_empty() || !errors_section.is_empty() { - "---------------------------------------" - } else { - "" - }; - let verdict = format!( "{} dotnet restore: {} projects, {} errors, {} warnings ({})", status_icon, summary.restored_projects, summary.errors, summary.warnings, duration @@ -1324,7 +1296,7 @@ fn format_restore_output( // Status line emitted last; see format_build_output (issue #1574). // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [warnings_section, errors_section, sep.into(), verdict] + [warnings_section, errors_section, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a77b0ba76..3f2beea75 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -251,10 +251,10 @@ fn run_diff( let mut final_output = result.stdout.clone(); if !diff_result.stdout.is_empty() { - println!("\n--- Changes ---"); + println!("\nChanges:"); let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500)); println!("{}", compacted); - final_output.push_str("\n--- Changes ---\n"); + final_output.push_str("\nChanges:\n"); final_output.push_str(&compacted); } @@ -363,7 +363,7 @@ fn run_show( let mut final_output = summary_result.stdout.clone(); if !diff_text.is_empty() { if verbose > 0 { - println!("\n--- Changes ---"); + println!("\nChanges:"); } let compacted = compact_diff(diff_text, max_lines.unwrap_or(500)); println!("{}", compacted); diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index e962e3e1c..3adfd6434 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -431,7 +431,6 @@ pub(crate) fn filter_go_test_json(output: &str) -> String { result.push_str(&format!(", {} skipped", total_skip)); } result.push_str(&format!(" in {} packages\n", total_packages)); - result.push_str("═══════════════════════════════════════\n"); // Show package-level failures first (timeouts, signals, panics). // Skip packages that already have individual test-level failures — those are displayed @@ -586,7 +585,6 @@ pub(crate) fn filter_go_build(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Go build: {} errors\n", errors.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { @@ -678,7 +676,6 @@ fn filter_go_vet(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Go vet: {} issues\n", issues.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_VET_ISSUES: usize = CAP_ERRORS; for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 8c9ef8427..865b9b8b0 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -310,7 +310,6 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { "golangci-lint: {} issues in {} files\n", total_issues, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top linters let mut linter_counts: Vec<_> = by_linter.iter().collect(); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 13fa95464..afc3e7962 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -268,7 +268,6 @@ fn filter_eslint_json(output: &str) -> String { "ESLint: {} errors, {} warnings in {} files\n", total_errors, total_warnings, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -394,7 +393,6 @@ fn filter_pylint_json(output: &str) -> String { result.push('\n'); } - result.push_str("═══════════════════════════════════════\n"); // Show top symbols (rules) let mut symbol_counts: Vec<_> = by_symbol.iter().collect(); @@ -471,7 +469,6 @@ fn filter_generic_lint(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); - result.push_str("═══════════════════════════════════════\n"); const MAX_ISSUES: usize = CAP_ERRORS; for issue in issues.iter().take(MAX_ISSUES) { diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index 1f15a2af0..fd359f0a0 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -115,7 +115,6 @@ fn filter_next_build(output: &str) -> String { // Build filtered output let mut result = String::new(); result.push_str("Next.js Build\n"); - result.push_str("═══════════════════════════════════════\n"); if already_built && routes_total == 0 { result.push_str("Already built (using cache)\n\n"); diff --git a/src/cmds/js/prettier_cmd.rs b/src/cmds/js/prettier_cmd.rs index 685b2f51d..54ef74064 100644 --- a/src/cmds/js/prettier_cmd.rs +++ b/src/cmds/js/prettier_cmd.rs @@ -94,7 +94,6 @@ pub fn filter_prettier_output(output: &str) -> String { "Prettier: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_PRETTIER_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() { diff --git a/src/cmds/js/prisma_cmd.rs b/src/cmds/js/prisma_cmd.rs index 397236907..2f7231382 100644 --- a/src/cmds/js/prisma_cmd.rs +++ b/src/cmds/js/prisma_cmd.rs @@ -277,7 +277,6 @@ fn filter_migrate_dev(output: &str) -> String { if !migration_name.is_empty() { result.push_str(&format!("Migration: {}\n", migration_name)); - result.push_str("═══════════════════════════════════════\n"); } result.push_str("Changes:\n"); diff --git a/src/cmds/js/tsc_cmd.rs b/src/cmds/js/tsc_cmd.rs index 0b75fc9f6..96a494cd8 100644 --- a/src/cmds/js/tsc_cmd.rs +++ b/src/cmds/js/tsc_cmd.rs @@ -84,7 +84,7 @@ impl BlockHandler for TscHandler { } let mut result = format!( - "═══════════════════════════════════════\nTypeScript: {} errors in {} files\n", + "TypeScript: {} errors in {} files\n", self.error_count, self.files.len() ); @@ -174,7 +174,6 @@ pub(crate) fn filter_tsc_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (compact, one line) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/python/mypy_cmd.rs b/src/cmds/python/mypy_cmd.rs index 69bc7173a..7c26cc9d4 100644 --- a/src/cmds/python/mypy_cmd.rs +++ b/src/cmds/python/mypy_cmd.rs @@ -164,7 +164,6 @@ pub fn filter_mypy_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (only when 2+ distinct codes) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 037dd58a4..9a4d00271 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -153,7 +153,6 @@ fn filter_pip_list(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip list: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); // Group by first letter for easier scanning let mut by_letter: std::collections::HashMap> = @@ -203,7 +202,6 @@ fn filter_pip_outdated(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip outdated: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_PIP_PACKAGES: usize = CAP_LIST; for (i, pkg) in packages.iter().take(MAX_PIP_PACKAGES).enumerate() { diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 2febb2ea1..87eca2ec9 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -202,7 +202,6 @@ fn build_pytest_summary( result.push_str(&format!(", {} xpassed", xpassed)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Surface xfail/xpass entries (with their reasons) — XPASS in particular // signals that something expected-to-fail now passes. diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 3f240df13..f8a512035 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -144,7 +144,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!(" ({} fixable)", fixable_count)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -288,7 +287,6 @@ pub fn filter_ruff_format(output: &str) -> String { "Ruff format: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_RUFF_FORMAT_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index 80fbe1c10..bf907c37f 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -214,7 +214,6 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { result.push_str(&format!(", {} pending", s.pending_count)); } result.push_str(&format!(" ({:.2}s)\n", s.duration)); - result.push_str("═══════════════════════════════════════\n"); let failures: Vec<&RspecExample> = rspec .examples @@ -230,7 +229,7 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { for (i, example) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!( - "{}. ❌ {}\n {}:{}\n", + "{}. ✗ {}\n {}:{}\n", i + 1, example.full_description, example.file_path, @@ -353,9 +352,8 @@ fn filter_rspec_text(output: &str) -> String { return format!("RSpec: {}", summary_line); } let mut result = format!("RSpec: {}\n", summary_line); - result.push_str("═══════════════════════════════════════\n\n"); for (i, failure) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { - result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); + result.push_str(&format!("{}. ✗ {}\n", i + 1, failure)); if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } @@ -596,7 +594,7 @@ mod tests { fn test_filter_rspec_with_failures() { let result = filter_rspec_output(with_failures_json()); assert!(result.contains("1 passed, 1 failed")); - assert!(result.contains("❌ User saves to database")); + assert!(result.contains("✗ User saves to database")); assert!(result.contains("user_spec.rb:10")); assert!(result.contains("ExpectationNotMetError")); assert!(result.contains("expected true but got false")); @@ -672,7 +670,7 @@ Failures: let result = filter_rspec_output(text); assert!(result.contains("RSpec:")); assert!(result.contains("4 examples, 1 failure")); - assert!(result.contains("❌"), "should show failure marker"); + assert!(result.contains("✗"), "should show failure marker"); } #[test] @@ -698,7 +696,7 @@ Failures: "#; let result = filter_rspec_text(text); assert!(result.contains("2 failures")); - assert!(result.contains("❌")); + assert!(result.contains("✗")); // Should show spec file path, not gem backtrace assert!(result.contains("spec/models/user_spec.rb:15")); } @@ -741,9 +739,9 @@ Failures: "summary_line": "6 examples, 6 failures" }"#; let result = filter_rspec_output(json); - assert!(result.contains("1. ❌"), "should show first failure"); - assert!(result.contains("5. ❌"), "should show fifth failure"); - assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!(result.contains("1. ✗"), "should show first failure"); + assert!(result.contains("5. ✗"), "should show fifth failure"); + assert!(!result.contains("6. ✗"), "should not show sixth inline"); assert!( result.contains("+1 more"), "should show overflow count: {}", @@ -946,9 +944,9 @@ Failures: 14 examples, 7 failures "#; let result = filter_rspec_text(text); - assert!(result.contains("1. ❌"), "should show first failure"); - assert!(result.contains("5. ❌"), "should show fifth failure"); - assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!(result.contains("1. ✗"), "should show first failure"); + assert!(result.contains("5. ✗"), "should show fifth failure"); + assert!(!result.contains("6. ✗"), "should not show sixth inline"); assert!( result.contains("+2 more"), "should show overflow count: {}", diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 5b8152ce0..db3aa0a3c 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -140,7 +140,7 @@ impl BlockHandler for CargoBuildHandler { Some(format!("{}\n", s)) } else { Some(format!( - "═══════════════════════════════════════\ncargo build: {} errors, {} warnings ({} crates)\n", + "cargo build: {} errors, {} warnings ({} crates)\n", self.error_count, self.warnings, self.compiled )) } @@ -526,7 +526,6 @@ fn filter_cargo_install(output: &str) -> String { deps_info )); } - result.push_str("═══════════════════════════════════════\n"); const MAX_INSTALL_ERRORS: usize = CAP_ERRORS; for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) { @@ -834,7 +833,7 @@ fn filter_cargo_build(output: &str) -> String { } let mut result = format!( - "cargo build: {} errors, {} warnings ({} crates)\n═══════════════════════════════════════\n", + "cargo build: {} errors, {} warnings ({} crates)\n", handler.error_count, handler.warnings, handler.compiled ); const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; @@ -1049,7 +1048,6 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_FAILURES: usize = CAP_WARNINGS; for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); @@ -1206,7 +1204,6 @@ fn filter_cargo_clippy(output: &str) -> String { "cargo clippy: {} errors, {} warnings\n", error_count, warning_count )); - result.push_str("═══════════════════════════════════════\n"); // Show full error blocks so developers can see what needs fixing if !error_blocks.is_empty() { diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index c71dd1072..431b25c78 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -235,7 +235,6 @@ fn filter_black_output(output: &str) -> String { "Format (black): {} files need formatting\n", count )); - result.push_str("═══════════════════════════════════════\n"); if !files_to_format.is_empty() { const MAX_FORMAT_FILES: usize = CAP_WARNINGS; From 1050cfeadcc3fd2b34df3401c6ec3aa09f0cd199 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:05:05 -0300 Subject: [PATCH 15/19] fix(mvn): re-arm failure trail on per-test sublines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surefire 3.x emits one blank-separated detail block per failing test under a single class close line. The trail previously ended at the first blank line and never re-armed, so failures after the first lost their exception message (unindented -> dropped) and leaked the full junit/jdk framework stack (indented -> kept by keep_continuation). SurefireBlock now remembers the trail's keep/drop decision when it ends at a blank line (trail_rearm) and re-enters the trail on the next per-test subline with the same decision — a capped class drops all its per-test blocks, not just the first. Any other non-blank line disarms re-entry; RUNNING/commit/drop clear it so an unrelated class can never inherit the decision. Extra blanks between per-test blocks stay armed. Per-test sublines use '<<< ERROR!' for thrown (non-assertion) exceptions — accept both markers via a shared is_per_test_subline() (also reused by filter_quiet, which already re-armed correctly), and widen the CLOSE regex to tolerate an ERROR! marker defensively (Surefire 3.5.5 emits FAILURE! even for errors-only classes, per the fixture capture; detection keys off the counts, not the marker). Fixture captured from real Maven 3.9.9 / Surefire 3.5.5 output against the new tests/fixtures/multifail-skeleton/ (CalcTest: assertion failure + thrown exception in one class; BoomTest: errors-only class). A byte-exact pin locks the single-failure path unchanged. --- src/cmds/jvm/README.md | 2 + src/cmds/jvm/mvn_cmd.rs | 309 +++++++++++++++++- tests/fixtures/multifail-skeleton/pom.xml | 30 ++ .../src/main/java/com/example/rtk/Calc.java | 7 + .../test/java/com/example/rtk/BoomTest.java | 10 + .../test/java/com/example/rtk/CalcTest.java | 24 ++ .../fixtures/mvn_test_multifail_slice_raw.txt | 70 ++++ 7 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/multifail-skeleton/pom.xml create mode 100644 tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java create mode 100644 tests/fixtures/mvn_test_multifail_slice_raw.txt diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 49a3dbcd8..b36d7d0ea 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -24,6 +24,8 @@ Key behaviours: - **English-footer guard** — if neither `BUILD SUCCESS` nor `BUILD FAILURE` appears as a trimmed line suffix, return the ANSI-stripped raw input unchanged. Protects non-English locales. - **Verbose bypass** — `-X`, `--debug`, `-e`, `--errors` skip filtering (`run_passthrough`). User asked for detail; respect it. - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). +- **Multi-failure classes (trail re-arm)** — when a single class has several failing tests, Surefire 3.x emits one blank-separated detail block per failing test under a single close line. When a failure trail ends at a blank line, the state machine arms a re-entry: the next per-test subline (`[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` or `<<< ERROR!`) re-enters the trail with the same keep/drop decision, so every failure message survives (and a capped class drops *all* its blocks). Any other non-blank line disarms the re-entry. +- **`<<< 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). - **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **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 885f84974..84fbd2123 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -22,11 +22,15 @@ lazy_static! { static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. - /// Tolerates the optional `<<< FAILURE!` marker. Separator is `-` (Surefire 2.x) - /// or `--` (Surefire 3.x). Prefix INFO/ERROR/WARNING (3.x emits WARNING for - /// classes with only skipped tests). + /// Tolerates the optional `<<< FAILURE!` / `<<< ERROR!` marker (3.5.5 emits + /// `<<< FAILURE!` even for errors-only classes — see + /// `mvn_test_multifail_slice_raw.txt`; `ERROR!` accepted defensively for + /// other Surefire versions; failure detection is via the captured counts, + /// not the marker). Separator is `-` (Surefire 2.x) or `--` (Surefire 3.x). + /// Prefix INFO/ERROR/WARNING (3.x emits WARNING for classes with only + /// skipped tests). static ref CLOSE: Regex = Regex::new( - r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)?\s+--?\s+in (.+)$" + r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*(?:FAILURE|ERROR)!)?\s+--?\s+in (.+)$" ).unwrap(); /// Final BUILD footer. @@ -117,6 +121,18 @@ fn is_framework_frame(trimmed: &str) -> bool { .any(|p| trimmed.starts_with(p)) } +/// `[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 +/// CLOSE-shaped line outside a block would match too — acceptable: the +/// disarm-on-take guard limits the effect to one stray line. +/// Note: the `[ERROR] Class.test:25 …` failures-summary entries (3-space +/// indent, no `<<<` marker) do NOT match. +fn is_per_test_subline(line: &str) -> bool { + line.starts_with("[ERROR] ") + && (line.contains("<<< FAILURE!") || line.contains("<<< ERROR!")) +} + // ── English-footer guard ──────────────────────────────────────────────────── fn has_english_footer(stripped: &str) -> bool { @@ -187,6 +203,13 @@ fn keep_outside_block(line: &str) -> bool { /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the /// buffered block and the trail; user-code frames are preserved. +/// - multi-failure classes: Surefire 3.x emits one blank-separated detail +/// block per failing test under a single CLOSE line. When a trail ends at +/// a blank line, `trail_rearm` remembers the keep/drop decision so the +/// next per-test subline re-enters the trail with the same decision. +/// End-of-input with `trail_rearm` still `Some` is harmless (nothing +/// pending in `out`); `finish()` / `flush_open_block_as_keep` need no +/// special handling. struct SurefireBlock<'a> { block_lines: Vec<&'a str>, block_running: Option<&'a str>, @@ -196,6 +219,12 @@ struct SurefireBlock<'a> { /// `<<< FAILURE!` subline, exception, user frames) without writing it to /// `out`. Used when the caller capped a failing block via `drop_failing`. drop_trail: bool, + /// 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`. + trail_rearm: Option, } enum SurefireStep<'a> { @@ -221,6 +250,7 @@ impl<'a> SurefireBlock<'a> { in_block: false, failure_trail: false, drop_trail: false, + trail_rearm: None, } } @@ -237,6 +267,9 @@ impl<'a> SurefireBlock<'a> { self.block_running = Some(line); self.in_block = true; self.failure_trail = false; + // Load-bearing: a capped multi-failure class followed by a kept + // class must not re-arm into the new class's trail decision. + self.trail_rearm = None; return SurefireStep::Consumed; } @@ -268,6 +301,9 @@ impl<'a> SurefireBlock<'a> { if !self.drop_trail { out.push('\n'); } + // Arm re-entry: a following per-test subline belongs to the + // same class and must inherit this trail's keep/drop decision. + self.trail_rearm = Some(self.drop_trail); self.failure_trail = false; self.drop_trail = false; return SurefireStep::Consumed; @@ -284,6 +320,25 @@ impl<'a> SurefireBlock<'a> { return SurefireStep::Consumed; } + if let Some(dropped) = self.trail_rearm { + if line.is_empty() { + // 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) { + self.failure_trail = true; + self.drop_trail = dropped; + if !dropped { + out.push_str(line); + out.push('\n'); + } + return SurefireStep::Consumed; + } + // Non-subline: trail is over; already disarmed — fall through. + } + SurefireStep::Passthrough } @@ -294,6 +349,9 @@ impl<'a> SurefireBlock<'a> { fn drop_failing(&mut self) { self.failure_trail = true; self.drop_trail = true; + // Belt-and-suspenders: a CLOSE can only follow a RUNNING (which + // already cleared `trail_rearm`), but keep the invariant local too. + self.trail_rearm = None; } /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with @@ -321,6 +379,8 @@ impl<'a> SurefireBlock<'a> { out.push_str(close); out.push('\n'); self.failure_trail = true; + // Belt-and-suspenders: see `drop_failing`. + self.trail_rearm = None; } /// End-of-stream flush: if a block opened and never closed (truncated @@ -722,12 +782,14 @@ pub fn filter_quiet(raw: &str) -> String { if CLOSE.is_match(line) { out.push_str(line); out.push('\n'); - failure_trail = line.contains("<<< FAILURE!"); + failure_trail = + line.contains("<<< FAILURE!") || line.contains("<<< ERROR!"); continue; } - // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!`. - if line.starts_with("[ERROR] ") && line.contains("<<< FAILURE!") { + // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` + // (or `<<< ERROR!` for thrown exceptions). + if is_per_test_subline(line) { out.push_str(line); out.push('\n'); failure_trail = true; @@ -1116,6 +1178,238 @@ mod tests { ); } + // ── Multi-failure class (trail re-arm) ────────────────────────────────── + + /// Surefire 3.x emits one blank-separated detail block per failing test + /// under a single CLOSE line. All per-test exception messages must survive + /// (not just the first), framework frames must stay stripped throughout. + /// Real fixture: `CalcTest` (1 failure + 1 error) + `BoomTest` (errors-only). + #[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); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure (ERROR! subline) message preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failOne(CalcTest.java:12)"), + "first user frame preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failTwo(CalcTest.java:17)"), + "second user frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// Same multi-failure fixture through `filter_package` (drift guard — + /// the install/verify route shares `SurefireBlock` and must not diverge). + #[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); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure message preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// A capped (dropped) multi-failure class must drop **all** its per-test + /// blocks — the re-arm inherits the drop decision — and the tail counts + /// classes, not failures. The existing `surefire_caps_failing_blocks_emits_tail` + /// only covers single-failure classes. + #[test] + fn surefire_drop_failing_drops_all_sublines_of_capped_class() { + let i = "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n\ + [INFO] Running x.FailA\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.011 s <<< FAILURE! -- in x.FailA\n\ + [ERROR] x.FailA.one -- Time elapsed: 0.010 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomA\n\ + \tat x.FailA.one(FailA.java:10)\n\ + \n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \tat x.MultiFail.first(MultiFail.java:20)\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< ERROR!\n\ + java.lang.IllegalStateException: boomSecond\n\ + \tat x.MultiFail.second(MultiFail.java:30)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire_with_cap(i, 1); + + assert!(o.contains("boomA"), "first class kept; got:\n{}", o); + assert!( + !o.contains("Running x.MultiFail") && !o.contains("boomFirst"), + "capped class first block dropped; got:\n{}", + o + ); + assert!( + !o.contains("x.MultiFail.second") && !o.contains("boomSecond"), + "capped class second per-test block dropped (re-arm inherits drop); got:\n{}", + o + ); + assert!( + o.contains("... +1 more failing test classes"), + "tail counts one class, not one per failure; got:\n{}", + o + ); + } + + /// A non-subline line (`[INFO] Results:`) immediately after a trail blank + /// must disarm the re-arm and be kept normally by the outside-block list. + #[test] + fn surefire_rearm_disarms_at_results_boundary() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomSecond"), "second block kept; got:\n{}", o); + assert!( + o.contains("[INFO] Results:"), + "Results boundary disarms re-arm and is kept; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 2, Failures: 2"), + "aggregate kept; got:\n{}", + o + ); + } + + /// Double blank between per-test blocks: stay armed across the extra + /// blank, still re-enter the trail — and no spurious blank lines leak. + #[test] + fn surefire_tolerates_double_blank_between_failure_blocks() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomFirst"), "first block kept; got:\n{}", o); + assert!( + o.contains("boomSecond"), + "second block re-enters trail across double blank; got:\n{}", + o + ); + assert!( + !o.contains("\n\n\n"), + "no spurious blank lines leak; got:\n{:?}", + o + ); + } + + /// Byte-exact pin of the single-failure path: the re-arm machinery must + /// not change output for single-failure fixtures (no extra blank lines, + /// no reordering). Literal captured from `filter_surefire` at the commit + /// preceding the trail re-arm change. + #[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 expected = "[INFO] Scanning for projects...\n\ + [INFO] ----------------------< commons-cli:commons-cli >-----------------------\n\ + [INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT\n\ + [INFO] Running org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25)\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Failures:\n\ + [ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: \n\ + [ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61\n\ + [INFO] BUILD FAILURE\n\ + [INFO] Total time: 01:05 min\n\ + [INFO] Finished at: 2026-05-21T14:57:09Z\n\ + [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures.\n"; + assert_eq!(o, expected, "single-failure output must be byte-identical"); + } + + /// Savings on the multifail slice. Threshold is low by design: the slice + /// is nearly all kept failure signal (two failing classes, three per-test + /// detail blocks), so the droppable share is small — measured 19.9% at + /// commit time (precedent: reactor-fail pins ≥30% with a "short fixture" + /// note). + #[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 savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 15.0, + "multifail slice ≥15% savings (dense failure-signal fixture), got {:.1}%", + savings + ); + } + + /// CLOSE regex accepts a `<<< ERROR!` marker (defensive — Surefire 3.5.5 + /// emits `<<< FAILURE!` even for errors-only classes, per the multifail + /// fixture capture; other versions may emit `ERROR!`). + #[test] + fn close_line_matches_error_marker() { + let line = "[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< ERROR! -- in com.example.rtk.BoomTest"; + let caps = CLOSE + .captures(line) + .expect("CLOSE must match an ERROR!-marked close line"); + assert_eq!(caps.get(1).expect("failures group").as_str(), "0"); + assert_eq!(caps.get(2).expect("errors group").as_str(), "1"); + } + /// `mvn test` whose compile step fails before Surefire runs must still /// keep the `[ERROR]` block's indented `symbol:` / `location:` continuation /// lines. Regression guard for the P0 reviewer ask: `filter_surefire` @@ -1730,3 +2024,4 @@ mod tests { ); } } + diff --git a/tests/fixtures/multifail-skeleton/pom.xml b/tests/fixtures/multifail-skeleton/pom.xml new file mode 100644 index 000000000..58081295d --- /dev/null +++ b/tests/fixtures/multifail-skeleton/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + com.example.rtk + multifail-skeleton + 1.0.0-SNAPSHOT + jar + + 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/multifail-skeleton/src/main/java/com/example/rtk/Calc.java b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java new file mode 100644 index 000000000..26090c28e --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java @@ -0,0 +1,7 @@ +package com.example.rtk; + +public class Calc { + public int add(int a, int b) { + return a + b; + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java new file mode 100644 index 000000000..ea5e9720f --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java @@ -0,0 +1,10 @@ +package com.example.rtk; + +import org.junit.jupiter.api.Test; + +class BoomTest { + @Test + void boom() { + throw new IllegalStateException("boom: errors-only class for fixture capture"); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java new file mode 100644 index 000000000..eecf368d2 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java @@ -0,0 +1,24 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class CalcTest { + private final Calc calc = new Calc(); + + @Test + void failOne() { + assertEquals(5, calc.add(2, 2), "failOne: addition should equal five"); + } + + @Test + void failTwo() { + throw new IllegalStateException("failTwo: induced error for fixture capture"); + } + + @Test + void passes() { + assertEquals(4, calc.add(2, 2)); + } +} diff --git a/tests/fixtures/mvn_test_multifail_slice_raw.txt b/tests/fixtures/mvn_test_multifail_slice_raw.txt new file mode 100644 index 000000000..ade0090be --- /dev/null +++ b/tests/fixtures/mvn_test_multifail_slice_raw.txt @@ -0,0 +1,70 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ multifail-skeleton --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.rtk.CalcTest +[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.108 s <<< FAILURE! -- in com.example.rtk.CalcTest +[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.050 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.006 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.BoomTest +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< FAILURE! -- in com.example.rtk.BoomTest +[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.004 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] +[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: 4, Failures: 1, Errors: 2, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.171 s +[INFO] Finished at: 2026-06-06T10:53:18-03: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 /home/vcyber/projetos/rtk/tests/fixtures/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 be28a511797fb5214ff0784f57f491d2b7dd0e71 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:07:46 -0300 Subject: [PATCH 16/19] fix(ci): pin fixture line endings, harden CRLF tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test (windows-latest) failed on the two CRLF tests: with no .gitattributes, the Windows runner's core.autocrlf=true checks fixtures out with CRLF, include_str! embeds \r\n, and the tests' replace('\n', "\r\n") synthesis produces \r\r\n. str::lines() strips only the final \r\n of each pair, leaving a stray \r that $-anchored regexes (MODULE_BANNER, BUILD_FOOT) reject — dropping the module banner and BUILD SUCCESS in the CRLF leg only. Real Maven CRLF output (single \r\n) was always handled; only the synthesized double-CR leg broke. Root-cause fix: tests/fixtures/** -text — fixtures are byte-exact filter inputs; eol conversion must never touch them. Defense-in-depth: the two CRLF tests normalize the embedded fixture back to LF before synthesizing, so they stay correct even on checkouts without attributes (zip downloads, pre-existing autocrlf clones). Also fixes the test doc comment, which claimed str::lines() keeps the trailing \r of a \r\n pair — it strips it; the hazard is the doubled \r\r\n, not single CRLF. --- .gitattributes | 4 ++++ src/cmds/jvm/mvn_cmd.rs | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..fd52a2695 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Fixtures are byte-exact filter inputs; eol conversion must never touch them. +# Without this, Windows runners (core.autocrlf=true) check fixtures out with +# CRLF, and tests that synthesize CRLF input from them produce \r\r\n. +tests/fixtures/** -text diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 84fbd2123..e4d8d40ef 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -1517,14 +1517,18 @@ mod tests { // ── CRLF line-ending compatibility ─────────────────────────────────────── - /// `str::lines()` splits on `\n` but keeps any trailing `\r`, so exact - /// equality / `starts_with("…")` checks can silently fail under Windows - /// line endings. Convert an LF fixture to CRLF, filter both, normalise - /// the CRLF output back to LF, and assert byte-equality. + /// `str::lines()` strips single `\r\n` pairs entirely, so real Maven CRLF + /// output filters cleanly. The hazard is a fixture *already checked out + /// with CRLF* (e.g. `core.autocrlf=true` without `.gitattributes`): the + /// `\n` → `\r\n` synthesis below would then produce `\r\r\n`, leaving a + /// stray `\r` per line that `$`-anchored regexes reject. Normalise the + /// embedded fixture back to LF first — correct regardless of checkout + /// state (defense-in-depth alongside `tests/fixtures/** -text`). #[test] fn surefire_handles_crlf_line_endings() { - let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); - let o_lf = filter_surefire(i_lf); + 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 i_crlf = i_lf.replace('\n', "\r\n"); let o_crlf = filter_surefire(&i_crlf); assert_eq!( @@ -1536,8 +1540,9 @@ mod tests { #[test] fn package_handles_crlf_line_endings() { - let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); - let o_lf = filter_package(i_lf); + let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_package(&i_lf); let i_crlf = i_lf.replace('\n', "\r\n"); let o_crlf = filter_package(&i_crlf); assert_eq!( @@ -2025,3 +2030,4 @@ mod tests { } } + From df76528dd36dba4a58163a4827d34fbb9e7c17ca Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:12:10 -0300 Subject: [PATCH 17/19] fix(mvn): strip post-failure help boilerplate in non-quiet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter_quiet already dropped the help block Maven emits after 'Failed to execute goal' (See ..., -> [Help 1], Re-run Maven, help URLs, bare [ERROR] dividers); the non-quiet paths kept it via the [ERROR] catch-alls — keep_outside_block for test/package and filter_compile's own [ERROR] branch. Promote QUIET_BOILER_PREFIXES to a shared BOILER_PREFIXES (+ is_boilerplate(), which also covers the bare [ERROR] divider lines, matching filter_quiet's existing drop) and reject boilerplate before each [ERROR] catch-all. The list deliberately keeps the resume hint ([ERROR] After correcting the problems... / [ERROR] mvn -rf :module) — actionable signal for resuming a multi-module build — and the Failed to execute goal terminator. keep_continuation hygiene (defensive parity, not a demonstrated bug): filter_surefire only reset the flag inside its kept branch, so a dropped [ERROR] line would leave it stale and a following indented line could be wrongly kept. No committed fixture exhibits the leak; add the same end-of-loop fall-through reset filter_package already has. filter_compile resets on its new boilerplate drop path. Multifail-slice savings rise from 19.9% to 42.3%; threshold pinned at >=30% to match the reactor-fail precedent. README: document the stripping and drop the stale duration-normalisation bullet (that behaviour was removed in 77e28d0). --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 119 ++++++++++++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index b36d7d0ea..5858abbb6 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -26,7 +26,7 @@ Key behaviours: - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). - **Multi-failure classes (trail re-arm)** — when a single class has several failing tests, Surefire 3.x emits one blank-separated detail block per failing test under a single close line. When a failure trail ends at a blank line, the state machine arms a re-entry: the next per-test subline (`[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` or `<<< ERROR!`) re-enters the trail with the same keep/drop decision, so every failure message survives (and a capped class drops *all* its blocks). Any other non-blank line disarms the re-entry. - **`<<< 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). -- **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. +- **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")`. - **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 `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index e4d8d40ef..4a0eeb173 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -121,6 +121,26 @@ fn is_framework_frame(trimmed: &str) -> bool { .any(|p| trimmed.starts_with(p)) } +/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — +/// pure noise pointing at log files and help URLs, no signal for the user/LLM. +/// Deliberately excludes `[ERROR] After correcting the problems` and +/// `[ERROR] mvn -rf :…` (the resume hint is actionable signal for a +/// multi-module build) and `[ERROR] Failed to execute goal` (signal). +const BOILER_PREFIXES: &[&str] = &[ + "[ERROR] See ", + "[ERROR] -> [Help", + "[ERROR] To see the full stack trace", + "[ERROR] Re-run Maven", + "[ERROR] For more information", + "[ERROR] [Help", +]; + +/// Post-failure help boilerplate, plus the bare `[ERROR]` divider lines Maven +/// emits between boilerplate blocks (same drop rules as `filter_quiet`). +fn is_boilerplate(line: &str) -> bool { + BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) || line.trim_end() == "[ERROR]" +} + /// `[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 @@ -166,6 +186,11 @@ fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { } fn keep_outside_block(line: &str) -> 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) { + return false; + } RESULTS.is_match(line) || AGG.is_match(line) || BUILD_FOOT.is_match(line) @@ -566,6 +591,10 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { && !line.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; } block.finish(&mut out); @@ -615,6 +644,12 @@ pub fn filter_compile(raw: &str) -> String { keep_continuation = false; continue; } + // Help boilerplate: drop before the `[ERROR]` catch-all (parity with + // keep_outside_block / filter_quiet). + if is_boilerplate(line) { + keep_continuation = false; + continue; + } if line.starts_with("[ERROR]") { out.push_str(line); out.push('\n'); @@ -742,17 +777,6 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { // ── Quiet-mode filter ─────────────────────────────────────────────────────── -/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — -/// pure noise pointing at log files and help URLs, no signal for the user/LLM. -const QUIET_BOILER_PREFIXES: &[&str] = &[ - "[ERROR] See ", - "[ERROR] -> [Help", - "[ERROR] To see the full stack trace", - "[ERROR] Re-run Maven", - "[ERROR] For more information", - "[ERROR] [Help", -]; - /// Filter for `mvn -q` invocations. /// /// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard @@ -824,13 +848,9 @@ pub fn filter_quiet(raw: &str) -> String { continue; } - // Drop post-failure help boilerplate. - if QUIET_BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) { - continue; - } - - // Drop empty `[ERROR]` / `[ERROR] ` divider lines Maven emits between blocks. - if line.trim_end() == "[ERROR]" { + // Drop post-failure help boilerplate and bare `[ERROR]` dividers + // (shared with the non-quiet filters — see BOILER_PREFIXES). + if is_boilerplate(line) { continue; } @@ -1382,21 +1402,57 @@ mod tests { /// Savings on the multifail slice. Threshold is low by design: the slice /// is nearly all kept failure signal (two failing classes, three per-test - /// detail blocks), so the droppable share is small — measured 19.9% at - /// commit time (precedent: reactor-fail pins ≥30% with a "short fixture" - /// note). + /// detail blocks), so the droppable share is small — measured 42.3% after + /// non-quiet boilerplate stripping (19.9% before it; precedent: + /// reactor-fail pins ≥30% with a "short fixture" note). #[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 savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( - savings >= 15.0, - "multifail slice ≥15% savings (dense failure-signal fixture), got {:.1}%", + savings >= 30.0, + "multifail slice ≥30% savings (dense failure-signal fixture), got {:.1}%", savings ); } + /// Non-quiet runs must strip the post-failure help boilerplate + /// (`-> [Help 1]`, `Re-run Maven`, `See …`, bare `[ERROR]` dividers) the + /// same way `filter_quiet` does, while keeping the `Failed to execute + /// goal` terminator (signal). + #[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); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator kept; got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help link stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("To see the full stack trace"), + "stack-trace hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("See dump files"), + "dump-file pointer stripped; got:\n{}", + o + ); + assert!( + !o.lines().any(|l| l.trim_end() == "[ERROR]"), + "bare [ERROR] dividers stripped; got:\n{}", + o + ); + } + /// CLOSE regex accepts a `<<< ERROR!` marker (defensive — Surefire 3.5.5 /// emits `<<< FAILURE!` even for errors-only classes, per the multifail /// fixture capture; other versions may emit `ERROR!`). @@ -1766,6 +1822,17 @@ mod tests { "goal terminator preserved; got:\n{}", o ); + assert!( + o.contains("mvn -rf :child-b"), + "resume hint preserved (actionable signal); got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help boilerplate stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 30.0, @@ -1798,6 +1865,11 @@ mod tests { "indented continuation preserved" ); assert!(o.contains("BUILD FAILURE"), "footer preserved"); + assert!( + !o.contains("[Help 1]"), + "help boilerplate stripped in compile path; got:\n{}", + o + ); } #[test] @@ -2031,3 +2103,4 @@ mod tests { } + From 3c0ee949d474df66cfb46538adcf2556b6ad25e2 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 7 Jun 2026 14:35:41 -0300 Subject: [PATCH 18/19] refactor(mvn): bind failure cap to truncate::CAP_WARNINGS, drop config knob Replace the per-filter LimitsConfig::mvn_max_failures field with a local const MAX_MVN_FAILING_CLASSES = CAP_WARNINGS, matching the canonical cap binding used by pytest/rspec/rake/runner. Align cap = 0 semantics with the core policy (summary-only: no blocks emitted, tail still counts) instead of the old opt-out meaning, so the future config surface lands without a special case. --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 36 +++++++++++++++++++++--------------- src/core/config.rs | 6 ------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 5858abbb6..9d315bb44 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -29,7 +29,7 @@ Key behaviours: - **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")`. - **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 `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. +- **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 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. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 4a0eeb173..c3ddc0552 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -6,6 +6,7 @@ //! mode toggle) that TOML DSL cannot express. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, strip_ansi}; use anyhow::Result; use lazy_static::lazy_static; @@ -15,6 +16,10 @@ use std::ffi::OsString; use std::path::Path; use std::process::Command; +/// Cap on emitted failing test-class blocks and `[ERROR] Failures:` summary +/// entries — test-failure cap class, same binding as pytest/rspec/rake/runner. +const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS; + // ── Shared regex patterns ──────────────────────────────────────────────────── lazy_static! { @@ -223,7 +228,7 @@ fn keep_outside_block(line: &str) -> bool { /// - in-block buffering until the next CLOSE line /// - CLOSE with `Failures > 0` or `Errors > 0` → yields /// [`SurefireStep::FailingClose`] so the outer loop can decide whether to -/// emit (Commit D uses this seam to enforce `mvn_max_failures`) +/// emit (this seam enforces [`MAX_MVN_FAILING_CLASSES`]) /// - failure-trail handling for the exception/user-frame trail Surefire 3.x /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the @@ -443,8 +448,8 @@ impl<'a> SurefireBlock<'a> { /// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the /// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds /// with hundreds of failures this can be quite large. Cap entries at -/// `mvn_max_failures` and emit `\n... +N more failures\n` immediately before -/// the `Tests run:` aggregate when entries were dropped. +/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n... +N more failures\n` immediately +/// before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, in_summary: bool, @@ -469,7 +474,8 @@ impl FailuresSummaryCap { if !self.in_summary || !line.starts_with("[ERROR] ") { return false; } - if self.cap == 0 || self.emitted < self.cap { + // Per core cap policy, `0` means summary-only: no entries, tail still counts. + if self.emitted < self.cap { out.push_str(line); out.push('\n'); self.emitted += 1; @@ -524,7 +530,7 @@ impl FailuresSummaryCap { /// 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, crate::core::config::limits().mvn_max_failures) + filter_surefire_with_cap(raw, MAX_MVN_FAILING_CLASSES) } fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { @@ -549,7 +555,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { lines, close, } => { - if cap == 0 || emitted_failing < cap { + if emitted_failing < cap { block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { @@ -687,7 +693,7 @@ pub fn filter_compile(raw: &str) -> String { /// 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, crate::core::config::limits().mvn_max_failures) + filter_package_with_cap(raw, MAX_MVN_FAILING_CLASSES) } fn filter_package_with_cap(raw: &str, cap: usize) -> String { @@ -713,7 +719,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { lines, close, } => { - if cap == 0 || emitted_failing < cap { + if emitted_failing < cap { block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { @@ -1671,10 +1677,10 @@ mod tests { ); } - /// Cap of 0 means no cap — same fixture but with `cap = 0` should emit - /// all five blocks without any tail. + /// Cap of 0 means summary-only (core cap policy): no failing-class blocks + /// emitted, tail still counts every dropped class. #[test] - fn surefire_cap_zero_disables_capping() { + fn surefire_cap_zero_emits_summary_only() { let mut i = String::from( "[INFO] Scanning for projects...\n\ [INFO] -----< x >-----\n", @@ -1691,15 +1697,15 @@ mod tests { let o = filter_surefire_with_cap(&i, 0); for n in 1..=5 { assert!( - o.contains(&format!("Running x.Fail{}", n)), - "Fail{n} kept under cap=0; got:\n{}", + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped under cap=0; got:\n{}", o, n = n ); } assert!( - !o.contains("more failing test classes"), - "no tail under cap=0; got:\n{}", + o.contains("+5 more failing test classes"), + "tail counts all 5 under cap=0; got:\n{}", o ); } diff --git a/src/core/config.rs b/src/core/config.rs index 9bee5d032..ed0f00c6c 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -131,11 +131,6 @@ pub struct LimitsConfig { pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, - /// Max failing Surefire test classes (and `[ERROR] Failures:` summary - /// entries) emitted by the Maven filter. Excess emissions are replaced - /// by a `... +N more failing test classes` / `... +N more failures` tail. - /// Set to `0` to opt out of the cap entirely. Default: 25. - pub mvn_max_failures: usize, } impl Default for LimitsConfig { @@ -146,7 +141,6 @@ impl Default for LimitsConfig { status_max_files: 15, status_max_untracked: 10, passthrough_max_chars: 2000, - mvn_max_failures: 25, } } } From f8bc856078dd43bb5780e3372dcda6588247e39b Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 7 Jun 2026 14:36:46 -0300 Subject: [PATCH 19/19] =?UTF-8?q?style(mvn):=20align=20overflow=20tail=20w?= =?UTF-8?q?ith=20canonical=20'=E2=80=A6=20+N=20more=20{label}'=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the join_with_overflow convention (ellipsis char, '+N more' label) used by container, aws, gh and glab so the LLM sees one consistent overflow style. The tee '[full output: …]' recovery hint already comes from core tee_and_hint via RunOptions::with_tee, so capped failure output carries the canonical recovery path unchanged. --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 9d315bb44..0fe2e0d40 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -29,7 +29,7 @@ Key behaviours: - **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")`. - **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 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. +- **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. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index c3ddc0552..3ec2c5e08 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -448,7 +448,7 @@ impl<'a> SurefireBlock<'a> { /// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the /// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds /// with hundreds of failures this can be quite large. Cap entries at -/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n... +N more failures\n` immediately +/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, @@ -495,7 +495,7 @@ impl FailuresSummaryCap { } } - /// Pre-emit the `... +N more failures` tail when the aggregate + /// Pre-emit the `… +N more failures` tail when the aggregate /// `[ERROR] Tests run:` line is about to be written, then close the /// summary. Caller writes the AGG line itself afterwards. fn handle_aggregate(&mut self, line: &str, out: &mut String) { @@ -503,7 +503,7 @@ impl FailuresSummaryCap { return; } if self.dropped > 0 { - out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } self.in_summary = false; self.emitted = 0; @@ -515,7 +515,7 @@ impl FailuresSummaryCap { /// the resulting filtered output is still well-formed. fn finish(&mut self, out: &mut String) { if self.in_summary && self.dropped > 0 { - out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } } } @@ -607,7 +607,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { summary.finish(&mut out); if dropped_failing > 0 { out.push_str(&format!( - "\n... +{} more failing test classes\n", + "\n… +{} more failing test classes\n", dropped_failing )); } @@ -774,7 +774,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { summary.finish(&mut out); if dropped_failing > 0 { out.push_str(&format!( - "\n... +{} more failing test classes\n", + "\n… +{} more failing test classes\n", dropped_failing )); } @@ -1313,7 +1313,7 @@ mod tests { o ); assert!( - o.contains("... +1 more failing test classes"), + o.contains("… +1 more failing test classes"), "tail counts one class, not one per failure; got:\n{}", o ); @@ -1618,7 +1618,7 @@ mod tests { /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect /// the first 3 failing blocks emitted in full and a - /// `... +2 more failing test classes` tail. + /// `… +2 more failing test classes` tail. #[test] fn surefire_caps_failing_blocks_emits_tail() { let mut i = String::from( @@ -1671,7 +1671,7 @@ mod tests { ); } assert!( - o.contains("... +2 more failing test classes"), + o.contains("… +2 more failing test classes"), "tail emitted; got:\n{}", o ); @@ -1711,7 +1711,7 @@ mod tests { } /// `[ERROR] Failures:` summary block cap: with N>cap entries, expect the - /// first `cap` entries plus a `\n... +(N-cap) more failures\n` tail + /// first `cap` entries plus a `\n… +(N-cap) more failures\n` tail /// emitted before the aggregate `[ERROR] Tests run:` line. #[test] fn failures_summary_block_is_capped() { @@ -1754,7 +1754,7 @@ mod tests { } // Tail emitted before aggregate. let tail_idx = o - .find("... +2 more failures") + .find("… +2 more failures") .unwrap_or_else(|| panic!("tail must appear; got:\n{}", o)); let agg_idx = o .find("[ERROR] Tests run: 100")