Skip to content

Commit fa55089

Browse files
kylehgcclaude
andcommitted
feat(mvn): add rtk mvnd support for Maven Daemon
Closes rtk-ai#3184. mvnd is a separate binary, not a mvn wrapper, so it gets its own `rtk mvnd` entry point rather than folding into `rtk mvn`: a user who typed `mvnd` must get the daemon, never `mvn` or a `./mvnw` wrapper that happens to sit in the working directory. Everything downstream is shared — phase detection and all four filters are reused unchanged. mvnd's rolling/parallel console UI only engages on a TTY, so the output rtk captures is plain Maven output; daemon status chatter carries no [INFO]/[ERROR] prefix and is already dropped by the existing keep-lists. - src/main.rs: `Commands::Mvnd` -> `mvn_cmd::run_daemon` - src/cmds/jvm/mvn_cmd.rs: thread a `daemon` flag through binary selection; `run`/`run_daemon` share `run_tool` - src/discover/rules.rs: rewrite rule so the hook maps `mvnd <goal>` to `rtk mvnd <goal>` (the `mvn\b` pattern never matched `mvnd`) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e32bb38 commit fa55089

5 files changed

Lines changed: 88 additions & 12 deletions

File tree

‎src/cmds/jvm/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Filters for JVM-based build tools.
55
| Module | Tool(s) | Modes |
66
|------------------|--------------------------------------|----------------------------------------------------------------------------------------|
77
| `gradlew_cmd.rs` | `./gradlew`, `gradlew.bat`, `gradle` | Build / Test / ConnectedTest / Lint / Dependencies — streaming line filter + passthrough |
8-
| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase |
8+
| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd`, `mvnd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase |
99

1010
## Maven (`mvn_cmd.rs`)
1111

@@ -28,14 +28,15 @@ Key behaviours:
2828
- **`<<< 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).
2929
- **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 <args> -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.
3030
- **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`.
31+
- **Maven Daemon (`rtk mvnd`)** — `mvnd` is a separate entry point (`run_daemon`), not a `mvn` alias: it shares phase detection and every filter, but always executes `mvnd` and is never substituted by a `./mvnw` wrapper found in the working directory. Its rolling/parallel console UI only engages on a TTY, so the captured output rtk filters is plain Maven output. Daemon status chatter (`Connecting to daemon`, …) carries no `[INFO]`/`[ERROR]` prefix and is dropped by the existing keep-lists.
3132
- **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for <root>` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`).
3233
- **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.
3334

3435
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.
3536

3637
### Integrity-check whitelist
3738

38-
`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.
39+
`Commands::Mvn` and `Commands::Mvnd` are intentionally omitted from `is_operational_command` in `src/main.rs`, matching the gradle precedent (`Commands::Gradlew` also omitted). The whitelist guards SHA-256 hook-integrity verification; filter modules invoked through an already-verified hook do not need a second check on their own dispatch path. Per the comment above the function, the whitelist is opt-in by design and a forgotten command fails open rather than creating false confidence about what's protected.
3940

4041
## Gradle (`gradlew_cmd.rs`)
4142

‎src/cmds/jvm/mvn_cmd.rs‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,12 @@ pub fn filter_quiet(raw: &str) -> String {
871871

872872
// ── Wrapper detection ───────────────────────────────────────────────────────
873873

874-
fn mvn_binary() -> &'static str {
875-
if cfg!(windows) {
874+
/// Maven Daemon (`mvnd`) has no project-local wrapper of its own, so it is
875+
/// never substituted by `./mvnw`: the user asked for the daemon explicitly.
876+
fn mvn_binary(daemon: bool) -> &'static str {
877+
if daemon {
878+
"mvnd"
879+
} else if cfg!(windows) {
876880
if Path::new(".\\mvnw.cmd").exists() {
877881
".\\mvnw.cmd"
878882
} else {
@@ -885,8 +889,10 @@ fn mvn_binary() -> &'static str {
885889
}
886890
}
887891

888-
fn new_mvn_command(args: &[String]) -> Command {
889-
let mut cmd = if cfg!(windows) {
892+
fn new_mvn_command(args: &[String], daemon: bool) -> Command {
893+
let mut cmd = if daemon {
894+
resolved_command("mvnd")
895+
} else if cfg!(windows) {
890896
if Path::new(".\\mvnw.cmd").exists() {
891897
Command::new(".\\mvnw.cmd")
892898
} else {
@@ -904,16 +910,27 @@ fn new_mvn_command(args: &[String]) -> Command {
904910
// ── Entry point ─────────────────────────────────────────────────────────────
905911

906912
pub fn run(args: &[String], verbose: u8) -> Result<i32> {
913+
run_tool(args, false, verbose)
914+
}
915+
916+
/// `rtk mvnd` — Maven Daemon. Non-interactive `mvnd` output is plain Maven
917+
/// output (the rolling console UI only engages on a TTY), so the same phase
918+
/// detection and filters apply; only the executed binary differs.
919+
pub fn run_daemon(args: &[String], verbose: u8) -> Result<i32> {
920+
run_tool(args, true, verbose)
921+
}
922+
923+
fn run_tool(args: &[String], daemon: bool, verbose: u8) -> Result<i32> {
907924
// Verbose flags bypass filtering — user wants full output.
908925
if args
909926
.iter()
910927
.any(|a| matches!(a.as_str(), "-X" | "--debug" | "-e" | "--errors"))
911928
{
912929
let osargs: Vec<OsString> = args.iter().map(OsString::from).collect();
913-
return runner::run_passthrough(mvn_binary(), &osargs, verbose);
930+
return runner::run_passthrough(mvn_binary(daemon), &osargs, verbose);
914931
}
915932

916-
let tool = mvn_binary();
933+
let tool = mvn_binary(daemon);
917934
let args_display = args.join(" ");
918935

919936
// Quiet mode: standard footer guard can't fire (no `BUILD SUCCESS` line
@@ -926,7 +943,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
926943
return runner::run_passthrough(tool, &osargs, verbose);
927944
}
928945
return runner::run_filtered(
929-
new_mvn_command(args),
946+
new_mvn_command(args, daemon),
930947
tool,
931948
&args_display,
932949
filter_quiet,
@@ -938,21 +955,21 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
938955

939956
match phase {
940957
MvnPhase::Test => runner::run_filtered(
941-
new_mvn_command(args),
958+
new_mvn_command(args, daemon),
942959
tool,
943960
&args_display,
944961
filter_surefire,
945962
RunOptions::with_tee("mvn_test"),
946963
),
947964
MvnPhase::Compile => runner::run_filtered(
948-
new_mvn_command(args),
965+
new_mvn_command(args, daemon),
949966
tool,
950967
&args_display,
951968
filter_compile,
952969
RunOptions::with_tee("mvn_compile"),
953970
),
954971
MvnPhase::Package => runner::run_filtered(
955-
new_mvn_command(args),
972+
new_mvn_command(args, daemon),
956973
tool,
957974
&args_display,
958975
filter_package,
@@ -1071,6 +1088,20 @@ mod tests {
10711088
assert_eq!(detect_phase(&s(["--help"])), MvnPhase::Passthrough);
10721089
}
10731090

1091+
// ── Binary selection ─────────────────────────────────────────────────────
1092+
1093+
/// rtk-ai/rtk#3184 — the daemon is never swapped for `mvn`/`./mvnw`,
1094+
/// whatever wrapper happens to sit in the working directory.
1095+
#[test]
1096+
fn mvnd_binary_is_never_the_wrapper() {
1097+
assert_eq!(mvn_binary(true), "mvnd");
1098+
}
1099+
1100+
#[test]
1101+
fn mvn_binary_without_daemon_is_not_mvnd() {
1102+
assert_ne!(mvn_binary(false), "mvnd");
1103+
}
1104+
10741105
// ── Surefire filter ──────────────────────────────────────────────────────
10751106

10761107
#[test]

‎src/discover/registry.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3907,6 +3907,27 @@ mod tests {
39073907
);
39083908
}
39093909

3910+
/// rtk-ai/rtk#3184 — `mvnd` must route to `rtk mvnd`, never `rtk mvn`,
3911+
/// so the daemon binary is the one that actually runs.
3912+
#[test]
3913+
fn test_rewrite_mvnd_clean_install() {
3914+
assert_eq!(
3915+
rewrite_command_no_prefixes("mvnd clean install", &[]),
3916+
Some("rtk mvnd clean install".into())
3917+
);
3918+
}
3919+
3920+
#[test]
3921+
fn test_classify_mvnd_test() {
3922+
assert!(matches!(
3923+
classify_command("mvnd test"),
3924+
Classification::Supported {
3925+
rtk_equivalent: "rtk mvnd",
3926+
..
3927+
}
3928+
));
3929+
}
3930+
39103931
// --- Compound operator edge cases ---
39113932

39123933
#[test]

‎src/discover/rules.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,18 @@ pub const RULES: &[RtkRule] = &[
879879
subcmd_savings: &[],
880880
subcmd_status: &[],
881881
},
882+
RtkRule {
883+
// `mvnd` is a separate binary, not a `mvn` wrapper — it must keep its
884+
// own rtk_cmd so the daemon is what actually runs.
885+
pattern: r"^mvnd\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b",
886+
rtk_cmd: "rtk mvnd",
887+
pipeline_final_safe: false,
888+
rewrite_prefixes: &["mvnd"],
889+
category: "Build",
890+
savings_pct: 82.0,
891+
subcmd_savings: &[],
892+
subcmd_status: &[],
893+
},
882894
RtkRule {
883895
pattern: r"^ping\b",
884896
rtk_cmd: "rtk ping",

‎src/main.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,14 @@ enum Commands {
825825
args: Vec<String>,
826826
},
827827

828+
/// Maven Daemon (mvnd) with compact output — same filters as `rtk mvn`
829+
#[command(name = "mvnd")]
830+
Mvnd {
831+
/// Maven goals and arguments (e.g., clean install, -DskipTests test, -X)
832+
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
833+
args: Vec<String>,
834+
},
835+
828836
/// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1)
829837
#[command(name = "hook-audit")]
830838
HookAudit {
@@ -2422,6 +2430,8 @@ fn run_cli() -> Result<i32> {
24222430

24232431
Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?,
24242432

2433+
Commands::Mvnd { args } => mvn_cmd::run_daemon(&args, cli.verbose)?,
2434+
24252435
Commands::HookAudit { since } => {
24262436
hooks::hook_audit_cmd::run(since, cli.verbose)?;
24272437
0
@@ -3154,6 +3164,7 @@ mod tests {
31543164
"golangci-lint",
31553165
"gradlew",
31563166
"mvn",
3167+
"mvnd",
31573168
"sbt",
31583169
"php",
31593170
"phpunit",

0 commit comments

Comments
 (0)