304 lines
9.0 KiB
Python
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)
|