Repository navigation
feat(hooks): task-boundary test discovery hook [OMN-7261] #1504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7923479
feat(hooks): add task-boundary test discovery hook [OMN-7261]
jonahgabriel 1a927a8
Merge remote-tracking branch 'origin/main' into jonah/omn-7261-task-b…
jonahgabriel 449b379
fix(OMN-7261): harden task-boundary hook gate
jonahgabriel 220f927
fix(OMN-7261): rely on subprocess test timeout
jonahgabriel 594ee6e
fix(OMN-7261): align task-boundary trigger operators
jonahgabriel 6185155
fix(OMN-7261): fail open when hook common loader is missing
jonahgabriel 5b807d1
fix(OMN-7261): harden task-boundary bootstrap failures
jonahgabriel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| #!/usr/bin/env python3 | ||
| # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. | ||
| # SPDX-License-Identifier: MIT | ||
| """Task-boundary test discovery and execution hook. | ||
|
|
||
| Fired on PreToolUse(Bash) when the command contains ``git commit`` or | ||
| ``gh pr create``. Discovers relevant tests for staged Python files and | ||
| runs them with a 120 s budget. Blocks the tool call on test failure; | ||
| fails open on timeout / infrastructure errors. | ||
|
|
||
| Exit codes: | ||
| 0 — all checks pass (or no tests applicable / infra failure) | ||
| 2 — test failures found (blocks the commit/PR) | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import json | ||
| import os | ||
| import re | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import time | ||
| from pathlib import Path | ||
|
|
||
| DEBOUNCE_SECONDS = 60 | ||
| TEST_TIMEOUT_SECONDS = 120 | ||
|
|
||
| _RE_GIT_COMMIT = re.compile(r"(^|\s|&&|\|\||;)\s*git\s+commit\b") | ||
| _RE_GH_PR_CREATE = re.compile(r"(^|\s|&&|\|\||\||;)\s*gh\s+pr\s+create\b") | ||
|
|
||
|
|
||
| def _is_trigger_command(command: str) -> bool: | ||
| return bool(_RE_GIT_COMMIT.search(command) or _RE_GH_PR_CREATE.search(command)) | ||
|
jonahgabriel marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def _repo_root_for(cwd: str | None) -> Path | None: | ||
| start = Path(cwd) if cwd else Path.cwd() | ||
| for p in [start, *start.parents]: | ||
| if (p / ".git").exists() or (p / ".git").is_file(): | ||
| return p | ||
| return None | ||
|
|
||
|
|
||
| def _staged_python_files(repo_root: Path) -> list[str]: | ||
| try: | ||
| r = subprocess.run( | ||
| ["git", "diff", "--cached", "--name-only", "--", "*.py"], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=repo_root, | ||
| timeout=5, | ||
| check=False, | ||
| ) | ||
| if r.returncode == 0: | ||
| return [f for f in r.stdout.strip().split("\n") if f] | ||
| except (subprocess.TimeoutExpired, Exception): | ||
| pass | ||
| return [] | ||
|
|
||
|
|
||
| def _discover_tests(repo_root: Path, changed_files: list[str]) -> list[str]: | ||
| tests_dir = repo_root / "tests" | ||
| if not tests_dir.is_dir(): | ||
| return [] | ||
|
|
||
| collected: list[str] = [] | ||
| for changed in changed_files: | ||
| stem = Path(changed).stem | ||
| candidates = [ | ||
| tests_dir / f"test_{stem}.py", | ||
| ] | ||
| parent = Path(changed).parent | ||
| if str(parent) != ".": | ||
| candidates.append(tests_dir / parent / f"test_{stem}.py") | ||
| candidates.append(tests_dir / "hooks" / f"test_{stem}.py") | ||
|
|
||
| for cand in candidates: | ||
| if cand.is_file() and str(cand) not in collected: | ||
| collected.append(str(cand)) | ||
|
|
||
| return collected | ||
|
|
||
|
|
||
| def _debounce_key(repo_root: Path) -> str: | ||
| h = hashlib.sha256(str(repo_root).encode()).hexdigest()[:12] | ||
| return os.path.join(tempfile.gettempdir(), f"onex_task_boundary_last_run_{h}") | ||
|
|
||
|
|
||
| def _should_skip_debounce(key: str) -> bool: | ||
| try: | ||
| p = Path(key) | ||
| if not p.exists(): | ||
| return False | ||
| mtime = p.stat().st_mtime | ||
| return (time.time() - mtime) < DEBOUNCE_SECONDS | ||
| except OSError: | ||
| return False | ||
|
|
||
|
|
||
| def _touch_debounce(key: str) -> None: | ||
| try: | ||
| Path(key).touch() | ||
| except OSError: | ||
| pass | ||
|
|
||
|
|
||
| def _run_tests(repo_root: Path, test_files: list[str]) -> tuple[bool, str]: | ||
| try: | ||
| r = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| "-m", | ||
| "pytest", | ||
| *test_files, | ||
| "-q", | ||
| "--tb=short", | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| cwd=repo_root, | ||
| timeout=TEST_TIMEOUT_SECONDS, | ||
| check=False, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ) | ||
| except subprocess.TimeoutExpired: | ||
| return True, "task-boundary-tests: timed out after 120s (failing open)" | ||
| except FileNotFoundError: | ||
| return True, "task-boundary-tests: pytest not found (failing open)" | ||
|
|
||
| if r.returncode == 0: | ||
| return True, "" | ||
|
|
||
| output = (r.stdout + "\n" + r.stderr).strip() | ||
| if len(output) > 2000: | ||
| output = output[:2000] + "\n... (truncated)" | ||
| return False, output | ||
|
|
||
|
|
||
| def main() -> None: | ||
| tool_info = json.loads(sys.stdin.read()) | ||
|
|
||
| tool_name = tool_info.get("tool_name", "") | ||
| if tool_name != "Bash": | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| command = tool_info.get("tool_input", {}).get("command", "") | ||
| if not _is_trigger_command(command): | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| cwd = tool_info.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") | ||
| repo_root = _repo_root_for(cwd) | ||
| if repo_root is None: | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| debounce_key = _debounce_key(repo_root) | ||
| if _should_skip_debounce(debounce_key): | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| changed = _staged_python_files(repo_root) | ||
| if not changed: | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| tests = _discover_tests(repo_root, changed) | ||
| if not tests: | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| passed, message = _run_tests(repo_root, tests) | ||
|
jonahgabriel marked this conversation as resolved.
|
||
| if passed: | ||
| _touch_debounce(debounce_key) | ||
| json.dump(tool_info, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
| result = { | ||
| "decision": "block", | ||
| "reason": "Task-boundary tests failed:\n" + message, | ||
| } | ||
| json.dump(result, sys.stdout) | ||
| sys.exit(2) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
168 changes: 168 additions & 0 deletions
168
plugins/onex/hooks/scripts/pre_tool_use_task_boundary_tests.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| #!/bin/bash | ||
| # SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. | ||
| # SPDX-License-Identifier: MIT | ||
| # PreToolUse Task-Boundary Tests Hook [OMN-7261] | ||
| # Intercepts git commit / gh pr create and runs targeted tests first. | ||
|
|
||
| set -euo pipefail | ||
| _OMNICLAUDE_HOOK_NAME="$(basename "${BASH_SOURCE[0]}")" | ||
| source "$(dirname "${BASH_SOURCE[0]}")/error-guard.sh" 2>/dev/null || true | ||
|
|
||
| _OMNICLAUDE_CALLER_CWD="${CLAUDE_PROJECT_DIR:-$PWD}" | ||
|
|
||
| # shellcheck source=../lib/repo_guard.sh | ||
| . "$(dirname "${BASH_SOURCE[0]}")/../lib/repo_guard.sh" 2>/dev/null || true | ||
| if declare -F is_omninode_repo >/dev/null 2>&1; then | ||
| CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$_OMNICLAUDE_CALLER_CWD}" \ | ||
| is_omninode_repo || { | ||
| _OMNICLAUDE_PASSTHROUGH=$(cat) | ||
| echo "$_OMNICLAUDE_PASSTHROUGH" | ||
| trap - EXIT 2>/dev/null || true | ||
| exit 0 | ||
| } | ||
| fi | ||
|
|
||
| cd "$HOME" 2>/dev/null || cd /tmp || true | ||
|
|
||
| if ! _SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \ | ||
| || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")"; then | ||
| _OMNICLAUDE_PASSTHROUGH=$(cat) | ||
| echo "$_OMNICLAUDE_PASSTHROUGH" | ||
| exit 0 | ||
| fi | ||
| if ! SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)"; then | ||
| _OMNICLAUDE_PASSTHROUGH=$(cat) | ||
| echo "$_OMNICLAUDE_PASSTHROUGH" | ||
| exit 0 | ||
| fi | ||
| if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then | ||
| PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" | ||
| elif ! PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"; then | ||
| _OMNICLAUDE_PASSTHROUGH=$(cat) | ||
| echo "$_OMNICLAUDE_PASSTHROUGH" | ||
| exit 0 | ||
| fi | ||
| unset _SELF | ||
| HOOKS_DIR="${PLUGIN_ROOT}/hooks" | ||
| HOOKS_LIB="${HOOKS_DIR}/lib" | ||
| TOOL_INFO=$(cat) | ||
| source "${SCRIPT_DIR}/onex-paths.sh" || { | ||
| echo "ONEX_STATE_DIR not set" >&2 | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| } | ||
| unset SCRIPT_DIR | ||
| LOG_FILE="${ONEX_HOOK_LOG}" | ||
|
|
||
| HOOK_BITS_PATH="${HOOKS_LIB}/hook_bits.sh" | ||
| if [[ -f "$HOOK_BITS_PATH" ]]; then | ||
| # shellcheck source=../lib/hook_bits.sh | ||
| if ! source "$HOOK_BITS_PATH"; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| TASK_BOUNDARY_BIT="$(hook_bits_bit_for_name TASK_BOUNDARY_TESTS 2>/dev/null || true)" | ||
| if [[ -n "$TASK_BOUNDARY_BIT" ]]; then | ||
| if [[ -z "${ONEX_HOOKS_MASK:-}" ]]; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| if ! TASK_BOUNDARY_MASK="$(hook_bits_parse_mask "$ONEX_HOOKS_MASK" 2>/dev/null)"; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| if [[ -z "$TASK_BOUNDARY_MASK" ]]; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| if ! hook_bits_is_enabled "$TASK_BOUNDARY_MASK" "$TASK_BOUNDARY_BIT"; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| fi | ||
| fi | ||
| unset HOOK_BITS_PATH TASK_BOUNDARY_BIT TASK_BOUNDARY_MASK | ||
|
|
||
| PROJECT_ROOT="${PLUGIN_ROOT}/../.." | ||
| if [[ -f "${PROJECT_ROOT}/.env" ]]; then | ||
| PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)" | ||
| elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then | ||
| PROJECT_ROOT="${CLAUDE_PROJECT_DIR}" | ||
| else | ||
| PROJECT_ROOT="$(pwd)" | ||
| fi | ||
|
|
||
| mkdir -p "$(dirname "$LOG_FILE")" | ||
|
|
||
| export PYTHONPATH="${PROJECT_ROOT}:${PLUGIN_ROOT}/lib:${HOOKS_LIB}:${PYTHONPATH:-}" | ||
|
|
||
| if [[ -f "$PROJECT_ROOT/.env" ]]; then | ||
| set -a | ||
| source "$PROJECT_ROOT/.env" 2>/dev/null || true | ||
| set +a | ||
| fi | ||
|
|
||
| COMMON_SH="${HOOKS_DIR}/scripts/common.sh" | ||
| if [[ ! -f "$COMMON_SH" ]]; then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: common.sh missing; failing open" >> "$LOG_FILE" | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| if ! source "$COMMON_SH"; then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: failed to source common.sh; failing open" >> "$LOG_FILE" | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| unset COMMON_SH | ||
| if ! TOOL_NAME=$(echo "$TOOL_INFO" | jq -er '.tool_name // empty' 2>>"$LOG_FILE"); then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: invalid hook JSON; failing open" >> "$LOG_FILE" | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
|
|
||
| if [[ "$TOOL_NAME" != "Bash" ]]; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
|
|
||
| COMMAND=$(echo "$TOOL_INFO" | jq -r '.tool_input.command // empty' 2>/dev/null) | ||
| if [[ -z "$COMMAND" ]]; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
|
|
||
| if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)git\s+commit(\s|$)'; then | ||
| if ! echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||\||;)gh\s+pr\s+create(\s|$)'; then | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
| fi | ||
|
|
||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests triggered" >> "$LOG_FILE" | ||
|
|
||
| RUNNER="${HOOKS_LIB}/task_boundary_tests.py" | ||
| if [[ ! -r "$RUNNER" ]]; then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: runner missing/unreadable, failing open" >> "$LOG_FILE" | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
|
|
||
| set +e | ||
| RESULT=$(echo "$TOOL_INFO" | \ | ||
| env -u PYTHONPATH "$PYTHON_CMD" "$RUNNER" 2>>"$LOG_FILE") | ||
| EXIT_CODE=$? | ||
| set -e | ||
| unset RUNNER | ||
|
|
||
| if [ $EXIT_CODE -eq 0 ]; then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests PASSED" >> "$LOG_FILE" | ||
| echo "$RESULT" | ||
| elif [ $EXIT_CODE -eq 2 ]; then | ||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Task-boundary tests BLOCKED" >> "$LOG_FILE" | ||
| echo "$RESULT" | ||
| exit 2 | ||
| else | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] ERROR: Task-boundary tests failed with code $EXIT_CODE, failing open" >> "$LOG_FILE" | ||
| echo "$TOOL_INFO" | ||
| exit 0 | ||
| fi | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.