222c41dd16
Relocates the graphify BFS/path query script from scripts/ (a PowerShell folder) into a new tools/ directory for Python developer utilities. The script is not part of the CI pipeline — it's a local query tool for navigating graphify-out/graph.json during codebase investigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Filtered graph query for graphify-out/graph.json.
|
|
|
|
Reduces noise by excluding test nodes and helper-only symbols.
|
|
Supports BFS query and shortest-path modes.
|
|
|
|
Usage:
|
|
python scripts/graph_query.py "job lifecycle"
|
|
python scripts/graph_query.py --path "job()" "_destroy_clone()"
|
|
python scripts/graph_query.py "transport" --context calls --context implements
|
|
python scripts/graph_query.py "lifecycle" --include-tests
|
|
python scripts/graph_query.py "build" --depth 3 --budget 4000
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
DEFAULT_EXCLUDE = [
|
|
r"^tests/",
|
|
r"/test_",
|
|
r"_test\.py$",
|
|
r"\.Tests\.",
|
|
r"Test-",
|
|
r"Measure-",
|
|
]
|
|
|
|
SIGNAL_RELATIONS = {
|
|
"calls",
|
|
"implements",
|
|
"uses",
|
|
"defines",
|
|
"inherits",
|
|
"shares_data_with",
|
|
"references",
|
|
}
|
|
|
|
|
|
def load_graph(graph_path: Path) -> tuple[dict, dict]:
|
|
data = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
nodes: dict[str, dict] = {n["id"]: n for n in data["nodes"]}
|
|
adj: dict[str, list[tuple[str, str, float]]] = {nid: [] for nid in nodes}
|
|
for link in data["links"]:
|
|
src, tgt = link["source"], link["target"]
|
|
rel = link.get("relation", "")
|
|
cs = link.get("confidence_score", 1.0)
|
|
if src in adj:
|
|
adj[src].append((tgt, rel, cs))
|
|
if tgt in adj:
|
|
adj[tgt].append((src, rel, cs))
|
|
return nodes, adj
|
|
|
|
|
|
def allowed_set(nodes: dict, exclude_patterns: list[str]) -> set[str]:
|
|
allowed: set[str] = set()
|
|
for nid, node in nodes.items():
|
|
sf = node.get("source_file") or ""
|
|
if not any(re.search(pat, sf) for pat in exclude_patterns):
|
|
allowed.add(nid)
|
|
return allowed
|
|
|
|
|
|
def find_starts(nodes: dict, query: str, allowed: set[str]) -> list[str]:
|
|
q = query.lower()
|
|
exact, partial = [], []
|
|
for nid in allowed:
|
|
label = (nodes[nid].get("label") or "").lower()
|
|
sf = (nodes[nid].get("source_file") or "").lower()
|
|
if q == label or q == nid:
|
|
exact.append(nid)
|
|
elif q in label or q in sf:
|
|
partial.append(nid)
|
|
return exact or partial
|
|
|
|
|
|
def bfs(
|
|
nodes: dict,
|
|
adj: dict,
|
|
starts: list[str],
|
|
allowed: set[str],
|
|
max_depth: int,
|
|
relations: set[str] | None,
|
|
) -> list[tuple[dict, int, str | None]]:
|
|
visited: dict[str, str | None] = {}
|
|
queue: deque[tuple[str, int, str | None]] = deque()
|
|
for nid in starts:
|
|
if nid in allowed:
|
|
visited[nid] = None
|
|
queue.append((nid, 0, None))
|
|
|
|
results: list[tuple[dict, int, str | None]] = []
|
|
while queue:
|
|
nid, depth, via_rel = queue.popleft()
|
|
results.append((nodes[nid], depth, via_rel))
|
|
if depth < max_depth:
|
|
for neighbor, rel, _cs in adj.get(nid, []):
|
|
if neighbor not in visited and neighbor in allowed:
|
|
if relations is None or rel in relations:
|
|
visited[neighbor] = nid
|
|
queue.append((neighbor, depth + 1, rel))
|
|
return results
|
|
|
|
|
|
def shortest_path(
|
|
nodes: dict,
|
|
adj: dict,
|
|
a_ids: list[str],
|
|
b_ids: list[str],
|
|
allowed: set[str],
|
|
) -> list[tuple[str, str]]:
|
|
b_set = set(b_ids)
|
|
for start in a_ids:
|
|
if start not in allowed:
|
|
continue
|
|
visited: dict[str, tuple[str, str] | None] = {start: None}
|
|
queue: deque[str] = deque([start])
|
|
while queue:
|
|
cur = queue.popleft()
|
|
if cur in b_set:
|
|
path: list[tuple[str, str]] = []
|
|
node: str | None = cur
|
|
rel_used = ""
|
|
while node is not None:
|
|
prev = visited[node]
|
|
if prev is None:
|
|
path.append((node, ""))
|
|
else:
|
|
prev_node, rel_used = prev
|
|
path.append((node, rel_used))
|
|
node = prev_node
|
|
continue
|
|
break
|
|
return list(reversed(path))
|
|
for neighbor, rel, _cs in adj.get(cur, []):
|
|
if neighbor not in visited and neighbor in allowed:
|
|
visited[neighbor] = (cur, rel)
|
|
queue.append(neighbor)
|
|
return []
|
|
|
|
|
|
def fmt_node(node: dict, depth: int, via_rel: str | None) -> str:
|
|
indent = " " * depth
|
|
label = node.get("label", node["id"])
|
|
sf = node.get("source_file", "")
|
|
loc = node.get("source_location", "")
|
|
loc_str = f" loc={loc}" if loc else ""
|
|
rel_str = f" ←{via_rel}" if via_rel else ""
|
|
return f"{indent}NODE {label} [src={sf}{loc_str}]{rel_str}"
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="Filtered graphify query")
|
|
ap.add_argument("query", nargs="?", help="Search term for BFS query")
|
|
ap.add_argument("--path", nargs=2, metavar=("A", "B"), help="Shortest path between two nodes")
|
|
ap.add_argument("--context", action="append", metavar="REL", help="Limit traversal to these edge relations (repeatable)")
|
|
ap.add_argument("--depth", type=int, default=2, help="BFS max depth (default 2)")
|
|
ap.add_argument("--budget", type=int, default=3000, help="Approximate output char budget (default 3000)")
|
|
ap.add_argument("--include-tests", action="store_true", help="Do not exclude test nodes")
|
|
ap.add_argument("--graph", type=Path, default=Path("graphify-out/graph.json"), help="Path to graph.json")
|
|
args = ap.parse_args()
|
|
|
|
if not args.query and not args.path:
|
|
ap.print_help()
|
|
sys.exit(1)
|
|
|
|
if not args.graph.exists():
|
|
print(f"error: {args.graph} not found. Run /graphify first.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
nodes, adj = load_graph(args.graph)
|
|
exclude = [] if args.include_tests else DEFAULT_EXCLUDE
|
|
allowed = allowed_set(nodes, exclude)
|
|
relations = set(args.context) if args.context else None
|
|
|
|
total = len(nodes)
|
|
filtered_out = total - len(allowed)
|
|
print(f"Graph: {total} nodes, {filtered_out} excluded ({filtered_out*100//total}% noise filtered)\n")
|
|
|
|
if args.path:
|
|
a_ids = find_starts(nodes, args.path[0], allowed)
|
|
b_ids = find_starts(nodes, args.path[1], allowed)
|
|
if not a_ids:
|
|
print(f"No match for '{args.path[0]}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not b_ids:
|
|
print(f"No match for '{args.path[1]}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
path = shortest_path(nodes, adj, a_ids, b_ids, allowed)
|
|
if not path:
|
|
print(f"No path found between '{args.path[0]}' and '{args.path[1]}'")
|
|
else:
|
|
print(f"Path ({len(path)} hops):")
|
|
for i, (nid, rel) in enumerate(path):
|
|
node = nodes[nid]
|
|
label = node.get("label", nid)
|
|
sf = node.get("source_file", "")
|
|
loc = node.get("source_location", "")
|
|
loc_str = f":{loc}" if loc else ""
|
|
arrow = f" --{rel}--> " if rel else ""
|
|
print(f" {' ' * i}{arrow}{label} [{sf}{loc_str}]")
|
|
return
|
|
|
|
starts = find_starts(nodes, args.query, allowed)
|
|
if not starts:
|
|
print(f"No matching nodes for '{args.query}' (excluding tests)")
|
|
sys.exit(0)
|
|
|
|
print(f"BFS from {len(starts)} start node(s) · depth={args.depth}" +
|
|
(f" · context={','.join(sorted(relations))}" if relations else "") + "\n")
|
|
|
|
results = bfs(nodes, adj, starts, allowed, args.depth, relations)
|
|
|
|
chars = 0
|
|
shown = 0
|
|
for node, depth, via_rel in results:
|
|
line = fmt_node(node, depth, via_rel)
|
|
if chars + len(line) > args.budget:
|
|
print(f"\n... ({len(results) - shown} more nodes, increase --budget to see)")
|
|
break
|
|
print(line)
|
|
chars += len(line) + 1
|
|
shown += 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|