#!/usr/bin/env python3 """ Claude Code PostToolUse hook — blocks new `print(` calls in backend/**/*.py. Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write|MultiEdit". Behavior: - Reads hook input as JSON from stdin (per Anthropic spec). - If tool is Edit/Write/MultiEdit and file_path matches backend/**/*.py: - Scans new content for new `print(` calls. - Exits with code 2 to block (stderr is forwarded to Claude as feedback). - Otherwise exits 0 (no-op). Notes: - Allows `print(` inside lines containing `# noqa` (escape hatch). - Skips files under `backend/tests/**` (test code may use print for debugging). - Skips `backend/scripts/**` (one-off scripts). """ from __future__ import annotations import json import re import sys from pathlib import Path ALLOW_PATH_PREFIXES = ( "backend/tests/", "backend/scripts/", ) WATCH_PATH_PREFIX = "backend/" # Lookbehind `(? str: """Convert absolute/relative path to repo-relative POSIX-like form.""" parts = Path(raw).parts # Match the repo-root segment "backend" exactly (not a substring like # "services-backend"), and anchor on the FIRST such segment so a nested # "backend" dir does not drop the leading path components. try: idx = parts.index("backend") except ValueError: return Path(raw).as_posix() return Path(*parts[idx:]).as_posix() def main() -> int: try: payload = json.load(sys.stdin) except Exception: # No input or malformed — skip silently. return 0 tool_name = payload.get("tool_name") or payload.get("tool") or "" if tool_name not in {"Edit", "Write", "MultiEdit"}: return 0 tool_input = payload.get("tool_input") or {} file_path = tool_input.get("file_path") or tool_input.get("path") or "" if not file_path: return 0 norm = normalize_path(file_path) if not norm.startswith(WATCH_PATH_PREFIX): return 0 if any(norm.startswith(p) for p in ALLOW_PATH_PREFIXES): return 0 if not norm.endswith(".py"): return 0 # Pull the new content. Edit gives `new_string`, Write gives `content`, # MultiEdit gives an `edits` array of `{old_string, new_string}` entries. edits = tool_input.get("edits") if isinstance(edits, list): new_text = "\n".join( str(e.get("new_string") or "") for e in edits if isinstance(e, dict) ) else: new_text = tool_input.get("new_string") or tool_input.get("content") or "" if not new_text: return 0 offenders: list[tuple[int, str]] = [] for lineno, line in enumerate(new_text.splitlines(), start=1): if "# noqa" in line: continue # Skip whole-line comments (e.g. `# print(debug)`); regex on raw # lines cannot see the surrounding syntax otherwise. if line.lstrip().startswith("#"): continue if PRINT_RE.search(line): offenders.append((lineno, line.strip())) if not offenders: return 0 sample = offenders[0] msg_lines = [ f"Blocked: `print(` introduced in {norm}.", f"Line {sample[0]}: {sample[1][:120]}", "GenDesign convention: use `logger.info/warning/error` instead of print().", "If this is intentional one-off debug, add `# noqa` on the line and retry.", ] print("\n".join(msg_lines), file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main())