gendesign/_wt-cadval/scripts/claude-hooks/check-sql-pitfalls.py
bot-backend 542ff1c9ca docs(tradein/domclick): два комментария описывали пул, которого нет с миграции 253
Оба места утверждали, что у Домклика один выделенный резидентный прокси и
пула нет. Это перестало быть правдой ещё в #2800 (миграция 253 сняла
резервацию узла), но текст остался — и именно на него опирался тикет #3189,
поставленный под «калибровку» ограничения, которого не существует.
Свип к тому же ходит через пул давно (serp.py:359), а бэкфилл подключён к
нему в PR #3222.

Заодно докстринг называл не тот ограничитель: свип кладёт не счётчик
провалов lease, а break по первому DomClickBlockedError (#2854) — до
ротации дело не доходит ни при каком счётчике, отсюда buckets_completed=0.

Только комментарии, поведение не меняется. Миграция 175 уже применена, а
_schema_migrations трекает по имени файла без checksum — правка текста
её не перезапустит.
2026-08-29 18:57:25 +03:00

87 lines
3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())