Files
2026-07-13 12:42:18 +08:00

304 lines
9.0 KiB
Python

"""PreToolUse search enrichment for Claude Code hooks.
Intercepts Grep/Glob/Bash/Read tool calls and enriches them with
structural context from the code knowledge graph: callers, callees,
execution flows, community membership, and test coverage.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Flags that consume the next token in grep/rg commands
_RG_FLAGS_WITH_VALUES = frozenset({
"-e", "-f", "-m", "-A", "-B", "-C", "-g", "--glob",
"-t", "--type", "--include", "--exclude", "--max-count",
"--max-depth", "--max-filesize", "--color", "--colors",
"--context-separator", "--field-match-separator",
"--path-separator", "--replace", "--sort", "--sortr",
})
def extract_pattern(tool_name: str, tool_input: dict[str, Any]) -> str | None:
"""Extract a search pattern from a tool call's input.
Returns None if no meaningful pattern can be extracted.
"""
if tool_name == "Grep":
return tool_input.get("pattern")
if tool_name == "Glob":
raw = tool_input.get("pattern", "")
# Extract meaningful name from glob: "**/auth*.ts" -> "auth"
# Skip pure extension globs like "**/*.ts"
match = re.search(r"[*/]([a-zA-Z][a-zA-Z0-9_]{2,})", raw)
return match.group(1) if match else None
if tool_name == "Bash":
cmd = tool_input.get("command", "")
if not re.search(r"\brg\b|\bgrep\b", cmd):
return None
tokens = cmd.split()
found_cmd = False
skip_next = False
for token in tokens:
if skip_next:
skip_next = False
continue
if not found_cmd:
if re.search(r"\brg$|\bgrep$", token):
found_cmd = True
continue
if token.startswith("-"):
if token in _RG_FLAGS_WITH_VALUES:
skip_next = True
continue
cleaned = token.strip("'\"")
return cleaned if len(cleaned) >= 3 else None
return None
return None
def _make_relative(file_path: str, repo_root: str) -> str:
"""Make a file path relative to repo_root for display."""
try:
return str(Path(file_path).relative_to(repo_root))
except ValueError:
return file_path
def _get_community_name(conn: Any, community_id: int) -> str:
"""Fetch a community name by ID."""
row = conn.execute(
"SELECT name FROM communities WHERE id = ?", (community_id,)
).fetchone()
return row["name"] if row else ""
def _get_flow_names_for_node(conn: Any, node_id: int) -> list[str]:
"""Fetch execution flow names that a node participates in (max 3)."""
rows = conn.execute(
"SELECT f.name FROM flow_memberships fm "
"JOIN flows f ON fm.flow_id = f.id "
"WHERE fm.node_id = ? LIMIT 3",
(node_id,),
).fetchall()
return [r["name"] for r in rows]
def _format_node_context(
node: Any,
store: Any,
conn: Any,
repo_root: str,
) -> list[str]:
"""Format a single node's structural context as plain text lines."""
from .graph import GraphNode
assert isinstance(node, GraphNode)
qn = node.qualified_name
loc = _make_relative(node.file_path, repo_root)
if node.line_start:
loc = f"{loc}:{node.line_start}"
header = f"{node.name} ({loc})"
# Community
if node.extra.get("community_id"):
cname = _get_community_name(conn, node.extra["community_id"])
if cname:
header += f" [{cname}]"
else:
# Check via direct query
row = conn.execute(
"SELECT community_id FROM nodes WHERE id = ?", (node.id,)
).fetchone()
if row and row["community_id"]:
cname = _get_community_name(conn, row["community_id"])
if cname:
header += f" [{cname}]"
lines = [header]
# Callers (max 5, deduplicated)
callers: list[str] = []
seen: set[str] = set()
for e in store.get_edges_by_target(qn):
if e.kind == "CALLS" and len(callers) < 5:
c = store.get_node(e.source_qualified)
if c and c.name not in seen:
seen.add(c.name)
callers.append(c.name)
if callers:
lines.append(f" Called by: {', '.join(callers)}")
# Callees (max 5, deduplicated)
callees: list[str] = []
seen.clear()
for e in store.get_edges_by_source(qn):
if e.kind == "CALLS" and len(callees) < 5:
c = store.get_node(e.target_qualified)
if c and c.name not in seen:
seen.add(c.name)
callees.append(c.name)
if callees:
lines.append(f" Calls: {', '.join(callees)}")
# Execution flows
flow_names = _get_flow_names_for_node(conn, node.id)
if flow_names:
lines.append(f" Flows: {', '.join(flow_names)}")
# Tests
tests: list[str] = []
for e in store.get_edges_by_target(qn):
if e.kind == "TESTED_BY" and len(tests) < 3:
t = store.get_node(e.source_qualified)
if t:
tests.append(t.name)
if tests:
lines.append(f" Tests: {', '.join(tests)}")
return lines
def enrich_search(pattern: str, repo_root: str) -> str:
"""Search the graph for pattern and return enriched context."""
from .graph import GraphStore
from .search import _fts_search
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return ""
store = GraphStore(db_path)
try:
conn = store._conn
fts_results = _fts_search(conn, pattern, limit=8)
if not fts_results:
return ""
all_lines: list[str] = []
count = 0
for node_id, _score in fts_results:
if count >= 5:
break
node = store.get_node_by_id(node_id)
if not node or node.is_test:
continue
node_lines = _format_node_context(node, store, conn, repo_root)
all_lines.extend(node_lines)
all_lines.append("")
count += 1
if not all_lines:
return ""
header = f'[code-review-graph] {count} symbol(s) matching "{pattern}":\n'
return header + "\n".join(all_lines)
finally:
store.close()
def enrich_file_read(file_path: str, repo_root: str) -> str:
"""Enrich a file read with structural context for functions in that file."""
from .graph import GraphStore
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return ""
store = GraphStore(db_path)
try:
conn = store._conn
nodes = store.get_nodes_by_file(file_path)
if not nodes:
# Try with resolved path
try:
resolved = str(Path(file_path).resolve())
nodes = store.get_nodes_by_file(resolved)
except (OSError, ValueError):
pass
if not nodes:
return ""
# Filter to functions/classes/types (skip File nodes), limit to 10
interesting = [
n for n in nodes
if n.kind in ("Function", "Class", "Type", "Test")
][:10]
if not interesting:
return ""
all_lines: list[str] = []
for node in interesting:
node_lines = _format_node_context(node, store, conn, repo_root)
all_lines.extend(node_lines)
all_lines.append("")
rel_path = _make_relative(file_path, repo_root)
header = (
f"[code-review-graph] {len(interesting)} symbol(s) in {rel_path}:\n"
)
return header + "\n".join(all_lines)
finally:
store.close()
def run_hook() -> None:
"""Entry point for the enrich CLI subcommand.
Reads Claude Code hook JSON from stdin, extracts the search pattern,
queries the graph, and outputs hookSpecificOutput JSON to stdout.
"""
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
tool_name = hook_input.get("tool_name", "")
tool_input = hook_input.get("tool_input", {})
cwd = hook_input.get("cwd", os.getcwd())
# Find repo root by walking up from cwd
from .incremental import find_project_root
repo_root = str(find_project_root(Path(cwd)))
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return
# Dispatch
context = ""
if tool_name == "Read":
fp = tool_input.get("file_path", "")
if fp:
context = enrich_file_read(fp, repo_root)
else:
pattern = extract_pattern(tool_name, tool_input)
if not pattern or len(pattern) < 3:
return
context = enrich_search(pattern, repo_root)
if not context:
return
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": context,
}
}
json.dump(response, sys.stdout)