Last active
February 23, 2026 17:55
-
-
Save ondrasek/f796e3c3321fe0033845994f5406eb0d to your computer and use it in GitHub Desktop.
Python quality gate for Claude Code — self-updating Stop hook with 14 fail-fast checks
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
| #!/usr/bin/env bash | |
| # Quality gate hook for Claude Code Stop event | |
| # Fail-fast: stops at the first failing check, outputs its full stderr/stdout. | |
| # Exit 2 feeds stderr to Claude for automatic fixing. | |
| # | |
| # Repo-agnostic: scans all Python code in the repository. Each tool uses its | |
| # own config (pyproject.toml, pyrightconfig.json, etc.) for includes/excludes. | |
| # | |
| # Self-updating: checks a canonical GitHub gist for newer versions using ETags. | |
| # The .etag file is committed alongside this script as a version lock. | |
| set -o pipefail | |
| cd "$CLAUDE_PROJECT_DIR" || exit 0 | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| SCRIPT_PATH="${SCRIPT_DIR}/quality-gate.sh" | |
| ETAG_FILE="${SCRIPT_DIR}/quality-gate.sh.etag" | |
| GIST_RAW_URL="https://gist.githubusercontent.com/ondrasek/f796e3c3321fe0033845994f5406eb0d/raw/quality-gate.sh" | |
| CHECK_INTERVAL=3600 # 60 minutes | |
| HOOK_LOG="${CLAUDE_PROJECT_DIR}/.claude/hooks/hook-debug.log" | |
| mkdir -p "$(dirname "$HOOK_LOG")" 2>/dev/null | |
| debuglog() { | |
| echo "[quality-gate] $(date '+%Y-%m-%d %H:%M:%S') $1" >> "$HOOK_LOG" | |
| } | |
| debuglog "=== HOOK STARTED (pid=$$) ===" | |
| # ─── Self-update check ─────────────────────────────────────────────────────── | |
| # Uses ETag-based conditional GET against a GitHub gist. The .etag file serves | |
| # double duty: its content is the ETag, its mtime is the last-check timestamp. | |
| # Rate-limited to once per CHECK_INTERVAL seconds via mtime comparison. | |
| check_for_update() { | |
| # Skip if gist URL hasn't been configured yet | |
| [[ "$GIST_RAW_URL" == *"GIST_ID"* ]] && return 0 | |
| # Rate limit: check mtime of .etag file | |
| if [[ -f "$ETAG_FILE" ]]; then | |
| local now last_modified age | |
| now=$(date +%s) | |
| last_modified=$(stat -f %m "$ETAG_FILE" 2>/dev/null || stat -c %Y "$ETAG_FILE" 2>/dev/null) || return 0 | |
| age=$(( now - last_modified )) | |
| if (( age < CHECK_INTERVAL )); then | |
| debuglog "Update check skipped (last check ${age}s ago)" | |
| return 0 | |
| fi | |
| fi | |
| local etag="" | |
| [[ -f "$ETAG_FILE" ]] && etag=$(cat "$ETAG_FILE") | |
| local header_args=() | |
| [[ -n "$etag" ]] && header_args=(-H "If-None-Match: $etag") | |
| local headers_tmp="${TMPDIR:-/tmp}/qg-headers.$$" | |
| local status | |
| status=$(curl -sf --max-time 5 -o /dev/null -w "%{http_code}" \ | |
| -D "$headers_tmp" "${header_args[@]}" "$GIST_RAW_URL") || { | |
| rm -f "$headers_tmp" | |
| debuglog "Update check failed (network error), skipping" | |
| return 0 | |
| } | |
| if [[ "$status" == "200" ]]; then | |
| # New version available — extract new ETag for the update message | |
| local new_etag | |
| new_etag=$(grep -i '^etag:' "$headers_tmp" | tr -d '\r' | awk '{print $2}') | |
| rm -f "$headers_tmp" | |
| debuglog "Update available (new etag: $new_etag)" | |
| echo "" >&2 | |
| echo "QUALITY GATE UPDATE AVAILABLE:" >&2 | |
| echo "" >&2 | |
| echo "A newer version of quality-gate.sh is available from the canonical gist." >&2 | |
| echo "" >&2 | |
| echo "ACTION REQUIRED: Update the quality gate before proceeding." >&2 | |
| echo " 1. Download the new script:" >&2 | |
| echo " curl -sf '$GIST_RAW_URL' > '$SCRIPT_PATH'" >&2 | |
| echo " 2. Make it executable:" >&2 | |
| echo " chmod +x '$SCRIPT_PATH'" >&2 | |
| echo " 3. Save the new ETag:" >&2 | |
| echo " echo '$new_etag' > '$ETAG_FILE'" >&2 | |
| echo " 4. Stage and commit both files." >&2 | |
| echo "" >&2 | |
| exit 2 | |
| fi | |
| # 304 = current, any other status = skip silently | |
| rm -f "$headers_tmp" | |
| touch "$ETAG_FILE" # Update mtime for rate limiting | |
| debuglog "Update check complete (status=$status, up to date)" | |
| } | |
| check_for_update | |
| # Skip entirely if no Python files exist | |
| if ! find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' -print -quit 2>/dev/null | grep -q .; then | |
| debuglog "No Python files found, skipping quality gate" | |
| exit 0 | |
| fi | |
| # Per-tool diagnostic hints. Keyed by the NAME passed to run_check/run_check_nonempty. | |
| # These tell Claude how to investigate and fix each type of failure. | |
| declare -A TOOL_HINTS | |
| TOOL_HINTS=( | |
| [pytest]="Read the failing test file and the source it tests. Run 'uv run pytest path/to/test_file.py::TestClass::test_name -x --tb=long' to see the full traceback. Fix the source code, not the test, unless the test itself is wrong." | |
| [coverage]="Run 'uv run pytest --cov --cov-report=term-missing' to see which lines are uncovered. Add tests for the uncovered code paths. Configure [tool.coverage.run] in pyproject.toml to set source directories." | |
| [ruff check]="Run 'uv run ruff check --output-format=full' for detailed explanations. Most issues are auto-fixable with 'uv run ruff check --fix'. Read the file at the reported line before editing." | |
| [ruff format]="Run 'uv run ruff format' to auto-fix all formatting issues." | |
| [pyright]="Read the file at the reported line number. Check type annotations, imports, and function signatures. Run 'uv run pyright path/to/file.py' to re-check a single file after fixing." | |
| [mypy]="Read the file at the reported line number. Fix type annotations — add missing type params, annotate untyped defs, fix incompatible assignments. Run 'uv run mypy path/to/file.py' to re-check a single file after fixing." | |
| [bandit]="Read the flagged code. Common fixes: use 'secrets' module instead of random for security, avoid shell=True in subprocess calls, use parameterized queries for SQL. Run 'uv run bandit -r . -ll --format custom --msg-template \"{relpath}:{line} {test_id} {msg}\"' for concise output." | |
| [vulture]="The reported code is detected as unused (dead code). Read the file to verify it is truly unused. If it is, delete it. If it's used dynamically (e.g. via getattr or as a public API), add it to a vulture whitelist." | |
| [xenon]="The reported function has cyclomatic complexity rank C or worse (CC > 10). Read the function and extract helper functions to reduce branching. Each 'if', 'elif', 'for', 'while', 'and', 'or', 'except', ternary, and comprehension-if adds +1 CC. Target: every function at rank B or better (CC <= 10)." | |
| [refurb]="Run 'uv run refurb --explain ERRCODE' (e.g. 'uv run refurb --explain FURB123') to understand the suggested modernization. These are usually simple one-line replacements. Read the file at the reported line, apply the suggested fix." | |
| [import-linter]="Check the import layering rules in pyproject.toml under [tool.importlinter]. The error shows which import violates the dependency contract. Fix by restructuring the import or moving code to the correct layer." | |
| [ty]="Read the file at the reported line. Fix type errors — check annotations, return types, and argument types. Run 'uv run ty check path/to/file.py' to re-check a single file." | |
| [interrogate]="The reported module or function is missing a docstring. Add a one-line docstring to each flagged public function/class/module. Run 'uv run interrogate . -v --fail-under 70' to see which are missing." | |
| [style-guide]="CLI output formatting must follow the style guide: section headings use emoji + click.style(ALL CAPS text, fg=COLOR, bold=True). No ASCII splitter lines (===, ---, ***). Fix by replacing raw print/echo of splitter lines with styled headings." | |
| ) | |
| fail() { | |
| local name="$1" | |
| local cmd="$2" | |
| local output="$3" | |
| local hint="${TOOL_HINTS[$name]:-}" | |
| echo "" >&2 | |
| echo "QUALITY GATE FAILED [$name]:" >&2 | |
| echo "Command: $cmd" >&2 | |
| echo "" >&2 | |
| echo "$output" >&2 | |
| echo "" >&2 | |
| if [ -n "$hint" ]; then | |
| echo "Hint: $hint" >&2 | |
| echo "" >&2 | |
| fi | |
| echo "ACTION REQUIRED: You MUST fix the issue shown above. Do NOT stop or explain — read the failing file, edit the source code to resolve it, and the quality gate will re-run automatically." >&2 | |
| debuglog "=== FAILED: $name ===" | |
| exit 2 | |
| } | |
| # run_check NAME COMMAND... | |
| # Runs the command, fails fast if exit code is non-zero. | |
| run_check() { | |
| local name="$1"; shift | |
| local cmd="$*" | |
| debuglog "Running $name..." | |
| OUTPUT=$("$@" 2>&1) || fail "$name" "$cmd" "$OUTPUT" | |
| } | |
| # run_check_nonempty NAME COMMAND... | |
| # Runs the command, fails fast if exit code is non-zero AND there is output. | |
| # Used for tools like vulture/refurb where exit 0 with no output = clean. | |
| run_check_nonempty() { | |
| local name="$1"; shift | |
| local cmd="$*" | |
| debuglog "Running $name..." | |
| OUTPUT=$("$@" 2>&1) | |
| [ -n "$OUTPUT" ] && fail "$name" "$cmd" "$OUTPUT" | |
| } | |
| # ─── Style guide check (inline) ────────────────────────────────────────────── | |
| # Only runs if the project uses click/typer for CLI output. | |
| # Rules: | |
| # 1. No ASCII art splitter lines (===, ---, ***) in click.echo/print calls | |
| # 2. Section headings must use click.style() with bold=True and a color | |
| check_style_guide() { | |
| # Find Python files that import click | |
| local CLI_FILES=() | |
| while IFS= read -r -d '' f; do | |
| if grep -qE '(import click|from click)' "$f" 2>/dev/null; then | |
| CLI_FILES+=("$f") | |
| fi | |
| done < <(find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' -print0 2>/dev/null) | |
| # No click files — skip silently | |
| [ ${#CLI_FILES[@]} -eq 0 ] && return 0 | |
| local ERRORS=() | |
| for f in "${CLI_FILES[@]}"; do | |
| [ -f "$f" ] || continue | |
| local relpath="${f#./}" | |
| # Rule 1: No ASCII splitter lines in echo/print calls | |
| while IFS= read -r match; do | |
| ERRORS+=("$relpath: ASCII splitter line — use emoji + click.style(ALL CAPS, bold=True) instead: $match") | |
| done < <(grep -nE '(echo|print)\(.*"[=\-\*]{3,}' "$f" 2>/dev/null || true) | |
| # Rule 2: Section heading echo() calls should use click.style with bold | |
| while IFS= read -r match; do | |
| if echo "$match" | grep -q 'click\.style'; then | |
| continue | |
| fi | |
| ERRORS+=("$relpath: Unstyled ALL-CAPS heading — wrap with click.style(..., bold=True, fg=COLOR): $match") | |
| done < <(grep -nE 'click\.echo\("[^"]*[A-Z]{3,}[^"]*"\)' "$f" 2>/dev/null || true) | |
| done | |
| if [ ${#ERRORS[@]} -gt 0 ]; then | |
| local output="STYLE GUIDE VIOLATIONS:\n\n" | |
| for err in "${ERRORS[@]}"; do | |
| output+=" - $err\n" | |
| done | |
| output+="\nDesign rules: Section headings must use emoji + click.style(ALL CAPS text, fg=COLOR, bold=True). No ASCII splitter lines (===, ---, ***)." | |
| printf '%b' "$output" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| # ─── Checks ordered by speed and likelihood of failure ──────────────────────── | |
| # Skip pytest/coverage if no test files exist | |
| TEST_FILES=$(find . -name "test_*.py" -o -name "*_test.py" 2>/dev/null | grep -v ".venv" | head -1) | |
| if [ -n "$TEST_FILES" ]; then | |
| run_check "pytest" uv run pytest -x --tb=short -m "not slow" | |
| run_check "coverage" uv run pytest --cov --cov-report=term --cov-fail-under=80 -q -m "not slow" | |
| else | |
| debuglog "Skipping pytest/coverage (no test files found)" | |
| fi | |
| run_check "ruff check" uv run ruff check . | |
| run_check "ruff format" uv run ruff format --check . | |
| run_check "pyright" uv run pyright . | |
| run_check "mypy" uv run mypy . | |
| run_check "bandit" uv run bandit -r . -q -ll | |
| run_check_nonempty "vulture" uv run vulture . --min-confidence 80 | |
| run_check "xenon" uv run xenon --max-absolute B --max-modules A --max-average A . | |
| run_check_nonempty "refurb" uv run refurb . | |
| run_check "import-linter" uv run lint-imports | |
| run_check "ty" uv run ty check . | |
| run_check "interrogate" uv run interrogate . -v --fail-under 70 | |
| # Style guide runs as a function — call directly, capture output | |
| debuglog "Running style-guide..." | |
| STYLE_OUTPUT=$(check_style_guide 2>&1) | |
| if [ -n "$STYLE_OUTPUT" ]; then | |
| fail "style-guide" "check_style_guide (inline)" "$STYLE_OUTPUT" | |
| fi | |
| debuglog "=== ALL 14 CHECKS PASSED ===" | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment