From 578641ec896deb8f6a0e94e9a3089ec60bc92be0 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Thu, 18 Jun 2026 10:31:35 -0500 Subject: [PATCH] feat(hooks): add test-runner PostToolUse hook After editing a .groovy/.java file under modules/ or plugins/, map it to its test class and run it via gradle (e.g. CacheDB.groovy -> ":nextflow:test --tests *CacheDBTest"); test files run themselves. On failure, block and feed the failure summary back to Claude, falling back to the tail of the output when no failure lines match. Written in Bash; parses the payload with jq. Wired as a PostToolUse hook because it keys off tool_input.file_path, which is only present on Edit/Write/MultiEdit events. Scoping lives in the script (covers all three edit tools and both extensions with no gaps), so no declarative `if` is used here. Stacked on hooks-2-build-check. Signed-off-by: Edmund Miller --- .claude/README.md | 13 +++++++++ .claude/hooks/run-tests.sh | 56 ++++++++++++++++++++++++++++++++++++++ .claude/settings.json | 5 ++++ 3 files changed, 74 insertions(+) create mode 100755 .claude/hooks/run-tests.sh diff --git a/.claude/README.md b/.claude/README.md index ec4c6a5e95..128377e902 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -30,6 +30,19 @@ understood on its own. This file documents the hooks that are currently wired up - **Failure mode**: blocking — feeds a parsed error summary back to Claude so it can fix the breakage before yielding +### 3. Test runner (PostToolUse) +- **Script**: `hooks/run-tests.sh` +- **Trigger**: after an `Edit`/`Write`/`MultiEdit` on a `.groovy`/`.java` file under + `modules/` or `plugins/` +- **Action**: maps the edited file to its test class and runs it, e.g. + `modules/nextflow/.../cache/CacheDB.groovy` → `./gradlew :nextflow:test --tests "*CacheDBTest"` + (test files run themselves) +- **Failure mode**: blocking — feeds the test-failure summary back to Claude +- **Scoping**: the script itself decides what is testable (`.groovy`/`.java` under + `modules/` or `plugins/`, mapped to a gradle module). This covers all three edit tools + without gaps, so no declarative `if` is used here. Runs per edit, so the timeout is + generous (300s); narrow the matcher if per-edit test runs are too heavy. + ## Enabling / disabling Hooks are configured in `.claude/settings.json`. Disable one by removing its entry; diff --git a/.claude/hooks/run-tests.sh b/.claude/hooks/run-tests.sh new file mode 100755 index 0000000000..72c1cebdff --- /dev/null +++ b/.claude/hooks/run-tests.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Test runner hook for Nextflow development. +# +# After an Edit/Write/MultiEdit on a .groovy/.java file under modules/ or +# plugins/, map it to its test class and run it via gradle. A source file runs +# its corresponding *Test class; a test file runs itself. Block on failure. +# +# Reads the Claude Code hook payload as JSON on stdin (requires `jq`). + +set -uo pipefail + +command -v jq >/dev/null 2>&1 || exit 0 + +input=$(cat) +tool_name=$(jq -r '.tool_name // ""' <<<"$input") +file_path=$(jq -r '.tool_input.file_path // ""' <<<"$input") + +case "$tool_name" in + Edit|Write|MultiEdit) ;; + *) exit 0 ;; +esac + +[[ -n "$file_path" ]] || exit 0 +case "$file_path" in *.groovy|*.java) ;; *) exit 0 ;; esac +case "$file_path" in *modules/*|*plugins/*) ;; *) exit 0 ;; esac + +# Resolve the gradle module path. +module="" +case "$file_path" in + *plugins/nf-*) + plugin=$(sed -E 's#.*plugins/(nf-[^/]+)/.*#\1#' <<<"$file_path") + module="plugins:${plugin}" ;; + *modules/nextflow/*) module="nextflow" ;; + *modules/nf-commons/*) module="nf-commons" ;; + *modules/nf-httpfs/*) module="nf-httpfs" ;; + *modules/nf-lang/*) module="nf-lang" ;; + *modules/nf-lineage/*) module="nf-lineage" ;; +esac +[[ -n "$module" ]] || exit 0 + +class=$(basename "$file_path"); class="${class%.*}" +case "$class" in + *Test) test_class="$class" ;; + *) test_class="${class}Test" ;; +esac + +if out=$(./gradlew ":${module}:test" --tests "*${test_class}" 2>&1); then + jq -n --arg f "$(basename "$file_path")" \ + '{suppressOutput: true, systemMessage: ("✓ Tests passed for " + $f)}' +else + summary=$(printf '%s\n' "$out" | grep -iE 'failed|error|exception|assertion' | tail -n 5) + [[ -n "$summary" ]] || summary=$(printf '%s\n' "$out" | tail -n 15) + jq -n --arg f "$(basename "$file_path")" --arg s "$summary" \ + '{decision: "block", reason: ("Tests failed for " + $f + ":\n" + $s)}' +fi diff --git a/.claude/settings.json b/.claude/settings.json index 4550c77c28..2aca977c27 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,11 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-editorconfig.sh", "timeout": 30, "async": true + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh", + "timeout": 300 } ] }