#!/usr/bin/env python3 """ Claude Code PostToolUse hook — blocks `import psycopg2` in backend Python. GenDesign uses psycopg v3 (`psycopg[binary]>=3.2.0`); psycopg2 is NOT installed, so `import psycopg2` is a guaranteed `ModuleNotFoundError` at runtime — a recurring bug class (see `.claude/rules/backend.md`). This catches it at edit-time, not on CI. NOTE: the related `:param::type` cast trap is deliberately NOT checked here — the codebase documents that anti-pattern in dozens of comments/test-docstrings ("never :param::type"), so a regex would false-positive constantly. That trap is already covered by dedicated unit tests. Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write|MultiEdit". Watches `backend/**/*.py` (also `tradein-mvp/backend/**/*.py`, normalized to `backend/`). """ from __future__ import annotations import json import re import sys from pathlib import Path WATCH_PATH_PREFIX = "backend/" # Real import statement at line start (after optional indent); not a comment or a # docstring sentence like "never import psycopg2" (which starts with a word, not `import`). PSYCOPG2_RE = re.compile( r"(?m)^\s*(?:import\s+psycopg2|from\s+psycopg2(?:\.[\w.]+)?\s+import)\b" ) def normalize_path(raw: str) -> str: """Repo-relative POSIX path anchored on the first `backend` segment.""" parts = Path(raw).parts 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: 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) or not norm.endswith(".py"): return 0 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 for lineno, line in enumerate(new_text.splitlines(), start=1): if line.lstrip().startswith("#"): continue if PSYCOPG2_RE.match(line): msg = [ f"Blocked: `import psycopg2` в {norm} (line {lineno}).", "psycopg2 НЕ установлен → ModuleNotFoundError. Используй `import psycopg` (v3).", "Bulk INSERT: `cur.executemany()` / COPY (НЕ execute_values). См. .claude/rules/backend.md.", ] print("\n".join(msg), file=sys.stderr) return 2 return 0 if __name__ == "__main__": sys.exit(main())