From c238afbd4150ef53737f16e7dd84a565d2838ecc Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 23 Jul 2026 20:48:44 -0500 Subject: 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. --- languages/python/claude/hooks/validate-python.sh | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100755 languages/python/claude/hooks/validate-python.sh (limited to 'languages/python/claude/hooks/validate-python.sh') diff --git a/languages/python/claude/hooks/validate-python.sh b/languages/python/claude/hooks/validate-python.sh new file mode 100755 index 0000000..e43ad77 --- /dev/null +++ b/languages/python/claude/hooks/validate-python.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Validate Python files after Edit/Write/MultiEdit. +# PostToolUse hook: receives tool-call JSON on stdin. +# +# On success: exit 0 silent. +# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude +# sees a structured error in its context, THEN exit 2 to block the tool +# pipeline. stderr still echoes the error for terminal visibility. +# +# Phase 1: syntax — python3 compiles the file. Always available wherever this +# hook can meaningfully run, so it's the floor rather than an optional +# gate: a file that doesn't parse is never worth passing on. +# Phase 2: ruff — lint, when installed. Catches undefined names, unused +# imports, and the rest of the pyflakes set. Absent ruff doesn't block +# the edit, matching how the bash bundle treats shellcheck. +# +# Formatters (black, ruff format) are deliberately NOT enforced here. A project +# picks its own line length and quote style, so blocking on an unconfigured +# default would impose a contested choice. python.md recommends a formatter; +# this hook enforces correctness. +# +# Type checking (mypy, pyright) is also out: it needs the whole package and its +# dependencies resolved, which is a build-scale operation, not a per-keystroke +# one. Run it via `make lint` / `make typecheck`. +# +# Scope: .py and .pyi files, plus extensionless files whose first line is a +# python shebang (the CLI tools that fill a script-heavy repo carry no +# extension). + +set -u + +# Emit a JSON failure payload and exit 2. Arguments: +# $1 — short failure type (e.g. "PYTHON SYNTAX ERROR") +# $2 — file path +# $3 — tool output (error body) +fail_json() { + local ctx + ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \ + | jq -Rs .)" + cat <&2 + exit 2 +} + +f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" +[ -z "$f" ] && exit 0 +[ -f "$f" ] || exit 0 + +# Is this a Python file? By extension, or by shebang when it has no extension. +# Match on the basename, not the full path — a temp/parent dir can carry a dot +# (e.g. my.project/) and misfire the "*.*" extension test. +is_python=0 +base="${f##*/}" +case "$base" in + *.py | *.pyi) is_python=1 ;; + *.*) is_python=0 ;; # some other extension — not ours + *) + # No extension: sniff the shebang. + if head -1 "$f" 2>/dev/null | grep -qE '^#!.*\bpython[0-9.]*\b'; then + is_python=1 + fi + ;; +esac +[ "$is_python" -eq 1 ] || exit 0 + +# No python3 on this machine — nothing to validate, don't block the edit. +command -v python3 >/dev/null 2>&1 || exit 0 + +# --- Phase 1: syntax --- +# compile() rather than py_compile so no __pycache__ lands beside the source; +# the hook is a checker and must not leave build artifacts in the tree. +if ! out="$(python3 -c ' +import sys +p = sys.argv[1] +with open(p, "rb") as fh: + src = fh.read() +try: + compile(src, p, "exec") +except SyntaxError as e: + print(f"{e.msg} ({p}, line {e.lineno})", file=sys.stderr) + sys.exit(1) +' "$f" 2>&1)"; then + fail_json "PYTHON SYNTAX ERROR" "$f" "$out" +fi + +# --- Phase 2: lint (optional) --- +command -v ruff >/dev/null 2>&1 || exit 0 + +if ! out="$(ruff check "$f" 2>&1)"; then + fail_json "RUFF FAILED" "$f" "$out" +fi + +exit 0 -- cgit v1.2.3