#!/usr/bin/env bats # # Tests for languages/python/claude/hooks/validate-python.sh — the PostToolUse # hook that syntax-checks edited Python files and blocks on a violation. # # The hook reads tool-call JSON on stdin and extracts the file path, so each # test pipes a JSON payload naming a real file it wrote into a temp dir. # # The syntax gate is python3's own compiler, which is present wherever the hook # can meaningfully run, so those tests never skip. The lint gate (ruff) is # optional and its tests skip when it's absent, matching the bash bundle's # treatment of shellcheck. HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/claude/hooks/validate-python.sh" setup() { TEST_DIR="$(mktemp -d -t validate-python-bats.XXXXXX)" } teardown() { rm -rf "$TEST_DIR" } payload() { printf '{"tool_input": {"file_path": "%s"}}' "$1" } # ---- Normal ---------------------------------------------------------- @test "validate-python: a clean .py file passes silently (exit 0)" { printf 'def f(x):\n return x + 1\n' > "$TEST_DIR/clean.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")" [ "$status" -eq 0 ] [ -z "$output" ] } @test "validate-python: a .pyi stub is validated too" { printf 'def f(x: int) -> int: ...\n' > "$TEST_DIR/clean.pyi" run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.pyi")" [ "$status" -eq 0 ] } # ---- Error ----------------------------------------------------------- @test "validate-python: a syntax error blocks (exit 2, names the failure)" { printf 'def f(:\n return 1\n' > "$TEST_DIR/bad.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")" [ "$status" -eq 2 ] [[ "$output" == *"SYNTAX"* ]] } @test "validate-python: the block payload is valid JSON carrying the context" { printf 'def f(:\n' > "$TEST_DIR/bad.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")" [ "$status" -eq 2 ] # The first line of stdout must parse as JSON and carry the hook event name. echo "$output" | head -1 | jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"' } @test "validate-python: a ruff violation blocks when ruff is installed" { command -v ruff >/dev/null 2>&1 || skip "ruff not installed" # F821: reference to an undefined name — syntactically valid, lint-caught. printf 'def f():\n return undefined_name\n' > "$TEST_DIR/lint.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/lint.py")" [ "$status" -eq 2 ] [[ "$output" == *"RUFF"* ]] } # ---- Boundary -------------------------------------------------------- @test "validate-python: a non-Python file is ignored (exit 0)" { printf 'not python at all (((\n' > "$TEST_DIR/notes.txt" run bash "$HOOK" <<< "$(payload "$TEST_DIR/notes.txt")" [ "$status" -eq 0 ] } @test "validate-python: an extensionless file with a python shebang is validated" { printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/cli-tool" run bash "$HOOK" <<< "$(payload "$TEST_DIR/cli-tool")" [ "$status" -eq 2 ] } @test "validate-python: an extensionless non-python file is ignored (exit 0)" { printf '#!/usr/bin/env bash\necho hi\n' > "$TEST_DIR/shell-tool" run bash "$HOOK" <<< "$(payload "$TEST_DIR/shell-tool")" [ "$status" -eq 0 ] } @test "validate-python: a dotted parent directory does not misfire the extension test" { mkdir -p "$TEST_DIR/my.project" printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/my.project/cli-tool" run bash "$HOOK" <<< "$(payload "$TEST_DIR/my.project/cli-tool")" [ "$status" -eq 2 ] } @test "validate-python: empty file_path is a no-op (exit 0)" { run bash "$HOOK" <<< '{"tool_input": {}}' [ "$status" -eq 0 ] } @test "validate-python: a missing file is a no-op (exit 0)" { run bash "$HOOK" <<< "$(payload "$TEST_DIR/does-not-exist.py")" [ "$status" -eq 0 ] } @test "validate-python: an empty .py file passes (valid, compiles to nothing)" { : > "$TEST_DIR/empty.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/empty.py")" [ "$status" -eq 0 ] } @test "validate-python: compiling leaves no __pycache__ beside the file" { printf 'def f():\n return 1\n' > "$TEST_DIR/clean.py" run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")" [ "$status" -eq 0 ] [ ! -d "$TEST_DIR/__pycache__" ] }