From 8fea1aae19b62e9807999dc75980623c50aa43e0 Mon Sep 17 00:00:00 2001 From: Yonatan Karp-Rudin Date: Tue, 24 Feb 2026 07:03:15 +0100 Subject: [PATCH] feat(gradle): add Gradle/Gradlew test filter support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `rtk gradle` command that filters Gradle test output for token-optimized consumption by LLMs. The filter strips task progress lines (> Task :compileJava UP-TO-DATE), boilerplate advice sections (* Try: / * Get more help), and retains only failures, test summaries, and build status — achieving ~70% token reduction on test output. The command auto-detects `./gradlew` wrapper when present and falls back to `gradle`. Unfiltered tasks (build, assemble, etc.) pass through directly. Hook support rewrites both `gradle` and `./gradlew` invocations to `rtk gradle` in Claude Code sessions. This is the first PR for JVM ecosystem support, split for review size. Follow-up PRs will add filters for additional Gradle tasks (build, lint) and Maven support. Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/rtk-rewrite.sh | 6 + .claude/hooks/rtk-suggest.sh | 6 + CLAUDE.md | 1 + README.md | 7 + hooks/rtk-rewrite.sh | 6 + hooks/test-rtk-rewrite.sh | 20 ++ scripts/test-all.sh | 6 + src/gradle_cmd.rs | 383 +++++++++++++++++++++++++++++++++++ src/main.rs | 70 +++++++ 9 files changed, 505 insertions(+) create mode 100644 src/gradle_cmd.rs diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index 5c8bad02..5433ccbe 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -195,6 +195,12 @@ elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')" elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')" + +# --- JVM tooling (Gradle) --- +elif echo "$MATCH_CMD" | grep -qE '^\./gradlew[[:space:]]'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^\.\/gradlew /rtk gradle /')" +elif echo "$MATCH_CMD" | grep -qE '^gradle[[:space:]]'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^gradle /rtk gradle /')" fi # If no rewrite needed, approve as-is diff --git a/.claude/hooks/rtk-suggest.sh b/.claude/hooks/rtk-suggest.sh index b35b47d8..6e29b083 100755 --- a/.claude/hooks/rtk-suggest.sh +++ b/.claude/hooks/rtk-suggest.sh @@ -133,6 +133,12 @@ elif echo "$FIRST_CMD" | grep -qE '^wget\s+'; then # --- pnpm package management --- elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+(list|ls|outdated)(\s|$)'; then SUGGESTION=$(echo "$CMD" | sed 's/^pnpm /rtk pnpm /') + +# --- JVM tooling (Gradle) --- +elif echo "$FIRST_CMD" | grep -qE '^\./gradlew\s'; then + SUGGESTION=$(echo "$CMD" | sed 's/^\.\/gradlew /rtk gradle /') +elif echo "$FIRST_CMD" | grep -qE '^gradle\s'; then + SUGGESTION=$(echo "$CMD" | sed 's/^gradle /rtk gradle /') fi # If no suggestion, allow command as-is diff --git a/CLAUDE.md b/CLAUDE.md index b8cf94f0..844ffa15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,7 @@ rtk gain --history | grep proxy | pip_cmd.rs | pip/uv package manager | JSON parsing, auto-detect uv (70-85% reduction) | | go_cmd.rs | Go commands | NDJSON for test, text for build/vet (80-90% reduction) | | golangci_cmd.rs | golangci-lint | JSON parsing, group by rule (85% reduction) | +| gradle_cmd.rs | Gradle commands | Failures only for test (70% reduction) | | tee.rs | Full output recovery | Save raw output to file on failure, print hint for LLM re-read | | utils.rs | Shared utilities | Package manager detection, common formatting | | discover/ | Claude Code history analysis | Scan JSONL sessions, classify commands, report missed savings | diff --git a/README.md b/README.md index b6537eab..753b8da6 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,12 @@ rtk go vet # Vet issues (75% reduction) rtk golangci-lint run # JSON grouped by rule (85% reduction) ``` +### JVM Stack (Gradle) +```bash +rtk gradle test # Test failures only (70% reduction) +rtk gradle # Passthrough for other tasks +``` + ## Examples ### Standard vs rtk @@ -625,6 +631,7 @@ The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To us | `pip list/install/outdated` | `rtk pip ...` | | `go test/build/vet` | `rtk go ...` | | `golangci-lint run` | `rtk golangci-lint run` | +| `gradle/gradlew test` | `rtk gradle test` | | `docker ps/images/logs` | `rtk docker ...` | | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` | diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02caa..70c10111 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -185,6 +185,12 @@ elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')" elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')" + +# --- JVM tooling (Gradle) --- +elif echo "$MATCH_CMD" | grep -qE '^\./gradlew[[:space:]]'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^\.\/gradlew /rtk gradle /')" +elif echo "$MATCH_CMD" | grep -qE '^gradle[[:space:]]'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^gradle /rtk gradle /')" fi # If no rewrite needed, approve as-is diff --git a/hooks/test-rtk-rewrite.sh b/hooks/test-rtk-rewrite.sh index 2c5535b6..2d26e256 100755 --- a/hooks/test-rtk-rewrite.sh +++ b/hooks/test-rtk-rewrite.sh @@ -247,6 +247,26 @@ test_rewrite "pnpm vitest run --coverage" \ echo "" +# ---- SECTION: Gradle ---- +echo "--- Gradle ---" +test_rewrite "gradle test" \ + "gradle test" \ + "rtk gradle test" + +test_rewrite "./gradlew test" \ + "./gradlew test --tests com.example.MyTest" \ + "rtk gradle test --tests com.example.MyTest" + +test_rewrite "gradle :app:test" \ + "gradle :app:test" \ + "rtk gradle :app:test" + +test_rewrite "./gradlew :app:test" \ + "./gradlew :app:test --tests com.example.MyTest" \ + "rtk gradle :app:test --tests com.example.MyTest" + +echo "" + # ---- SECTION 5: Should NOT rewrite ---- echo "--- Should NOT rewrite ---" test_rewrite "already rtk" \ diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 74203f49..623df22f 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -433,6 +433,12 @@ section "Learn" assert_ok "rtk learn --help" rtk learn --help assert_ok "rtk learn (no sessions)" rtk learn --since 0 2>&1 || true +# ── 32. Gradle ───────────────────────────────────── + +section "Gradle" + +assert_help "rtk gradle test" rtk gradle test -h + # ══════════════════════════════════════════════════════ # Report # ══════════════════════════════════════════════════════ diff --git a/src/gradle_cmd.rs b/src/gradle_cmd.rs new file mode 100644 index 00000000..6eb38c93 --- /dev/null +++ b/src/gradle_cmd.rs @@ -0,0 +1,383 @@ +use crate::tracking; +use anyhow::{Context, Result}; +use std::process::Command; + +/// Detect gradle binary: prefer ./gradlew wrapper, fall back to gradle +fn detect_gradle_binary() -> String { + #[cfg(target_os = "windows")] + { + if std::path::Path::new("gradlew.bat").exists() { + return "gradlew.bat".to_string(); + } + } + #[cfg(not(target_os = "windows"))] + { + if std::path::Path::new("./gradlew").exists() { + return "./gradlew".to_string(); + } + } + "gradle".to_string() +} + +/// Filter gradle test output: show failures only, strip task progress +fn filter_gradle_test(output: &str) -> String { + if output.trim().is_empty() { + return "Gradle test: no output".to_string(); + } + + let mut result = String::new(); + let mut in_failure_block = false; + let mut in_test_failure = false; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + if in_test_failure || in_failure_block { + result.push('\n'); + } + continue; + } + + // Skip > Task lines (progress noise) + if trimmed.starts_with("> Task") { + in_test_failure = false; + continue; + } + + // Test summary line: "N tests completed, M failed" + if trimmed.contains("tests completed") { + in_test_failure = false; + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // FAILURE: block (ends test failure context) + if trimmed.starts_with("FAILURE:") { + in_test_failure = false; + in_failure_block = true; + result.push('\n'); + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // Test failure line: "ClassName > method() FAILED" + if trimmed.contains("FAILED") && trimmed.contains(" > ") { + in_test_failure = true; + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // Exception/assertion details (all lines under a failed test are diagnostic) + if in_test_failure { + result.push_str(" "); + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // "* What went wrong:" and following lines + if in_failure_block && trimmed.starts_with("* What went wrong:") { + result.push_str(trimmed); + result.push('\n'); + continue; + } + + if in_failure_block && trimmed.starts_with("> ") && !trimmed.starts_with("> Task") { + result.push_str(" "); + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // Stop failure block at advice + if trimmed.starts_with("* Try:") || trimmed.starts_with("* Get more help") { + in_failure_block = false; + continue; + } + + // BUILD SUCCESSFUL / BUILD FAILED summary + if trimmed.starts_with("BUILD SUCCESSFUL") || trimmed.starts_with("BUILD FAILED") { + in_failure_block = false; + result.push('\n'); + result.push_str(trimmed); + result.push('\n'); + continue; + } + + // Actionable tasks line + if trimmed.contains("actionable task") { + result.push_str(trimmed); + result.push('\n'); + continue; + } + } + + let output = result.trim().to_string(); + if output.is_empty() { + "Gradle test: no output".to_string() + } else { + output + } +} + +/// Return the appropriate filter for a Gradle task, if one exists. +fn get_filter(task: &str) -> Option String> { + if task == "test" || task.ends_with(":test") { + Some(filter_gradle_test) + } else { + None + } +} + +/// Execute a Gradle task, applying a filter if one exists for it. +pub fn run_task(task: &str, args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + let gradle = detect_gradle_binary(); + let filter = get_filter(task); + + let mut cmd = Command::new(&gradle); + cmd.arg("--console=plain"); + cmd.arg(task); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} {}", gradle, task, args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {} {}. Is Gradle installed?", gradle, task))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + + let filtered = match filter { + Some(f) => { + let result = f(&raw); + if let Some(hint) = + crate::tee::tee_and_hint(&raw, &format!("gradle_{}", task), exit_code) + { + println!("{}\n{}", result, hint); + } else { + println!("{}", result); + } + result + } + None => { + print!("{}", stdout); + eprint!("{}", stderr); + raw.clone() + } + }; + + timer.track( + &format!("{} {} {}", gradle, task, args.join(" ")), + &format!("rtk gradle {} {}", task, args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + const GRADLE_TEST_PASS: &str = r#"> Task :compileJava UP-TO-DATE +> Task :processResources NO-SOURCE +> Task :classes UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources NO-SOURCE +> Task :testClasses UP-TO-DATE +> Task :test + +BUILD SUCCESSFUL in 5s +4 actionable tasks: 1 executed, 3 up-to-date"#; + + const GRADLE_TEST_FAIL: &str = r#"> Task :compileJava UP-TO-DATE +> Task :processResources NO-SOURCE +> Task :classes UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources NO-SOURCE +> Task :testClasses UP-TO-DATE + +> Task :test FAILED + +com.example.UserServiceTest > testCreateUser() FAILED + org.opentest4j.AssertionFailedError: expected: <201> but was: <400> + at com.example.UserServiceTest.testCreateUser(UserServiceTest.java:42) + +com.example.UserServiceTest > testDeleteUser() FAILED + java.lang.NullPointerException: Cannot invoke method on null + at com.example.UserServiceTest.testDeleteUser(UserServiceTest.java:67) + +com.example.OrderServiceTest > testPlaceOrder() FAILED + org.opentest4j.AssertionFailedError: expected: true but was: false + at com.example.OrderServiceTest.testPlaceOrder(OrderServiceTest.java:31) + +15 tests completed, 3 failed + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':test'. +> There were failing tests. See the report at: file:///home/user/project/build/reports/tests/test/index.html + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to get full insights. +> Get more help at https://help.gradle.org. + +BUILD FAILED in 8s +6 actionable tasks: 1 executed, 5 up-to-date"#; + + // --- detect_gradle_binary --- + + #[test] + fn test_detect_gradle_binary_returns_string() { + let binary = detect_gradle_binary(); + assert!( + binary == "gradle" || binary == "./gradlew" || binary == "gradlew.bat", + "Expected gradle, ./gradlew, or gradlew.bat, got: {}", + binary + ); + } + + // --- filter_gradle_test: passing --- + + #[test] + fn test_filter_gradle_test_all_pass_contains_success() { + let result = filter_gradle_test(GRADLE_TEST_PASS); + assert!(result.contains("BUILD SUCCESSFUL")); + } + + #[test] + fn test_filter_gradle_test_all_pass_no_task_lines() { + let result = filter_gradle_test(GRADLE_TEST_PASS); + assert!(!result.contains("> Task :compileJava")); + } + + #[test] + fn test_filter_gradle_test_all_pass_token_savings() { + let result = filter_gradle_test(GRADLE_TEST_PASS); + let input_tokens = count_tokens(GRADLE_TEST_PASS); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 30.0, + "Expected >=30% savings on passing tests, got {:.1}%", + savings + ); + } + + // --- filter_gradle_test: failures --- + + #[test] + fn test_filter_gradle_test_failures_shows_failed_tests() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(result.contains("testCreateUser")); + assert!(result.contains("testDeleteUser")); + assert!(result.contains("testPlaceOrder")); + } + + #[test] + fn test_filter_gradle_test_failures_shows_exception_details() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(result.contains("AssertionFailedError")); + assert!(result.contains("NullPointerException")); + } + + #[test] + fn test_filter_gradle_test_failures_shows_summary() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(result.contains("15 tests completed, 3 failed")); + } + + #[test] + fn test_filter_gradle_test_failures_shows_what_went_wrong() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(result.contains("What went wrong")); + } + + #[test] + fn test_filter_gradle_test_failures_no_task_progress() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(!result.contains("> Task :compileJava UP-TO-DATE")); + assert!(!result.contains("> Task :processResources")); + } + + #[test] + fn test_filter_gradle_test_failures_strips_try_advice() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(!result.contains("Run with --stacktrace")); + assert!(!result.contains("Get more help")); + } + + #[test] + fn test_filter_gradle_test_failures_shows_build_failed() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + assert!(result.contains("BUILD FAILED")); + } + + #[test] + fn test_filter_gradle_test_failures_token_savings() { + let result = filter_gradle_test(GRADLE_TEST_FAIL); + let input_tokens = count_tokens(GRADLE_TEST_FAIL); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 30.0, + "Expected >=30% savings on failing tests, got {:.1}%", + savings + ); + } + + // --- edge cases --- + + #[test] + fn test_filter_gradle_test_empty_input() { + let result = filter_gradle_test(""); + assert!(!result.is_empty()); + } + + // --- get_filter: submodule routing --- + + #[test] + fn test_get_filter_matches_top_level_test() { + assert!(get_filter("test").is_some()); + } + + #[test] + fn test_get_filter_matches_submodule_test() { + assert!(get_filter(":moduleA:test").is_some()); + assert!(get_filter(":app:test").is_some()); + } + + #[test] + fn test_get_filter_no_match_for_other_tasks() { + assert!(get_filter("build").is_none()); + assert!(get_filter("assemble").is_none()); + assert!(get_filter(":app:build").is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index fcb39303..62b55db4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod gh_cmd; mod git; mod go_cmd; mod golangci_cmd; +mod gradle_cmd; mod grep_cmd; mod hook_audit_cmd; mod init; @@ -516,6 +517,12 @@ enum Commands { args: Vec, }, + /// Gradle commands with compact output + Gradle { + #[command(subcommand)] + command: GradleCommands, + }, + /// Go commands with compact output Go { #[command(subcommand)] @@ -827,6 +834,19 @@ enum CargoCommands { Other(Vec), } +#[derive(Subcommand)] +enum GradleCommands { + /// Run tests with compact output (failures only) + Test { + /// Additional gradle test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported gradle subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + #[derive(Subcommand)] enum GoCommands { /// Run tests with compact output (90% token reduction via JSON streaming) @@ -1408,6 +1428,20 @@ fn main() -> Result<()> { pip_cmd::run(&args, cli.verbose)?; } + Commands::Gradle { command } => match command { + GradleCommands::Test { args } => { + gradle_cmd::run_task("test", &args, cli.verbose)?; + } + GradleCommands::Other(args) => { + let task = args[0].to_string_lossy().to_string(); + let extra_args: Vec = args[1..] + .iter() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + gradle_cmd::run_task(&task, &extra_args, cli.verbose)?; + } + }, + Commands::Go { command } => match command { GoCommands::Test { args } => { go_cmd::run_test(&args, cli.verbose)?; @@ -1546,4 +1580,40 @@ mod tests { _ => panic!("Expected Git Commit command"), } } + + #[test] + fn test_gradle_test_subcommand() { + let cli = Cli::try_parse_from(["rtk", "gradle", "test", "--tests", "com.example.MyTest"]) + .unwrap(); + match cli.command { + Commands::Gradle { + command: GradleCommands::Test { args }, + } => { + assert_eq!(args, vec!["--tests", "com.example.MyTest"]); + } + _ => panic!("Expected Gradle Test command"), + } + } + + #[test] + fn test_gradle_submodule_test_routes_to_other() { + let cli = Cli::try_parse_from([ + "rtk", + "gradle", + ":moduleA:test", + "--tests", + "com.example.MyTest", + ]) + .unwrap(); + match cli.command { + Commands::Gradle { + command: GradleCommands::Other(args), + } => { + assert_eq!(args[0], ":moduleA:test"); + assert_eq!(args[1], "--tests"); + assert_eq!(args[2], "com.example.MyTest"); + } + _ => panic!("Expected Gradle Other command"), + } + } }