aboutsummaryrefslogtreecommitdiff
path: root/languages/python/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-23 20:48:44 -0500
committerCraig Jennings <c@cjennings.net>2026-07-23 20:48:44 -0500
commitc238afbd4150ef53737f16e7dd84a565d2838ecc (patch)
tree588a25dc318cb854c57d8146639d400674baed18 /languages/python/tests
parent10ea44b6de3be1872f7f0bd4501ccf3878105bc4 (diff)
downloadrulesets-c238afbd4150ef53737f16e7dd84a565d2838ecc.tar.gz
rulesets-c238afbd4150ef53737f16e7dd84a565d2838ecc.zip
feat(languages): ship the missing python and typescript hooks
The python and typescript bundles carried rules and a coverage script but no pre-commit hook, so any project installing one got no credential scan on commit. Both now ship all four components the README documents: the shared secret scan, a validate-on-edit hook, settings wiring, and a seed CLAUDE.md. Verified against a real repo: a commit carrying an AWS key is refused. install-lang now warns when a bundle is missing a documented component. That's the half that keeps this from recurring. Whoever adds the sixth bundle will forget something too, and today the installer prints success either way. Two things fell out of the build. node --check is unusable on TypeScript: it ignores --experimental-strip-types, so it rejects valid TS and accepts broken TS. The hook uses tsc filtered to syntactic diagnostics instead. And completing the bundles means every pair now collides on settings.json and pre-commit, so no two compose without FORCE=1. The earlier "bundles already compose" reading rested on these two being incomplete. Filed for a real decision; clock-panel is the project that wants it. Also stamps :LAST_REVIEWED: at task creation, with a lint checker to catch misses. Writing a task is reviewing it, so leaving the stamp off pushed every fresh task to the top of the next review batch to be re-derived by someone with less context than its author had. Tonight's sweep sent the staleness count from 13 to 22 while the list got more accurate.
Diffstat (limited to 'languages/python/tests')
-rw-r--r--languages/python/tests/pre-commit.bats138
-rw-r--r--languages/python/tests/validate-python.bats117
2 files changed, 255 insertions, 0 deletions
diff --git a/languages/python/tests/pre-commit.bats b/languages/python/tests/pre-commit.bats
new file mode 100644
index 0000000..1ac82ee
--- /dev/null
+++ b/languages/python/tests/pre-commit.bats
@@ -0,0 +1,138 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/python/githooks/pre-commit — the secret scan plus
+# syntax/lint gate that runs on staged Python files.
+#
+# The secret scan is the security-critical half and is language-independent, so
+# it gets the same coverage here as in the bash bundle: a real key blocks, a
+# clean diff passes, and the case-sensitivity split that keeps base64 blobs from
+# false-positiving is exercised directly.
+#
+# Each test builds a throwaway git repo, stages content, and runs the hook from
+# inside it — the hook reads `git diff --cached`, so a real index is required.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/githooks/pre-commit"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t pre-commit-py-bats.XXXXXX)"
+ cd "$TEST_DIR" || exit 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+ # A base commit so `git diff --cached` has a parent to diff against.
+ echo "seed" > seed.txt
+ git add seed.txt
+ git commit -qm seed
+}
+
+teardown() {
+ cd / || true
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "pre-commit(py): a clean staged Python file passes (exit 0)" {
+ printf 'def f(x):\n return x + 1\n' > ok.py
+ git add ok.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): an empty staging area passes (exit 0)" {
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the secret scan ------------------------------------------
+
+@test "pre-commit(py): an AWS key in a staged file blocks (exit 1)" {
+ printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "pre-commit(py): an sk- style token blocks (exit 1)" {
+ printf 'TOKEN = "sk-abcdefghijklmnopqrstuvwxyz0123"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a quoted api_key assignment blocks (exit 1)" {
+ printf 'api_key = "abcdefghijklmnopqrstuvwxyz"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a private-key header blocks (exit 1)" {
+ printf 'PEM = """-----BEGIN RSA PRIVATE KEY-----"""\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+# ---- Boundary: the case-sensitivity split ----------------------------
+
+@test "pre-commit(py): a mixed-case base64 blob does NOT false-positive" {
+ # The AWS pattern is uppercase-only by design. Under -i it would match any
+ # 20-char mixed-case run, which random base64 hits often enough to block
+ # real commits. This is the regression test for that split.
+ printf 'BLOB = "AKIAbcdefGHIJklmnOPqr0123456789abcdefGHIJ"\n' > data.py
+ git add data.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): a short quoted password value does NOT block" {
+ # The keyword patterns require 16+ chars, so a placeholder stays quiet.
+ printf 'password = "short"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): a secret only in a REMOVED line does not block" {
+ printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py
+ git add conf.py
+ git commit -qm "add key"
+ rm conf.py
+ git add -A
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the syntax gate ------------------------------------------
+
+@test "pre-commit(py): a staged Python syntax error blocks (exit 1)" {
+ printf 'def f(:\n return 1\n' > bad.py
+ git add bad.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"syntax"* ]]
+}
+
+@test "pre-commit(py): a .pyi stub with a syntax error blocks (exit 1)" {
+ printf 'def f( -> int: ...\n' > bad.pyi
+ git add bad.pyi
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a broken NON-Python file does not trip the syntax gate" {
+ printf 'this is (((not python\n' > notes.txt
+ git add notes.txt
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): the syntax gate leaves no __pycache__ in the repo" {
+ printf 'def f():\n return 1\n' > ok.py
+ git add ok.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+ [ ! -d __pycache__ ]
+}
diff --git a/languages/python/tests/validate-python.bats b/languages/python/tests/validate-python.bats
new file mode 100644
index 0000000..b5e4957
--- /dev/null
+++ b/languages/python/tests/validate-python.bats
@@ -0,0 +1,117 @@
+#!/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__" ]
+}