94 lines
2.7 KiB
Python
94 lines
2.7 KiB
Python
#!/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".
|
|
|
|
Behavior:
|
|
- Reads hook input as JSON from stdin (per Anthropic spec).
|
|
- If tool is Edit/Write 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/"
|
|
PRINT_RE = re.compile(r"(?<!\w)print\s*\(")
|
|
|
|
|
|
def normalize_path(raw: str) -> str:
|
|
"""Convert absolute/relative path to repo-relative POSIX-like form."""
|
|
p = Path(raw).as_posix()
|
|
# Try to strip everything up to the last "backend/" occurrence.
|
|
idx = p.rfind("backend/")
|
|
return p[idx:] if idx >= 0 else p
|
|
|
|
|
|
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"}:
|
|
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`.
|
|
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
|
|
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())
|