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 } ] }