chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:18 +08:00
commit 05f60106aa
288 changed files with 76871 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 27 tools:
1. build_or_update_graph - full or incremental build
2. get_impact_radius - blast radius from changed files
3. query_graph - predefined graph queries
4. get_review_context - focused subgraph + review prompt
5. semantic_search_nodes - keyword + vector search across nodes
6. list_graph_stats - aggregate statistics
7. embed_graph - compute vector embeddings for semantic search
8. get_docs_section - token-optimized documentation retrieval
9. find_large_functions - find oversized functions/classes by line count
10. list_flows - list execution flows sorted by criticality
11. get_flow - get details of a single execution flow
12. get_affected_flows - find flows affected by changed files
13. list_communities - list detected code communities
14. get_community - get details of a single community
15. get_architecture_overview - architecture overview from community structure
16. detect_changes - risk-scored change impact analysis for code review
17. refactor_tool - unified refactoring (rename preview, dead code, suggestions)
18. apply_refactor_tool - apply a previously previewed refactoring
19. generate_wiki - generate markdown wiki from community structure
20. get_wiki_page - retrieve a specific wiki page
21. list_repos - list registered repositories
22. cross_repo_search - search across all registered repositories
23. get_hub_nodes - find most connected nodes (architectural hotspots)
24. get_bridge_nodes - find architectural chokepoints (betweenness centrality)
25. get_knowledge_gaps - identify structural weaknesses
26. get_surprising_connections - find unexpected architectural coupling
27. get_suggested_questions - auto-generated review questions from graph analysis
28. traverse_graph - BFS/DFS traversal from best-matching node
"""
from __future__ import annotations
# Re-export names that external code may patch via "code_review_graph.tools.*"
from ..changes import parse_diff_ranges as parse_diff_ranges
from ..changes import parse_git_diff_ranges as parse_git_diff_ranges
from ..changes import parse_svn_diff_ranges as parse_svn_diff_ranges
from ..incremental import (
get_changed_files as get_changed_files,
)
from ..incremental import (
get_staged_and_unstaged as get_staged_and_unstaged,
)
# -- _common ----------------------------------------------------------------
from ._common import (
_BUILTIN_CALL_NAMES,
_get_store,
_validate_repo_root,
)
# -- analysis_tools ---------------------------------------------------------
from .analysis_tools import (
get_bridge_nodes_func,
get_hub_nodes_func,
get_knowledge_gaps_func,
get_suggested_questions_func,
get_surprising_connections_func,
)
# -- build ------------------------------------------------------------------
from .build import build_or_update_graph, run_postprocess
# -- community_tools --------------------------------------------------------
from .community_tools import (
get_architecture_overview_func,
get_community_func,
list_communities_func,
)
# -- context ----------------------------------------------------------------
from .context import get_minimal_context
# -- docs -------------------------------------------------------------------
from .docs import embed_graph, generate_wiki_func, get_docs_section, get_wiki_page_func
# -- flows_tools ------------------------------------------------------------
from .flows_tools import get_flow, list_flows
# -- query ------------------------------------------------------------------
from .query import (
find_large_functions,
get_impact_radius,
list_graph_stats,
query_graph,
semantic_search_nodes,
traverse_graph_func,
)
# -- refactor_tools ---------------------------------------------------------
from .refactor_tools import apply_refactor_func, refactor_func
# -- registry_tools ---------------------------------------------------------
from .registry_tools import cross_repo_search_func, list_repos_func
# -- review -----------------------------------------------------------------
from .review import (
detect_changes_func,
get_affected_flows_func,
get_review_context,
)
__all__ = [
# _common
"_BUILTIN_CALL_NAMES",
"_get_store",
"_validate_repo_root",
# build
"build_or_update_graph",
"run_postprocess",
# context
"get_minimal_context",
# community_tools
"get_architecture_overview_func",
"get_community_func",
"list_communities_func",
# docs
"embed_graph",
"generate_wiki_func",
"get_docs_section",
"get_wiki_page_func",
# flows_tools
"get_flow",
"list_flows",
# query
"find_large_functions",
"get_impact_radius",
"list_graph_stats",
"query_graph",
"semantic_search_nodes",
"traverse_graph_func",
# refactor_tools
"apply_refactor_func",
"refactor_func",
# registry_tools
"cross_repo_search_func",
"list_repos_func",
# review
"detect_changes_func",
"get_affected_flows_func",
"get_review_context",
# analysis_tools
"get_bridge_nodes_func",
"get_hub_nodes_func",
"get_knowledge_gaps_func",
"get_suggested_questions_func",
"get_surprising_connections_func",
# re-exported for backward compat (used in test patches)
"get_changed_files",
"get_staged_and_unstaged",
"parse_git_diff_ranges",
"parse_svn_diff_ranges",
"parse_diff_ranges",
]
+176
View File
@@ -0,0 +1,176 @@
"""Shared utilities for tool sub-modules."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..graph import GraphStore
from ..incremental import find_project_root, get_db_path
def _error_response(
message: str, status: str = "error", **extra: Any,
) -> dict[str, Any]:
"""Build a standardised error response dict."""
return {"status": status, "error": message, "summary": message, **extra}
# Common JS/TS builtin method names filtered from callers_of results.
# "Who calls .map()?" returns hundreds of hits and is never useful.
# These are kept in the graph (callees_of still shows them) but excluded
# when doing reverse call tracing to reduce noise.
_BUILTIN_CALL_NAMES: set[str] = {
"map", "filter", "reduce", "reduceRight", "forEach", "find", "findIndex",
"some", "every", "includes", "indexOf", "lastIndexOf",
"push", "pop", "shift", "unshift", "splice", "slice",
"concat", "join", "flat", "flatMap", "sort", "reverse", "fill",
"keys", "values", "entries", "from", "isArray", "of", "at",
"trim", "trimStart", "trimEnd", "split", "replace", "replaceAll",
"match", "matchAll", "search", "substring", "substr",
"toLowerCase", "toUpperCase", "startsWith", "endsWith",
"padStart", "padEnd", "repeat", "charAt", "charCodeAt",
"assign", "freeze", "defineProperty", "getOwnPropertyNames",
"hasOwnProperty", "create", "is", "fromEntries",
"log", "warn", "error", "info", "debug", "trace", "dir", "table",
"time", "timeEnd", "assert", "clear", "count",
"then", "catch", "finally", "resolve", "reject", "all", "allSettled", "race", "any",
"parse", "stringify",
"floor", "ceil", "round", "random", "max", "min", "abs", "pow", "sqrt",
"addEventListener", "removeEventListener", "querySelector", "querySelectorAll",
"getElementById", "createElement", "appendChild", "removeChild",
"setAttribute", "getAttribute", "preventDefault", "stopPropagation",
"setTimeout", "clearTimeout", "setInterval", "clearInterval",
"toString", "valueOf", "toJSON", "toISOString",
"getTime", "getFullYear", "now",
"isNaN", "parseInt", "parseFloat", "toFixed",
"encodeURIComponent", "decodeURIComponent",
"call", "apply", "bind", "next",
"emit", "on", "off", "once",
"pipe", "write", "read", "end", "close", "destroy",
"send", "status", "json", "redirect",
"set", "get", "delete", "has",
"findUnique", "findFirst", "findMany", "createMany",
"update", "updateMany", "deleteMany", "upsert",
"aggregate", "groupBy", "transaction",
"describe", "it", "test", "expect", "beforeEach", "afterEach",
"beforeAll", "afterAll", "mock", "spyOn",
"require", "fetch",
}
def _validate_repo_root(path: "Path | str") -> Path:
"""Validate that a path is a plausible project root.
Ensures the path is an existing directory that contains a ``.git``,
``.svn``, or ``.code-review-graph`` directory, preventing arbitrary
file-system traversal via the ``repo_root`` parameter.
"""
resolved = Path(path).resolve()
if not resolved.is_dir():
raise ValueError(
f"repo_root is not an existing directory: {resolved}"
)
has_vcs = (
(resolved / ".git").exists()
or (resolved / ".svn").exists()
or (resolved / ".code-review-graph").exists()
)
if not has_vcs:
raise ValueError(
f"repo_root does not look like a project root "
f"(no .git, .svn, or .code-review-graph directory found): "
f"{resolved}"
)
return resolved
def _resolve_root(repo_root: str | None = None) -> Path:
"""Resolve and validate the repository root without opening a store."""
return _validate_repo_root(Path(repo_root)) if repo_root else find_project_root()
def _get_store(repo_root: str | None = None) -> tuple[GraphStore, Path]:
"""Resolve repo root and open the graph store.
Callers own the returned store and must close it (try/finally or
context manager) to avoid leaking SQLite file descriptors.
"""
root = _resolve_root(repo_root)
db_path = get_db_path(root)
return GraphStore(db_path), root
def _resolve_graph_file_paths(
store: GraphStore, root: Path, file_paths: list[str],
) -> list[str]:
"""Resolve user-facing file paths to the paths stored in the graph.
Graphs may contain absolute paths, repo-relative paths, or cwd-relative
paths depending on how they were built. Tool inputs are usually relative to
repo root, so exact matching alone can miss existing graph nodes.
"""
resolved: list[str] = []
seen: set[str] = set()
def add(path: str) -> None:
if path not in seen:
resolved.append(path)
seen.add(path)
for file_path in file_paths:
raw = file_path.replace("\\", "/")
candidates = [raw]
path = Path(file_path)
if path.is_absolute():
try:
candidates.append(str(path.resolve().relative_to(root)).replace("\\", "/"))
except ValueError:
pass
else:
candidates.append(str(root / path))
for candidate in candidates:
if store.get_nodes_by_file(candidate):
add(candidate)
suffixes = []
for candidate in candidates:
normalized = candidate.replace("\\", "/")
if normalized not in suffixes:
suffixes.append(normalized)
for suffix in suffixes:
for matched_path in store.get_files_matching(suffix):
add(matched_path)
return resolved
def compact_response(
summary: str,
key_entities: list[str] | None = None,
risk: str = "unknown",
communities: list[str] | None = None,
flows_affected: list[str] | None = None,
next_tool_suggestions: list[str] | None = None,
data: dict[str, Any] | None = None,
detail_level: str = "minimal",
) -> dict[str, Any]:
"""Standard compact response format for token efficiency."""
resp: dict[str, Any] = {
"status": "ok",
"summary": summary,
}
if key_entities:
resp["key_entities"] = key_entities[:10]
if risk != "unknown":
resp["risk"] = risk
if communities:
resp["communities"] = communities[:5]
if flows_affected:
resp["flows_affected"] = flows_affected[:5]
if next_tool_suggestions:
resp["next_tool_suggestions"] = next_tool_suggestions[:3]
if detail_level != "minimal" and data:
resp["data"] = data
return resp
+184
View File
@@ -0,0 +1,184 @@
"""MCP tool wrappers for graph analysis features."""
from __future__ import annotations
from typing import Any
from ..analysis import (
find_bridge_nodes,
find_hub_nodes,
find_knowledge_gaps,
find_surprising_connections,
generate_suggested_questions,
)
from ._common import _get_store
def get_hub_nodes_func(
repo_root: str | None = None,
top_n: int = 10,
) -> dict[str, Any]:
"""Find the most connected nodes in the codebase graph.
Hub nodes have the highest total degree (in + out edges).
These are architectural hotspots -- changes to them have
disproportionate blast radius.
Args:
repo_root: Repository root (auto-detected if omitted).
top_n: Number of top hubs to return (default 10).
"""
store, _root = _get_store(repo_root or None)
try:
hubs = find_hub_nodes(store, top_n=top_n)
return {
"hub_nodes": hubs,
"count": len(hubs),
"next_tool_suggestions": [
"get_impact_radius -- check blast radius of a hub",
"query_graph callers_of -- see what calls a hub",
"get_bridge_nodes -- find architectural chokepoints",
],
}
finally:
store.close()
def get_bridge_nodes_func(
repo_root: str | None = None,
top_n: int = 10,
) -> dict[str, Any]:
"""Find architectural chokepoints via betweenness centrality.
Bridge nodes sit on the shortest paths between many node
pairs. If they break, multiple code regions lose
connectivity.
Args:
repo_root: Repository root (auto-detected if omitted).
top_n: Number of top bridges to return (default 10).
"""
store, _root = _get_store(repo_root or None)
try:
bridges = find_bridge_nodes(store, top_n=top_n)
return {
"bridge_nodes": bridges,
"count": len(bridges),
"next_tool_suggestions": [
"get_hub_nodes -- find most connected nodes",
"get_impact_radius -- check blast radius",
"detect_changes -- see if bridges are affected",
],
}
finally:
store.close()
def get_knowledge_gaps_func(
repo_root: str | None = None,
) -> dict[str, Any]:
"""Identify structural weaknesses in the codebase.
Finds: isolated nodes (disconnected), thin communities
(< 3 members), untested hotspots (high-degree, no tests),
and single-file communities.
Args:
repo_root: Repository root (auto-detected if omitted).
"""
store, _root = _get_store(repo_root or None)
try:
gaps = find_knowledge_gaps(store)
total = sum(len(v) for v in gaps.values())
return {
"gaps": gaps,
"total_gaps": total,
"summary": {
"isolated_nodes": len(gaps["isolated_nodes"]),
"thin_communities": len(
gaps["thin_communities"]
),
"untested_hotspots": len(
gaps["untested_hotspots"]
),
"single_file_communities": len(
gaps["single_file_communities"]
),
},
"next_tool_suggestions": [
"refactor dead_code -- find unused symbols",
"get_hub_nodes -- find high-impact nodes",
"get_suggested_questions -- review prompts",
],
}
finally:
store.close()
def get_surprising_connections_func(
repo_root: str | None = None,
top_n: int = 15,
) -> dict[str, Any]:
"""Find unexpected architectural coupling in the codebase.
Scores edges by surprise factors: cross-community,
cross-language, peripheral-to-hub, cross-test-boundary.
Args:
repo_root: Repository root (auto-detected if omitted).
top_n: Number of top surprises to return (default 15).
"""
store, _root = _get_store(repo_root or None)
try:
surprises = find_surprising_connections(
store, top_n=top_n
)
return {
"surprising_connections": surprises,
"count": len(surprises),
"next_tool_suggestions": [
"get_architecture_overview -- community structure",
"query_graph callers_of -- trace the coupling",
"get_bridge_nodes -- find chokepoints",
],
}
finally:
store.close()
def get_suggested_questions_func(
repo_root: str | None = None,
) -> dict[str, Any]:
"""Auto-generate review questions from graph analysis.
Produces questions about: bridge nodes, untested hubs,
surprising connections, thin communities, and untested
hotspots.
Args:
repo_root: Repository root (auto-detected if omitted).
"""
store, _root = _get_store(repo_root or None)
try:
questions = generate_suggested_questions(store)
by_priority: dict[str, list[dict[str, Any]]] = {
"high": [], "medium": [], "low": [],
}
for q in questions:
prio = q.get("priority", "medium")
if prio in by_priority:
by_priority[prio].append(q)
return {
"questions": questions,
"count": len(questions),
"by_priority": {
k: len(v) for k, v in by_priority.items()
},
"next_tool_suggestions": [
"get_knowledge_gaps -- structural weaknesses",
"detect_changes -- risk-scored review",
"get_architecture_overview -- community map",
],
}
finally:
store.close()
+541
View File
@@ -0,0 +1,541 @@
"""Tool 1: build_or_update_graph + run_postprocess."""
from __future__ import annotations
import logging
import sqlite3
import time
from typing import Any
from ..incremental import full_build, incremental_update
from ._common import _get_store
logger = logging.getLogger(__name__)
def _run_postprocess(
store: Any,
build_result: dict[str, Any],
postprocess: str,
full_rebuild: bool = False,
changed_files: list[str] | None = None,
) -> list[str]:
"""Run post-build steps based on *postprocess* level.
When *full_rebuild* is False and *changed_files* are available,
uses incremental flow/community detection for faster updates.
Returns a list of warning strings (empty on success).
"""
warnings: list[str] = []
build_result["postprocess_level"] = postprocess
if postprocess == "none":
return warnings
# -- Signatures + FTS (fast, always run unless "none") --
try:
rows = store.get_nodes_without_signature()
for row in rows:
node_id, name, kind, params, ret = (
row[0],
row[1],
row[2],
row[3],
row[4],
)
if kind in ("Function", "Test"):
sig = f"def {name}({params or ''})"
if ret:
sig += f" -> {ret}"
elif kind == "Class":
sig = f"class {name}"
else:
sig = name
store.update_node_signature(node_id, sig[:512])
store.commit()
build_result["signatures_updated"] = True
except (sqlite3.OperationalError, TypeError, KeyError) as e:
logger.warning("Signature computation failed: %s", e)
warnings.append(f"Signature computation failed: {type(e).__name__}: {e}")
try:
from code_review_graph.search import rebuild_fts_index
fts_count = rebuild_fts_index(store)
build_result["fts_indexed"] = fts_count
build_result["fts_rebuilt"] = True
except (sqlite3.OperationalError, ImportError) as e:
logger.warning("FTS index rebuild failed: %s", e)
warnings.append(f"FTS index rebuild failed: {type(e).__name__}: {e}")
if postprocess == "minimal":
return warnings
# -- Expensive: flows + communities (only for "full") --
use_incremental = not full_rebuild and bool(changed_files)
try:
if use_incremental:
from code_review_graph.flows import incremental_trace_flows
count = incremental_trace_flows(store, changed_files)
else:
from code_review_graph.flows import store_flows as _store_flows
from code_review_graph.flows import trace_flows as _trace_flows
flows = _trace_flows(store)
count = _store_flows(store, flows)
build_result["flows_detected"] = count
except (sqlite3.OperationalError, ImportError) as e:
logger.warning("Flow detection failed: %s", e)
warnings.append(f"Flow detection failed: {type(e).__name__}: {e}")
try:
if use_incremental:
from code_review_graph.communities import (
incremental_detect_communities,
)
count = incremental_detect_communities(store, changed_files)
else:
from code_review_graph.communities import (
detect_communities as _detect_communities,
)
from code_review_graph.communities import (
store_communities as _store_communities,
)
comms = _detect_communities(store)
count = _store_communities(store, comms)
build_result["communities_detected"] = count
except (sqlite3.OperationalError, ImportError) as e:
logger.warning("Community detection failed: %s", e)
warnings.append(f"Community detection failed: {type(e).__name__}: {e}")
# -- Compute pre-computed summary tables --
try:
_compute_summaries(store)
build_result["summaries_computed"] = True
except (sqlite3.OperationalError, Exception) as e:
logger.warning("Summary computation failed: %s", e)
warnings.append(f"Summary computation failed: {type(e).__name__}: {e}")
store.set_metadata(
"last_postprocessed_at",
time.strftime("%Y-%m-%dT%H:%M:%S"),
)
store.set_metadata("postprocess_level", postprocess)
return warnings
def _compute_summaries(store: Any) -> None:
"""Populate community_summaries, flow_snapshots, and risk_index tables.
Uses batched aggregate queries and in-memory grouping instead of
per-community/per-node loops. On graphs with ~100k edges this
reduces the work from ``O(nodes + communities)`` SQLite round trips
each doing their own B-tree scan to a handful of ``GROUP BY``
queries, turning what used to be an effective hang into a few
seconds.
Each summary block (community_summaries, flow_snapshots, risk_index)
is wrapped in an explicit transaction so the DELETE + INSERT sequence
is atomic. If a table doesn't exist yet the block is silently skipped.
"""
import json as _json
from collections import defaultdict
from os.path import commonprefix
conn = store._conn
# -- community_summaries --
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("DELETE FROM community_summaries")
# Pre-compute per-qualified_name edge counts once. Previously
# this section ran a per-community triple-JOIN aggregate query
# (nodes LEFT JOIN edges LEFT JOIN edges), which on graphs with
# thousands of communities was the second-biggest hang.
edge_counts: dict[str, int] = defaultdict(int)
for row in conn.execute(
"SELECT source_qualified, COUNT(*) FROM edges GROUP BY source_qualified"
):
edge_counts[row[0]] += row[1]
for row in conn.execute(
"SELECT target_qualified, COUNT(*) FROM edges GROUP BY target_qualified"
):
edge_counts[row[0]] += row[1]
# Group non-File nodes per community for top-symbol selection.
nodes_by_comm: dict[int, list[tuple[str, int]]] = defaultdict(list)
for row in conn.execute(
"SELECT community_id, name, qualified_name FROM nodes "
"WHERE community_id IS NOT NULL AND kind != 'File'"
):
cid, name, qn = row[0], row[1], row[2]
nodes_by_comm[cid].append((name, edge_counts.get(qn, 0)))
# Group distinct file paths per community (preserving first-seen
# order for stable output, same as DISTINCT in the old query).
files_by_comm: dict[int, list[str]] = defaultdict(list)
seen_files: dict[int, set[str]] = defaultdict(set)
for row in conn.execute(
"SELECT community_id, file_path FROM nodes WHERE community_id IS NOT NULL"
):
cid, fp = row[0], row[1]
if fp not in seen_files[cid]:
seen_files[cid].add(fp)
files_by_comm[cid].append(fp)
community_rows = conn.execute(
"SELECT id, name, size, dominant_language FROM communities"
).fetchall()
for r in community_rows:
cid, cname, csize, clang = r[0], r[1], r[2], r[3]
# Top 5 symbols by total edge count (in + out). Python's
# sorted() is stable so ties break by original row order.
members = sorted(
nodes_by_comm.get(cid, []),
key=lambda nc: nc[1],
reverse=True,
)
key_syms = _json.dumps([m[0] for m in members[:5]])
# Auto-generate purpose from common file path prefix.
paths = files_by_comm.get(cid, [])[:20]
purpose = ""
if paths:
prefix = commonprefix(paths)
if "/" in prefix:
purpose = prefix.rsplit("/", 1)[0].split("/")[-1] if "/" in prefix else ""
conn.execute(
"INSERT OR REPLACE INTO community_summaries "
"(community_id, name, purpose, key_symbols, size, dominant_language) "
"VALUES (?, ?, ?, ?, ?, ?)",
(cid, cname, purpose, key_syms, csize, clang or ""),
)
conn.commit()
except sqlite3.OperationalError:
conn.rollback() # Table may not exist yet
# -- flow_snapshots --
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("DELETE FROM flow_snapshots")
flow_rows = conn.execute(
"SELECT id, name, entry_point_id, criticality, node_count, "
"file_count, path_json FROM flows"
).fetchall()
# Collect every node id referenced by any flow, then fetch
# their qualified_names in one batched query instead of per-flow
# per-node lookups.
needed_ids: set[int] = set()
parsed_paths: list[list[int]] = []
for r in flow_rows:
needed_ids.add(r[2]) # entry_point_id
path_ids = _json.loads(r[6]) if r[6] else []
parsed_paths.append(path_ids)
# Match the old semantics: entry + up to 3 intermediates + last
for nid in path_ids[1:4]:
needed_ids.add(nid)
if path_ids:
needed_ids.add(path_ids[-1])
id_to_name: dict[int, str] = {}
if needed_ids:
# Batch the IN clause in chunks of 450 to stay under SQLite's
# default SQLITE_MAX_VARIABLE_NUMBER (999), same strategy as
# GraphStore.get_edges_among.
id_list = list(needed_ids)
for i in range(0, len(id_list), 450):
batch = id_list[i : i + 450]
placeholders = ",".join("?" for _ in batch)
node_rows = conn.execute(
f"SELECT id, qualified_name FROM nodes WHERE id IN ({placeholders})", # nosec B608
batch,
).fetchall()
for nr in node_rows:
id_to_name[nr[0]] = nr[1]
for r, path_ids in zip(flow_rows, parsed_paths):
fid, fname, ep_id = r[0], r[1], r[2]
crit, ncount, fcount = r[3], r[4], r[5]
ep_name = id_to_name.get(ep_id, str(ep_id))
critical_path: list[str] = []
if path_ids:
critical_path.append(ep_name)
if len(path_ids) > 2:
for nid in path_ids[1:4]:
nm = id_to_name.get(nid)
if nm:
critical_path.append(nm)
if len(path_ids) > 1:
last = id_to_name.get(path_ids[-1])
if last and last not in critical_path:
critical_path.append(last)
conn.execute(
"INSERT OR REPLACE INTO flow_snapshots "
"(flow_id, name, entry_point, critical_path, criticality, "
"node_count, file_count) VALUES (?, ?, ?, ?, ?, ?, ?)",
(fid, fname, ep_name, _json.dumps(critical_path), crit, ncount, fcount),
)
conn.commit()
except sqlite3.OperationalError:
conn.rollback()
# -- risk_index --
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("DELETE FROM risk_index")
# Pre-compute caller and test-coverage counts in two aggregate
# queries. Previously this section ran two COUNT(*) queries per
# candidate node; on a ~100k-edge graph with tens of thousands
# of Function/Class/Test nodes that was the primary hang
# observed during Godot builds.
caller_counts: dict[str, int] = {}
for row in conn.execute(
"SELECT target_qualified, COUNT(*) FROM edges "
"WHERE kind = 'CALLS' GROUP BY target_qualified"
):
caller_counts[row[0]] = row[1]
tested_counts: dict[str, int] = {}
for row in conn.execute(
"SELECT source_qualified, COUNT(*) FROM edges "
"WHERE kind = 'TESTED_BY' GROUP BY source_qualified"
):
tested_counts[row[0]] = row[1]
risk_nodes = conn.execute(
"SELECT id, qualified_name, name FROM nodes WHERE kind IN ('Function', 'Class', 'Test')"
).fetchall()
security_kw = {
"auth",
"login",
"password",
"token",
"session",
"crypt",
"secret",
"credential",
"permission",
"sql",
"execute",
}
for n in risk_nodes:
nid, qn, name = n[0], n[1], n[2]
caller_count = caller_counts.get(qn, 0)
tested = tested_counts.get(qn, 0)
coverage = "tested" if tested > 0 else "untested"
name_lower = name.lower()
sec_relevant = 1 if any(kw in name_lower for kw in security_kw) else 0
risk = 0.0
if caller_count > 10:
risk += 0.3
elif caller_count > 3:
risk += 0.15
if coverage == "untested":
risk += 0.3
if sec_relevant:
risk += 0.4
risk = min(risk, 1.0)
conn.execute(
"INSERT OR REPLACE INTO risk_index "
"(node_id, qualified_name, risk_score, caller_count, "
"test_coverage, security_relevant, last_computed) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now'))",
(nid, qn, risk, caller_count, coverage, sec_relevant),
)
conn.commit()
except sqlite3.OperationalError:
conn.rollback()
def build_or_update_graph(
full_rebuild: bool = False,
repo_root: str | None = None,
base: str = "HEAD~1",
postprocess: str = "full",
recurse_submodules: bool | None = None,
) -> dict[str, Any]:
"""Build or incrementally update the code knowledge graph.
Args:
full_rebuild: If True, re-parse every file. If False (default),
only re-parse files changed since ``base``.
repo_root: Path to the repository root. Auto-detected if omitted.
base: Git ref for incremental diff (default: HEAD~1).
postprocess: Post-processing level after build:
``"full"`` (default) — signatures, FTS, flows, communities.
``"minimal"`` — signatures + FTS only (fast, keeps search working).
``"none"`` — skip all post-processing (raw parse only).
recurse_submodules: If True, include files from git submodules
via ``git ls-files --recurse-submodules``. When None
(default), falls back to the CRG_RECURSE_SUBMODULES
environment variable. Default: disabled.
Returns:
Summary with files_parsed/updated, node/edge counts, and errors.
"""
store, root = _get_store(repo_root)
try:
if full_rebuild:
result = full_build(root, store, recurse_submodules)
build_result = {
"status": "ok",
"build_type": "full",
"summary": (
f"Full build complete: parsed {result['files_parsed']} files, "
f"created {result['total_nodes']} nodes and "
f"{result['total_edges']} edges."
),
**result,
}
else:
result = incremental_update(root, store, base=base)
if result["files_updated"] == 0:
return {
"status": "ok",
"build_type": "incremental",
"summary": "No changes detected. Graph is up to date.",
"postprocess_level": postprocess,
**result,
}
build_result = {
"status": "ok",
"build_type": "incremental",
"summary": (
f"Incremental update: {result['files_updated']} files re-parsed, "
f"{result['total_nodes']} nodes and "
f"{result['total_edges']} edges updated. "
f"Changed: {result['changed_files']}. "
f"Dependents also updated: {result['dependent_files']}."
),
**result,
}
# Pass changed_files for incremental flow/community detection
changed = result.get("changed_files") if not full_rebuild else None
warnings = _run_postprocess(
store,
build_result,
postprocess,
full_rebuild=full_rebuild,
changed_files=changed,
)
if warnings:
build_result["warnings"] = warnings
return build_result
finally:
store.close()
def run_postprocess(
flows: bool = True,
communities: bool = True,
fts: bool = True,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Run post-processing steps on an existing graph.
Useful for running expensive steps (flows, communities) separately
from the build, or for re-running after the graph has been updated
with ``postprocess="none"``.
Args:
flows: Run flow detection. Default: True.
communities: Run community detection. Default: True.
fts: Rebuild FTS index. Default: True.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Summary of what was computed.
"""
store, _root = _get_store(repo_root)
result: dict[str, Any] = {"status": "ok"}
warnings: list[str] = []
try:
try:
rows = store.get_nodes_without_signature()
for row in rows:
node_id, name, kind, params, ret = (
row[0],
row[1],
row[2],
row[3],
row[4],
)
if kind in ("Function", "Test"):
sig = f"def {name}({params or ''})"
if ret:
sig += f" -> {ret}"
elif kind == "Class":
sig = f"class {name}"
else:
sig = name
store.update_node_signature(node_id, sig[:512])
store.commit()
result["signatures_updated"] = True
except (sqlite3.OperationalError, TypeError, KeyError) as e:
logger.warning("Signature computation failed: %s", e)
warnings.append(f"Signature computation failed: {type(e).__name__}: {e}")
if fts:
try:
from code_review_graph.search import rebuild_fts_index
fts_count = rebuild_fts_index(store)
result["fts_indexed"] = fts_count
except (sqlite3.OperationalError, ImportError) as e:
store.rollback()
logger.warning("FTS index rebuild failed: %s", e)
warnings.append(f"FTS index rebuild failed: {type(e).__name__}: {e}")
if flows:
try:
from code_review_graph.flows import store_flows as _store_flows
from code_review_graph.flows import trace_flows as _trace_flows
traced = _trace_flows(store)
count = _store_flows(store, traced)
result["flows_detected"] = count
except (sqlite3.OperationalError, ImportError) as e:
store.rollback()
logger.warning("Flow detection failed: %s", e)
warnings.append(f"Flow detection failed: {type(e).__name__}: {e}")
if communities:
try:
from code_review_graph.communities import (
detect_communities as _detect_communities,
)
from code_review_graph.communities import (
store_communities as _store_communities,
)
comms = _detect_communities(store)
count = _store_communities(store, comms)
result["communities_detected"] = count
except (sqlite3.OperationalError, ImportError) as e:
store.rollback()
logger.warning("Community detection failed: %s", e)
warnings.append(f"Community detection failed: {type(e).__name__}: {e}")
store.set_metadata(
"last_postprocessed_at",
time.strftime("%Y-%m-%dT%H:%M:%S"),
)
result["summary"] = "Post-processing complete."
if warnings:
result["warnings"] = warnings
return result
finally:
store.close()
+246
View File
@@ -0,0 +1,246 @@
"""Tools 13, 14, 15: community listing, detail, architecture overview."""
from __future__ import annotations
from collections import Counter
from typing import Any
from ..communities import get_architecture_overview, get_communities
from ..context_savings import attach_context_savings
from ..graph import node_to_dict
from ..hints import generate_hints, get_session
from ._common import _get_store
# ---------------------------------------------------------------------------
# Tool 13: list_communities [EXPLORE]
# ---------------------------------------------------------------------------
def list_communities_func(
repo_root: str | None = None,
sort_by: str = "size",
min_size: int = 0,
detail_level: str = "standard",
) -> dict[str, Any]:
"""List detected code communities in the codebase.
[EXPLORE] Retrieves stored communities from the knowledge graph.
Each community represents a cluster of related code entities
(functions, classes) detected via the Leiden algorithm or
file-based grouping.
Args:
repo_root: Repository root path. Auto-detected if omitted.
sort_by: Sort column: size, cohesion, or name.
min_size: Minimum community size to include (default: 0).
detail_level: "standard" (default) returns full community data;
"minimal" returns only name, size, and cohesion
per community.
Returns:
List of communities with size and cohesion scores.
"""
store, root = _get_store(repo_root)
try:
communities = get_communities(
store, sort_by=sort_by, min_size=min_size
)
if detail_level == "minimal":
communities = [
{"name": c["name"], "size": c["size"], "cohesion": c["cohesion"]}
for c in communities
]
result: dict[str, object] = {
"status": "ok",
"summary": f"Found {len(communities)} communities",
"communities": communities,
}
result["_hints"] = generate_hints(
"list_communities", result, get_session()
)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 14: get_community [EXPLORE]
# ---------------------------------------------------------------------------
def get_community_func(
community_name: str | None = None,
community_id: int | None = None,
include_members: bool = False,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Get details of a single code community.
[EXPLORE] Retrieves a community by its database ID or by name match.
Optionally includes the full list of member nodes.
Args:
community_name: Name to search for (partial match). Ignored if
community_id given.
community_id: Database ID of the community.
include_members: If True, include full member node details.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Community details, or not_found status.
"""
store, root = _get_store(repo_root)
try:
community: dict | None = None
all_communities = get_communities(store)
if community_id is not None:
for c in all_communities:
if c.get("id") == community_id:
community = c
break
elif community_name is not None:
for c in all_communities:
if community_name.lower() in c["name"].lower():
community = c
break
if community is None:
return {
"status": "not_found",
"summary": (
"No community found matching the given criteria."
),
}
if include_members:
cid = community.get("id")
if cid is not None:
member_nodes = store.get_nodes_by_community_id(cid)
members = [node_to_dict(n) for n in member_nodes]
community["member_details"] = members
result = {
"status": "ok",
"summary": (
f"Community '{community['name']}': "
f"{community['size']} nodes, "
f"cohesion {community['cohesion']:.4f}"
),
"community": community,
}
result["_hints"] = generate_hints(
"get_community", result, get_session()
)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 15: get_architecture_overview [EXPLORE]
# ---------------------------------------------------------------------------
_MINIMAL_COMMUNITY_FIELDS = ("id", "name", "size", "cohesion", "dominant_language")
def _minimal_overview(overview: dict[str, Any]) -> dict[str, Any]:
"""Compress overview for ``detail_level="minimal"``.
The full overview can exceed 600KB on medium repos because it embeds
every community's member list and every individual cross-community
edge. Minimal mode drops member lists and aggregates the edge list
to one row per community pair with a count and the top edge kinds —
enough to spot coupling smells without exploding token budgets.
"""
communities = [
{k: c[k] for k in _MINIMAL_COMMUNITY_FIELDS if k in c}
for c in overview.get("communities", [])
]
id_to_name = {c["id"]: c["name"] for c in communities if "id" in c}
edge_pair_counts: Counter[tuple[int, int]] = Counter()
edge_pair_kinds: dict[tuple[int, int], Counter[str]] = {}
for e in overview.get("cross_community_edges", []):
# Use canonical (low, high) ordering so A↔B and B↔A aggregate together.
a, b = e["source_community"], e["target_community"]
pair = (a, b) if a <= b else (b, a)
edge_pair_counts[pair] += 1
edge_pair_kinds.setdefault(pair, Counter())[e["edge_kind"]] += 1
cross_pairs = [
{
"source_community": id_to_name.get(a, f"community-{a}"),
"target_community": id_to_name.get(b, f"community-{b}"),
"edge_count": count,
"top_kinds": [k for k, _ in edge_pair_kinds[(a, b)].most_common(3)],
}
for (a, b), count in edge_pair_counts.most_common()
]
return {
"communities": communities,
"cross_community_edges": cross_pairs,
"warnings": overview.get("warnings", []),
}
def get_architecture_overview_func(
repo_root: str | None = None,
detail_level: str = "minimal",
) -> dict[str, Any]:
"""Generate an architecture overview based on community structure.
[EXPLORE] Builds a high-level view of the codebase architecture by
analyzing community boundaries and cross-community coupling.
Includes warnings for high coupling between communities.
Args:
repo_root: Repository root path. Auto-detected if omitted.
detail_level: "minimal" (default) drops community member lists
and aggregates edges to one row per community pair
(typical reduction: 600KB -> <5KB);
"standard" returns the full overview including
per-edge cross-community detail.
Returns:
Architecture overview with communities, cross-community edges,
and warnings.
"""
store, root = _get_store(repo_root)
try:
full_overview = get_architecture_overview(store)
overview = full_overview
if detail_level == "minimal":
overview = _minimal_overview(full_overview)
n_communities = len(overview["communities"])
n_cross = len(overview["cross_community_edges"])
n_warnings = len(overview["warnings"])
cross_label = (
"community pairs"
if detail_level == "minimal"
else "cross-community edges"
)
result = {
"status": "ok",
"summary": (
f"Architecture: {n_communities} communities, "
f"{n_cross} {cross_label}, "
f"{n_warnings} warning(s)"
),
**overview,
}
result["_hints"] = generate_hints(
"get_architecture_overview", result, get_session()
)
if detail_level == "minimal":
attach_context_savings(result, original_context=full_overview)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
+152
View File
@@ -0,0 +1,152 @@
"""Tool: get_minimal_context — ultra-compact context for token-efficient workflows."""
from __future__ import annotations
import logging
import sqlite3
import subprocess
from pathlib import Path
from typing import Any
from ._common import _get_store, compact_response
logger = logging.getLogger(__name__)
def _has_git_changes(root: Path, base: str) -> bool:
"""Quick check for uncommitted or diffed changes."""
try:
result = subprocess.run(
["git", "diff", "--name-only", base, "--"],
capture_output=True, stdin=subprocess.DEVNULL, text=True,
cwd=str(root), timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return True
# Also check staged/unstaged
result2 = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, stdin=subprocess.DEVNULL, text=True,
cwd=str(root), timeout=10,
)
return bool(result2.stdout.strip())
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def get_minimal_context(
task: str = "",
changed_files: list[str] | None = None,
repo_root: str | None = None,
base: str = "HEAD~1",
) -> dict[str, Any]:
"""Return minimum context an agent needs to start any task (~100 tokens).
Combines graph stats, top communities, top flows, risk score,
and suggested next tools into an ultra-compact response.
Args:
task: Natural language description of what the agent is doing
(e.g. "review PR #42", "debug login timeout").
changed_files: Explicit changed files. Auto-detected from git if None.
repo_root: Repository root path. Auto-detected if None.
base: Git ref for diff comparison.
"""
store, root = _get_store(repo_root)
try:
# 1. Quick stats
stats = store.get_stats()
# 2. Risk from changed files
risk = "unknown"
risk_score = 0.0
top_affected: list[str] = []
test_gap_count = 0
if changed_files or _has_git_changes(root, base):
try:
from ..changes import analyze_changes
from ..incremental import get_changed_files as _get_changed
files = changed_files
if not files:
files = _get_changed(root, base)
if files:
abs_files = [str(root / f) for f in files]
analysis = analyze_changes(
store, abs_files, repo_root=str(root), base=base,
)
risk_score = analysis.get("risk_score", 0.0)
risk = (
"high" if risk_score > 0.7
else "medium" if risk_score > 0.4
else "low"
)
top_affected = [
f.get("name", "")
for f in analysis.get("changed_functions", [])[:5]
]
test_gap_count = len(analysis.get("test_gaps", []))
except (
ImportError, OSError, ValueError,
sqlite3.Error, subprocess.SubprocessError,
):
logger.debug("Risk analysis failed in get_minimal_context", exc_info=True)
# 3. Top 3 communities
communities: list[str] = []
try:
rows = store._conn.execute(
"SELECT name FROM communities ORDER BY size DESC LIMIT 3"
).fetchall()
communities = [r[0] for r in rows]
except sqlite3.OperationalError: # nosec B110 — table may not exist yet
logger.debug("communities table not yet populated")
# 4. Top 3 critical flows
flows: list[str] = []
try:
rows = store._conn.execute(
"SELECT name FROM flows ORDER BY criticality DESC LIMIT 3"
).fetchall()
flows = [r[0] for r in rows]
except sqlite3.OperationalError: # nosec B110 — table may not exist yet
logger.debug("flows table not yet populated")
# 5. Suggest next tools based on task keywords
task_lower = task.lower()
if any(w in task_lower for w in ("review", "pr", "merge", "diff")):
suggestions = ["detect_changes", "get_affected_flows", "get_review_context"]
elif any(w in task_lower for w in ("debug", "bug", "error", "fix")):
suggestions = ["semantic_search_nodes", "query_graph", "get_flow"]
elif any(w in task_lower for w in ("refactor", "rename", "dead", "clean")):
suggestions = ["refactor", "find_large_functions", "get_architecture_overview"]
elif any(w in task_lower for w in ("onboard", "understand", "explore", "arch")):
suggestions = [
"get_architecture_overview", "list_communities", "list_flows",
]
else:
suggestions = [
"detect_changes", "semantic_search_nodes",
"get_architecture_overview",
]
# Build summary
summary_parts = [
f"{stats.total_nodes} nodes, {stats.total_edges} edges"
f" across {stats.files_count} files.",
]
if risk != "unknown":
summary_parts.append(f"Risk: {risk} ({risk_score:.2f}).")
if test_gap_count:
summary_parts.append(f"{test_gap_count} test gaps.")
return compact_response(
summary=" ".join(summary_parts),
key_entities=top_affected or None,
risk=risk,
communities=communities or None,
flows_affected=flows or None,
next_tool_suggestions=suggestions,
)
finally:
store.close()
+274
View File
@@ -0,0 +1,274 @@
"""Tools 7, 8, 19, 20: embed_graph, get_docs_section, wiki tools."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from ..embeddings import EmbeddingStore, embed_all_nodes
from ..incremental import find_project_root, get_db_path
from ._common import _get_store, _resolve_root, _validate_repo_root
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool 7: embed_graph
# ---------------------------------------------------------------------------
def embed_graph(
repo_root: str | None = None,
model: str | None = None,
provider: str | None = None,
) -> dict[str, Any]:
"""Compute vector embeddings for all graph nodes to enable semantic search.
Requires: ``pip install code-review-graph[embeddings]`` (local provider only;
cloud providers like ``openai`` / ``google`` / ``minimax`` use stdlib ``urllib``).
Default model: all-MiniLM-L6-v2. Override via ``model`` param or
CRG_EMBEDDING_MODEL env var.
Changing the model or provider re-embeds all nodes automatically.
Only embeds nodes that don't already have up-to-date embeddings.
Args:
repo_root: Repository root path. Auto-detected if omitted.
model: Embedding model name. For local: HuggingFace ID or path;
for openai: model ID (e.g. ``text-embedding-3-small``);
for google: Gemini model ID. Falls back to
CRG_EMBEDDING_MODEL / CRG_OPENAI_MODEL env vars as appropriate.
provider: Provider name: ``local`` (default), ``openai``, ``google``,
or ``minimax``. ``openai`` requires CRG_OPENAI_BASE_URL +
CRG_OPENAI_API_KEY + CRG_OPENAI_MODEL env vars and accepts
any OpenAI-compatible endpoint (real OpenAI, Azure, new-api,
LiteLLM, vLLM, LocalAI, Ollama openai-mode, etc.).
Returns:
Number of nodes embedded and total embedding count.
"""
store, root = _get_store(repo_root)
try:
db_path = get_db_path(root)
try:
emb_store = EmbeddingStore(db_path, provider=provider, model=model)
except ValueError as exc:
# Unknown provider name or missing provider env vars — surface
# as a structured error rather than a traceback.
logger.error("embed_graph: %s", exc)
return {"status": "error", "error": str(exc)}
try:
if not emb_store.available:
if provider in ("openai", "google", "minimax"):
err = (
f"The '{provider}' embedding provider is not available. "
"Check the required environment variables "
"(see README and `get_provider()` docstring) and that "
"the endpoint is reachable."
)
else:
err = (
"The local embedding provider needs sentence-transformers. "
"Install with: pip install code-review-graph[embeddings] — "
"or switch provider to 'openai' / 'google' / 'minimax'."
)
return {"status": "error", "error": err}
newly_embedded = embed_all_nodes(store, emb_store)
total = emb_store.count()
return {
"status": "ok",
"summary": (
f"Embedded {newly_embedded} new node(s). "
f"Total embeddings: {total}. "
"Semantic search is now active."
),
"newly_embedded": newly_embedded,
"total_embeddings": total,
}
finally:
emb_store.close()
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 8: get_docs_section
# ---------------------------------------------------------------------------
def get_docs_section(
section_name: str, repo_root: str | None = None
) -> dict[str, Any]:
"""Return a specific section from the LLM-optimized reference.
Used by skills and Claude Code to load only the exact documentation
section needed, keeping token usage minimal (90%+ savings).
Args:
section_name: Exact section name. One of: usage, review-delta,
review-pr, commands, legal, watch, embeddings,
languages, troubleshooting.
repo_root: Repository root path. Auto-detected from current
directory if omitted.
Returns:
The section content, or an error if not found.
"""
import re as _re
search_roots: list[Path] = []
# Wheel install: docs are packaged inside code_review_graph/docs.
in_pkg_docs = (
Path(__file__).parent.parent
/ "docs"
/ "LLM-OPTIMIZED-REFERENCE.md"
)
if repo_root:
try:
search_roots.append(_validate_repo_root(Path(repo_root)))
except ValueError:
pass
elif in_pkg_docs.exists():
in_pkg_root = in_pkg_docs.parent.parent
search_roots.append(in_pkg_root)
if not repo_root:
project_root = find_project_root()
if project_root not in search_roots:
search_roots.append(project_root)
# Editable/source-tree fallback: docs live next to code_review_graph/.
pkg_docs = (
Path(__file__).parent.parent.parent
/ "docs"
/ "LLM-OPTIMIZED-REFERENCE.md"
)
if pkg_docs.exists():
pkg_root = pkg_docs.parent.parent
if pkg_root not in search_roots:
search_roots.append(pkg_root)
for search_root in search_roots:
candidate = search_root / "docs" / "LLM-OPTIMIZED-REFERENCE.md"
if candidate.exists():
content = candidate.read_text(encoding="utf-8", errors="replace")
match = _re.search(
rf'<section name="{_re.escape(section_name)}">'
r"(.*?)</section>",
content,
_re.DOTALL | _re.IGNORECASE,
)
if match:
return {
"status": "ok",
"section": section_name,
"content": match.group(1).strip(),
}
available = [
"usage", "review-delta", "review-pr", "commands",
"legal", "watch", "embeddings", "languages", "troubleshooting",
]
return {
"status": "not_found",
"error": (
f"Section '{section_name}' not found. "
f"Available: {', '.join(available)}"
),
}
# ---------------------------------------------------------------------------
# Tool 19: generate_wiki [DOCS]
# ---------------------------------------------------------------------------
def generate_wiki_func(
repo_root: str | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Generate a markdown wiki from the community structure.
[DOCS] Creates a wiki page for each detected community and an index
page. Pages are written to ``.code-review-graph/wiki/`` inside the
repository. Only regenerates pages whose content has changed unless
force=True.
Args:
repo_root: Repository root path. Auto-detected if omitted.
force: If True, regenerate all pages even if content is unchanged.
Returns:
Status with pages_generated, pages_updated, pages_unchanged counts.
"""
from ..incremental import get_data_dir
from ..wiki import generate_wiki
store, root = _get_store(repo_root)
try:
wiki_dir = get_data_dir(root) / "wiki"
result = generate_wiki(store, wiki_dir, force=force)
total = (
result["pages_generated"]
+ result["pages_updated"]
+ result["pages_unchanged"]
)
return {
"status": "ok",
"summary": (
f"Wiki generated: {result['pages_generated']} new, "
f"{result['pages_updated']} updated, "
f"{result['pages_unchanged']} unchanged "
f"({total} total pages)"
),
"wiki_dir": str(wiki_dir),
**result,
}
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 20: get_wiki_page [DOCS]
# ---------------------------------------------------------------------------
def get_wiki_page_func(
community_name: str,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Retrieve a specific wiki page by community name.
[DOCS] Returns the markdown content of the wiki page for the given
community. The wiki must have been generated first via generate_wiki.
Args:
community_name: Community name to look up (slugified for filename).
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Page content or not_found status.
"""
from ..incremental import get_data_dir
from ..wiki import get_wiki_page
root = _resolve_root(repo_root)
wiki_dir = get_data_dir(root) / "wiki"
content = get_wiki_page(wiki_dir, community_name)
if content is None:
return {
"status": "not_found",
"summary": f"No wiki page found for '{community_name}'.",
}
return {
"status": "ok",
"summary": (
f"Wiki page for '{community_name}' ({len(content)} chars)"
),
"content": content,
}
+176
View File
@@ -0,0 +1,176 @@
"""Tools 10, 11: list_flows, get_flow."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..flows import get_flow_by_id, get_flows
from ..hints import generate_hints, get_session
from ._common import _get_store
# ---------------------------------------------------------------------------
# Tool 10: list_flows [EXPLORE]
# ---------------------------------------------------------------------------
def list_flows(
repo_root: str | None = None,
sort_by: str = "criticality",
limit: int = 50,
kind: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""List execution flows in the codebase, sorted by criticality.
[EXPLORE] Retrieves stored execution flows from the knowledge graph.
Each flow represents a call chain starting from an entry point
(e.g. HTTP handler, CLI command, test function).
Args:
repo_root: Repository root path. Auto-detected if omitted.
sort_by: Sort column: criticality, depth, node_count, file_count,
or name.
limit: Maximum flows to return (default: 50).
kind: Optional filter by entry point kind (e.g. "Test", "Function").
detail_level: "standard" (default) returns full flow data;
"minimal" returns only name, criticality, and
node_count per flow.
Returns:
List of flows with criticality scores.
"""
store, root = _get_store(repo_root)
try:
fetch_limit = (
limit if not kind else limit * 10
) # fetch more when filtering
flows = get_flows(store, sort_by=sort_by, limit=fetch_limit)
if kind:
filtered = []
for f in flows:
ep_id = f.get("entry_point_id")
if ep_id is not None:
node_kind = store.get_node_kind_by_id(ep_id)
if node_kind == kind:
filtered.append(f)
flows = filtered[:limit]
if detail_level == "minimal":
flows = [
{
"name": f["name"],
"criticality": f["criticality"],
"node_count": f["node_count"],
}
for f in flows
]
result: dict[str, object] = {
"status": "ok",
"summary": f"Found {len(flows)} execution flow(s)",
"flows": flows,
}
result["_hints"] = generate_hints(
"list_flows", result, get_session()
)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 11: get_flow [EXPLORE]
# ---------------------------------------------------------------------------
def get_flow(
flow_id: int | None = None,
flow_name: str | None = None,
include_source: bool = False,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Get details of a single execution flow.
[EXPLORE] Retrieves full path details for a flow, including each step's
function name, file, and line numbers. Optionally includes source
snippets for every step in the path.
Args:
flow_id: Database ID of the flow (from list_flows).
flow_name: Name to search for (partial match). Ignored if flow_id
given.
include_source: If True, include source code snippets for each step.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Flow details with steps, or not_found status.
"""
store, root = _get_store(repo_root)
try:
flow: dict | None = None
if flow_id is not None:
flow = get_flow_by_id(store, flow_id)
elif flow_name is not None:
# Search flows by name match
all_flows = get_flows(
store, sort_by="criticality", limit=500
)
for f in all_flows:
if flow_name.lower() in f["name"].lower():
flow = get_flow_by_id(store, f["id"])
break
if flow is None:
return {
"status": "not_found",
"summary": "No flow found matching the given criteria.",
}
# Optionally include source snippets for each step
if include_source and "steps" in flow:
for step in flow["steps"]:
fp = Path(step["file"]) if step.get("file") else None
if fp is not None and not fp.is_absolute():
fp = root / fp
file_path = fp
if file_path and file_path.is_file():
try:
lines = file_path.read_text(
errors="replace"
).splitlines()
start = max(
0, (step.get("line_start") or 1) - 1
)
end = min(
len(lines),
step.get("line_end") or len(lines),
)
step["source"] = "\n".join(
f"{i + 1}: {lines[i]}"
for i in range(start, end)
)
except (OSError, UnicodeDecodeError):
step["source"] = "(could not read file)"
result = {
"status": "ok",
"summary": (
f"Flow '{flow['name']}': {flow['node_count']} nodes, "
f"depth {flow['depth']}, "
f"criticality {flow['criticality']:.4f}"
),
"flow": flow,
}
result["_hints"] = generate_hints(
"get_flow", result, get_session()
)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
+692
View File
@@ -0,0 +1,692 @@
"""Tools 2, 3, 5, 6, 9: query / search / stats helpers."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from ..context_savings import attach_context_savings, estimate_file_tokens
from ..embeddings import EmbeddingStore
from ..graph import _sanitize_name, edge_to_dict, node_to_dict
from ..hints import generate_hints, get_session
from ..incremental import get_changed_files, get_db_path, get_staged_and_unstaged
from ..search import hybrid_search
from ._common import _BUILTIN_CALL_NAMES, _get_store, _resolve_graph_file_paths
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool 2: get_impact_radius
# ---------------------------------------------------------------------------
_QUERY_PATTERNS = {
"callers_of": "Find all functions that call a given function",
"callees_of": "Find all functions called by a given function",
"imports_of": "Find all imports of a given file or module",
"importers_of": "Find all files that import a given file or module",
"children_of": "Find all nodes contained in a file or class",
"tests_for": "Find all tests for a given function or class",
"inheritors_of": "Find all classes that inherit from a given class",
"file_summary": "Get a summary of all nodes in a file",
}
def get_impact_radius(
changed_files: list[str] | None = None,
max_depth: int = 2,
max_results: int = 500,
repo_root: str | None = None,
base: str = "HEAD~1",
detail_level: str = "standard",
) -> dict[str, Any]:
"""Analyze the blast radius of changed files.
Args:
changed_files: Explicit list of changed file paths (relative to repo root).
If omitted, auto-detects from git diff.
max_depth: How many hops to traverse in the graph (default: 2).
max_results: Maximum impacted nodes to return (default: 500).
repo_root: Repository root path. Auto-detected if omitted.
base: Git ref for auto-detecting changes (default: HEAD~1).
detail_level: "standard" (full output) or "minimal" (summary only).
Returns:
Changed nodes, impacted nodes, impacted files, connecting edges,
plus ``truncated`` flag and ``total_impacted`` count.
"""
store, root = _get_store(repo_root)
try:
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changed files detected.",
"changed_nodes": [],
"impacted_nodes": [],
"impacted_files": [],
"truncated": False,
"total_impacted": 0,
}
# Resolve user-facing paths to the file paths stored in the graph.
original_tokens = estimate_file_tokens(root, changed_files)
abs_files = _resolve_graph_file_paths(store, root, changed_files)
result = store.get_impact_radius(
abs_files, max_depth=max_depth, max_nodes=max_results
)
changed_dicts = [node_to_dict(n) for n in result["changed_nodes"]]
impacted_dicts = [node_to_dict(n) for n in result["impacted_nodes"]]
edge_dicts = [edge_to_dict(e) for e in result["edges"]]
truncated = result["truncated"]
total_impacted = result["total_impacted"]
summary_parts = [
f"Blast radius for {len(changed_files)} changed file(s):",
f" - {len(changed_dicts)} nodes directly changed",
f" - {len(impacted_dicts)} nodes impacted (within {max_depth} hops)",
f" - {len(result['impacted_files'])} additional files affected",
]
if truncated:
summary_parts.append(
f" - Results truncated: showing {len(impacted_dicts)}"
f" of {total_impacted} impacted nodes"
)
if detail_level == "minimal":
impacted_count = len(impacted_dicts)
if impacted_count > 20:
risk = "high"
elif impacted_count > 5:
risk = "medium"
else:
risk = "low"
key_entities = [
n["name"] for n in impacted_dicts[:5]
]
minimal_response = {
"status": "ok",
"summary": "\n".join(summary_parts),
"risk": risk,
"impacted_file_count": len(result["impacted_files"]),
"key_entities": key_entities,
"truncated": truncated,
}
attach_context_savings(minimal_response, original_tokens=original_tokens)
return minimal_response
response = {
"status": "ok",
"summary": "\n".join(summary_parts),
"changed_files": changed_files,
"changed_nodes": changed_dicts,
"impacted_nodes": impacted_dicts,
"impacted_files": result["impacted_files"],
"edges": edge_dicts,
"truncated": truncated,
"total_impacted": total_impacted,
}
attach_context_savings(response, original_tokens=original_tokens)
return response
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 3: query_graph
# ---------------------------------------------------------------------------
def query_graph(
pattern: str,
target: str,
repo_root: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""Run a predefined graph query.
Args:
pattern: Query pattern. One of: callers_of, callees_of, imports_of,
importers_of, children_of, tests_for, inheritors_of, file_summary.
target: The node name, qualified name, or file path to query about.
repo_root: Repository root path. Auto-detected if omitted.
detail_level: "standard" (full output) or "minimal" (summary only).
Returns:
Matching nodes and edges for the query.
"""
store, root = _get_store(repo_root)
try:
if pattern not in _QUERY_PATTERNS:
return {
"status": "error",
"error": (
f"Unknown pattern '{pattern}'. "
f"Available: {list(_QUERY_PATTERNS.keys())}"
),
}
results: list[dict] = []
edges_out: list[dict] = []
# For callers_of, skip common builtins early (bare names only)
# "Who calls .map()?" returns hundreds of useless hits.
# Qualified names (e.g. "utils.py::map") bypass this filter.
if (
pattern == "callers_of"
and target in _BUILTIN_CALL_NAMES
and "::" not in target
):
return {
"status": "ok", "pattern": pattern, "target": target,
"description": _QUERY_PATTERNS[pattern],
"summary": (
f"'{target}' is a common builtin "
"— callers_of skipped to avoid noise."
),
"results": [], "edges": [],
}
# Resolve target - try as-is, then as absolute path, then search.
# file_summary targets are paths, so skip broad node search.
node = None
if pattern != "file_summary":
node = store.get_node(target)
if not node:
abs_target = str(root / target)
node = store.get_node(abs_target)
if not node:
# Search by name
candidates = store.search_nodes(target, limit=5)
if len(candidates) == 1:
node = candidates[0]
target = node.qualified_name
elif len(candidates) > 1:
return {
"status": "ambiguous",
"summary": (
f"Multiple matches for '{target}'. "
"Please use a qualified name."
),
"candidates": [node_to_dict(c) for c in candidates],
}
if not node and pattern != "file_summary":
return {
"status": "not_found",
"summary": f"No node found matching '{target}'.",
}
qn = node.qualified_name if node else target
if pattern == "callers_of":
seen_sources: set[str] = set()
for e in store.get_edges_by_target(qn):
if e.kind == "CALLS":
if e.source_qualified not in seen_sources:
seen_sources.add(e.source_qualified)
caller = store.get_node(e.source_qualified)
if caller:
results.append(node_to_dict(caller))
edges_out.append(edge_to_dict(e))
# Fallback: CALLS edges store unqualified target names
# (e.g. "generateTestCode") while qn is fully qualified
# (e.g. "file.ts::generateTestCode"). Search by plain name too.
if node:
for e in store.search_edges_by_target_name(node.name):
if e.source_qualified not in seen_sources:
seen_sources.add(e.source_qualified)
caller = store.get_node(e.source_qualified)
if caller:
results.append(node_to_dict(caller))
edges_out.append(edge_to_dict(e))
elif pattern == "callees_of":
seen_targets: set[str] = set()
for e in store.get_edges_by_source(qn):
if e.kind == "CALLS":
if e.target_qualified not in seen_targets:
seen_targets.add(e.target_qualified)
callee = store.get_node(e.target_qualified)
if callee:
results.append(node_to_dict(callee))
elif "::" not in e.target_qualified:
results.append({
"kind": "Function",
"name": e.target_qualified,
"qualified_name": e.target_qualified,
})
edges_out.append(edge_to_dict(e))
elif pattern == "imports_of":
for e in store.get_edges_by_source(qn):
if e.kind == "IMPORTS_FROM":
results.append({"import_target": e.target_qualified})
edges_out.append(edge_to_dict(e))
elif pattern == "importers_of":
# Find edges where target matches this file.
# Use resolve() to canonicalize the path, matching how
# _resolve_module_to_file stores edge targets.
abs_target = (
str((root / target).resolve()) if node is None
else node.file_path
)
for e in store.get_edges_by_target(abs_target):
if e.kind == "IMPORTS_FROM":
results.append({
"importer": e.source_qualified,
"file": e.file_path,
})
edges_out.append(edge_to_dict(e))
elif pattern == "children_of":
for e in store.get_edges_by_source(qn):
if e.kind == "CONTAINS":
child = store.get_node(e.target_qualified)
if child:
results.append(node_to_dict(child))
elif pattern == "tests_for":
for e in store.get_edges_by_target(qn):
if e.kind == "TESTED_BY":
test = store.get_node(e.source_qualified)
if test:
results.append(node_to_dict(test))
# Also search by naming convention
name = node.name if node else target
test_nodes = store.search_nodes(f"test_{name}", limit=10)
test_nodes += store.search_nodes(f"Test{name}", limit=10)
seen = {r.get("qualified_name") for r in results}
for t in test_nodes:
if t.qualified_name not in seen and t.is_test:
results.append(node_to_dict(t))
elif pattern == "inheritors_of":
for e in store.get_edges_by_target(qn):
if e.kind in ("INHERITS", "IMPLEMENTS"):
child = store.get_node(e.source_qualified)
if child:
results.append(node_to_dict(child))
edges_out.append(edge_to_dict(e))
# Fallback: INHERITS/IMPLEMENTS edges store unqualified base names
# (e.g. "Animal") while qn is fully qualified
# (e.g. "sample.dart::Animal"). Search by plain name too. See: #87
if not results and node:
for kind in ("INHERITS", "IMPLEMENTS"):
for e in store.search_edges_by_target_name(node.name, kind=kind):
child = store.get_node(e.source_qualified)
if child:
results.append(node_to_dict(child))
edges_out.append(edge_to_dict(e))
elif pattern == "file_summary":
graph_paths = _resolve_graph_file_paths(store, root, [target])
for graph_path in graph_paths:
for n in store.get_nodes_by_file(graph_path):
results.append(node_to_dict(n))
summary = (
f"Found {len(results)} result(s) "
f"for {pattern}('{target}')"
)
if detail_level == "minimal":
minimal_results = [
{
k: r[k]
for k in ("name", "kind", "file_path")
if k in r
}
for r in results[:5]
]
return {
"status": "ok",
"pattern": pattern,
"target": target,
"description": _QUERY_PATTERNS[pattern],
"summary": summary,
"result_count": len(results),
"results": minimal_results,
}
return {
"status": "ok",
"pattern": pattern,
"target": target,
"description": _QUERY_PATTERNS[pattern],
"summary": summary,
"results": results,
"edges": edges_out,
}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 5: semantic_search_nodes
# ---------------------------------------------------------------------------
def semantic_search_nodes(
query: str,
kind: str | None = None,
limit: int = 20,
repo_root: str | None = None,
context_files: list[str] | None = None,
model: str | None = None,
provider: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""Search for nodes by name, keyword, or semantic similarity.
Uses hybrid search (FTS5 BM25 + vector embeddings merged via Reciprocal
Rank Fusion) as the primary search path, with graceful fallback to
keyword matching.
Args:
query: Search string to match against node names and qualified names.
kind: Optional filter by node kind (File, Class, Function, Type, Test).
limit: Maximum results to return (default: 20).
repo_root: Repository root path. Auto-detected if omitted.
context_files: Optional list of file paths. Nodes in these files
receive a relevance boost.
detail_level: "standard" (full output) or "minimal" (summary only).
Returns:
Ranked list of matching nodes.
"""
store, root = _get_store(repo_root)
try:
results = hybrid_search(
store, query, kind=kind, limit=limit, context_files=context_files,
model=model, provider=provider,
)
search_mode = "hybrid"
if not results:
search_mode = "keyword"
summary = f"Found {len(results)} node(s) matching '{query}'" + (
f" (kind={kind})" if kind else ""
)
if detail_level == "minimal":
minimal_results = [
{
k: r[k]
for k in ("name", "kind", "file_path", "score")
if k in r
}
for r in results[:5]
]
return {
"status": "ok",
"query": query,
"search_mode": search_mode,
"summary": summary,
"results": minimal_results,
}
result: dict[str, object] = {
"status": "ok",
"query": query,
"search_mode": search_mode,
"summary": summary,
"results": results,
}
result["_hints"] = generate_hints(
"semantic_search_nodes", result, get_session()
)
return result
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 6: list_graph_stats
# ---------------------------------------------------------------------------
def list_graph_stats(repo_root: str | None = None) -> dict[str, Any]:
"""Get aggregate statistics about the knowledge graph.
Args:
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Total nodes, edges, breakdown by kind, languages, and last update time.
"""
store, root = _get_store(repo_root)
try:
stats = store.get_stats()
summary_parts = [
f"Graph statistics for {root.name}:",
f" Files: {stats.files_count}",
f" Total nodes: {stats.total_nodes}",
f" Total edges: {stats.total_edges}",
f" Languages: {', '.join(stats.languages) if stats.languages else 'none'}",
f" Last updated: {stats.last_updated or 'never'}",
"",
"Nodes by kind:",
]
for kind, count in sorted(stats.nodes_by_kind.items()):
summary_parts.append(f" {kind}: {count}")
summary_parts.append("")
summary_parts.append("Edges by kind:")
for kind, count in sorted(stats.edges_by_kind.items()):
summary_parts.append(f" {kind}: {count}")
# Add embedding info if available
emb_store = EmbeddingStore(get_db_path(root))
try:
emb_count = emb_store.count()
summary_parts.append("")
summary_parts.append(f"Embeddings: {emb_count} nodes embedded")
if not emb_store.available:
summary_parts.append(
" (install sentence-transformers for semantic search)"
)
finally:
emb_store.close()
return {
"status": "ok",
"summary": "\n".join(summary_parts),
"total_nodes": stats.total_nodes,
"total_edges": stats.total_edges,
"nodes_by_kind": stats.nodes_by_kind,
"edges_by_kind": stats.edges_by_kind,
"languages": stats.languages,
"files_count": stats.files_count,
"last_updated": stats.last_updated,
"embeddings_count": emb_count,
}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 9: find_large_functions
# ---------------------------------------------------------------------------
def find_large_functions(
min_lines: int = 50,
kind: str | None = None,
file_path_pattern: str | None = None,
limit: int = 50,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Find functions, classes, or files exceeding a line-count threshold.
Useful for identifying decomposition targets, code-quality audits,
and enforcing size limits during code review.
Args:
min_lines: Minimum line count to flag (default: 50).
kind: Filter by node kind: Function, Class, File, or Test.
file_path_pattern: Filter by file path substring (e.g. "components/").
limit: Maximum results (default: 50).
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Oversized nodes with line counts, ordered largest first.
"""
store, root = _get_store(repo_root)
try:
nodes = store.get_nodes_by_size(
min_lines=min_lines,
kind=kind,
file_path_pattern=file_path_pattern,
limit=limit,
)
results = []
for n in nodes:
d = node_to_dict(n)
d["line_count"] = (
(n.line_end - n.line_start + 1)
if n.line_start and n.line_end
else 0
)
# Make file_path relative for readability
try:
d["relative_path"] = str(Path(n.file_path).relative_to(root))
except ValueError:
d["relative_path"] = n.file_path
results.append(d)
summary_parts = [
f"Found {len(results)} node(s) with >= {min_lines} lines"
+ (f" (kind={kind})" if kind else "")
+ (f" matching '{file_path_pattern}'" if file_path_pattern else "")
+ ":",
]
for r in results[:10]:
summary_parts.append(
f" {r['line_count']:>4} lines | {r['kind']:>8} | "
f"{r['name']} ({r['relative_path']}:{r['line_start']})"
)
if len(results) > 10:
summary_parts.append(f" ... and {len(results) - 10} more")
return {
"status": "ok",
"summary": "\n".join(summary_parts),
"total_found": len(results),
"min_lines": min_lines,
"results": results,
}
finally:
store.close()
# -------------------------------------------------------------------
# traverse_graph: free-form BFS / DFS traversal
# -------------------------------------------------------------------
def traverse_graph_func(
query: str,
mode: str = "bfs",
depth: int = 3,
token_budget: int = 2000,
repo_root: str | None = None,
) -> dict[str, Any]:
"""BFS/DFS traversal from best-matching node.
Args:
query: Search string to find the starting node.
mode: "bfs" (breadth-first) or "dfs" (depth-first).
depth: Max traversal depth (1-6). Default: 3.
token_budget: Approximate token limit for results.
repo_root: Repository root path.
"""
store, root = _get_store(repo_root)
try:
results = hybrid_search(store, query, limit=1)
if not results:
return {
"error": f"No node matching '{query}'",
"nodes": [],
}
start_qn = results[0]["qualified_name"]
depth = max(1, min(depth, 6))
# BFS / DFS traversal
visited: dict[str, int] = {} # qn -> depth
queue: list[tuple[str, int]] = [
(start_qn, 0),
]
traversal: list[dict] = []
approx_tokens = 0
while queue:
if mode == "bfs":
current_qn, cur_depth = queue.pop(0)
else:
current_qn, cur_depth = queue.pop()
if current_qn in visited:
continue
if cur_depth > depth:
continue
visited[current_qn] = cur_depth
node = store.get_node(current_qn)
if not node:
continue
entry = {
"name": _sanitize_name(node.name),
"qualified_name": node.qualified_name,
"kind": node.kind,
"file": node.file_path,
"depth": cur_depth,
}
approx_tokens += len(str(entry)) // 4
if approx_tokens > token_budget:
break
traversal.append(entry)
# Get neighbours
out_edges = store.get_edges_by_source(
current_qn
)
in_edges = store.get_edges_by_target(
current_qn
)
for e in out_edges:
tgt = e.target_qualified
if tgt not in visited:
queue.append((tgt, cur_depth + 1))
for e in in_edges:
src = e.source_qualified
if src not in visited:
queue.append((src, cur_depth + 1))
return {
"start_node": start_qn,
"mode": mode,
"max_depth": depth,
"nodes_visited": len(traversal),
"traversal": traversal,
"truncated": approx_tokens > token_budget,
"next_tool_suggestions": [
"query_graph callers_of"
" -- focused relationship query",
"get_impact_radius"
" -- blast radius analysis",
],
}
finally:
store.close()
+168
View File
@@ -0,0 +1,168 @@
"""Tools 17, 18: refactor_func, apply_refactor_func."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..hints import generate_hints, get_session
from ..incremental import find_project_root
from ..refactor import (
apply_refactor,
find_dead_code,
rename_preview,
suggest_refactorings,
)
from ._common import _get_store, _validate_repo_root
# ---------------------------------------------------------------------------
# Tool 17: refactor_tool [REFACTOR]
# ---------------------------------------------------------------------------
def refactor_func(
mode: str = "rename",
old_name: str | None = None,
new_name: str | None = None,
kind: str | None = None,
file_pattern: str | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Unified refactoring entry point.
[REFACTOR] Supports three modes:
- ``rename``: Preview renaming a symbol (requires *old_name* and
*new_name*).
- ``dead_code``: Find unreferenced functions/classes.
- ``suggest``: Get community-driven refactoring suggestions.
Args:
mode: One of ``"rename"``, ``"dead_code"``, or ``"suggest"``.
old_name: (rename mode) Current symbol name.
new_name: (rename mode) Desired new name.
kind: (dead_code mode) Optional node kind filter.
file_pattern: (dead_code mode) Optional file path substring filter.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Mode-specific results dict.
"""
valid_modes = {"rename", "dead_code", "suggest"}
if mode not in valid_modes:
return {
"status": "error",
"error": (
f"Invalid mode '{mode}'. "
f"Must be one of: {', '.join(sorted(valid_modes))}"
),
}
store, root = _get_store(repo_root)
try:
if mode == "rename":
if not old_name or not new_name:
return {
"status": "error",
"error": (
"rename mode requires both old_name and new_name."
),
}
preview = rename_preview(store, old_name, new_name)
if preview is None:
return {
"status": "not_found",
"summary": f"No node found matching '{old_name}'.",
}
result = {
"status": "ok",
"summary": (
f"Rename preview: {old_name} -> {new_name}, "
f"{len(preview['edits'])} edit(s). "
f"Use apply_refactor_tool(refactor_id="
f"'{preview['refactor_id']}') to apply."
),
**preview,
}
result["_hints"] = generate_hints(
"refactor", result, get_session()
)
return result
elif mode == "dead_code":
dead = find_dead_code(
store, kind=kind, file_pattern=file_pattern, root=root
)
result = {
"status": "ok",
"summary": f"Found {len(dead)} dead code symbol(s).",
"dead_code": dead,
"total": len(dead),
}
result["_hints"] = generate_hints(
"refactor", result, get_session()
)
return result
else: # suggest
suggestions = suggest_refactorings(store)
result = {
"status": "ok",
"summary": (
f"Generated {len(suggestions)} "
"refactoring suggestion(s)."
),
"suggestions": suggestions,
"total": len(suggestions),
}
result["_hints"] = generate_hints(
"refactor", result, get_session()
)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 18: apply_refactor_tool [REFACTOR]
# ---------------------------------------------------------------------------
def apply_refactor_func(
refactor_id: str,
repo_root: str | None = None,
dry_run: bool = False,
) -> dict[str, Any]:
"""Apply a previously previewed refactoring to source files.
[REFACTOR] Validates the refactor_id, checks expiry, ensures all edit
paths are within the repo root, then performs exact string replacements.
Args:
refactor_id: ID returned by a prior ``refactor_tool(mode="rename")``
call.
repo_root: Repository root path. Auto-detected if omitted.
dry_run: If True, return a unified diff of what would change
without touching disk. The refactor_id remains valid so the
user can review the diff, then call again with ``dry_run=False``
to actually write the changes. See: #176
Returns:
Status with count of applied edits and modified files. When
``dry_run=True`` the response additionally contains ``would_modify``
(list of file paths) and ``diffs`` (map of file -> unified-diff
string).
"""
try:
root = (
_validate_repo_root(Path(repo_root))
if repo_root
else find_project_root()
)
except (RuntimeError, ValueError) as exc:
return {"status": "error", "error": str(exc)}
result = apply_refactor(refactor_id, root, dry_run=dry_run)
return result
+125
View File
@@ -0,0 +1,125 @@
"""Tools 21, 22: list_repos_func, cross_repo_search_func."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from ..graph import GraphStore
from ..incremental import get_db_path
from ..search import hybrid_search
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool 21: list_repos [REGISTRY]
# ---------------------------------------------------------------------------
def list_repos_func() -> dict[str, Any]:
"""List all registered repositories.
[REGISTRY] Returns the list of repositories registered in the global
multi-repo registry at ``~/.code-review-graph/registry.json``.
Returns:
List of registered repos with paths and aliases.
"""
from ..registry import Registry
try:
registry = Registry()
repos = registry.list_repos()
return {
"status": "ok",
"summary": f"{len(repos)} registered repository(ies)",
"repos": repos,
}
except Exception as exc:
return {"status": "error", "error": str(exc)}
# ---------------------------------------------------------------------------
# Tool 22: cross_repo_search [REGISTRY]
# ---------------------------------------------------------------------------
def cross_repo_search_func(
query: str,
kind: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
"""Search across all registered repositories.
[REGISTRY] Runs hybrid_search on each registered repo's graph database
and merges the results.
Args:
query: Search query string.
kind: Optional node kind filter (e.g. "Function", "Class").
limit: Maximum results per repo (default: 20).
Returns:
Combined search results from all registered repos.
"""
from ..registry import Registry
try:
registry = Registry()
repos = registry.list_repos()
if not repos:
return {
"status": "ok",
"summary": (
"No repositories registered. "
"Use 'register' to add repos."
),
"results": [],
}
all_results: list[dict[str, Any]] = []
searched_repos: list[str] = []
for repo_entry in repos:
repo_path = Path(repo_entry["path"])
db_path = get_db_path(repo_path)
if not db_path.exists():
continue
try:
store = GraphStore(str(db_path))
try:
results = hybrid_search(
store, query, kind=kind, limit=limit
)
alias = repo_entry.get("alias", repo_path.name)
for r in results:
r["repo"] = alias
r["repo_path"] = str(repo_path)
all_results.extend(results)
searched_repos.append(alias)
finally:
store.close()
except Exception as exc:
logger.warning(
"Search failed for %s: %s", repo_path, exc
)
# Sort all results by score descending
all_results.sort(
key=lambda r: r.get("score", 0), reverse=True
)
return {
"status": "ok",
"summary": (
f"Found {len(all_results)} result(s) across "
f"{len(searched_repos)} repo(s) for '{query}'"
),
"results": all_results[:limit],
"repos_searched": searched_repos,
}
except Exception as exc:
return {"status": "error", "error": str(exc)}
+477
View File
@@ -0,0 +1,477 @@
"""Tools 4, 12, 16: review context, affected flows, detect changes."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from ..changes import analyze_changes, parse_diff_ranges, parse_git_diff_ranges # noqa: F401
from ..context_savings import attach_context_savings, estimate_file_tokens
from ..flows import get_affected_flows as _get_affected_flows
from ..graph import edge_to_dict, node_to_dict
from ..hints import generate_hints, get_session
from ..incremental import get_changed_files, get_staged_and_unstaged
from ._common import _get_store, _resolve_graph_file_paths
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool 4: get_review_context
# ---------------------------------------------------------------------------
def get_review_context(
changed_files: list[str] | None = None,
max_depth: int = 2,
include_source: bool = True,
max_lines_per_file: int = 200,
repo_root: str | None = None,
base: str = "HEAD~1",
detail_level: str = "standard",
) -> dict[str, Any]:
"""Generate a focused review context from changed files.
Builds a token-optimized subgraph + source snippets for code review.
Args:
changed_files: Files to review (auto-detected from git diff if omitted).
max_depth: Impact radius depth (default: 2).
include_source: Whether to include source code snippets (default: True).
max_lines_per_file: Max source lines per file in output (default: 200).
repo_root: Repository root path. Auto-detected if omitted.
base: Git ref for change detection (default: HEAD~1).
detail_level: Output detail level. "standard" returns full context;
"minimal" returns summary, risk level, changed/impacted file counts,
top 5 key entity names, test gap count, and next tool suggestions.
Default: "standard".
Returns:
Structured review context with subgraph, source snippets, and
review guidance.
"""
store, root = _get_store(repo_root)
try:
# Get impact radius first
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changes detected. Nothing to review.",
"context": {},
}
graph_files = _resolve_graph_file_paths(store, root, changed_files)
original_tokens = estimate_file_tokens(root, changed_files)
impact = store.get_impact_radius(graph_files, max_depth=max_depth)
if detail_level == "minimal":
impacted_count = len(impact["impacted_nodes"])
if impacted_count > 20:
risk = "high"
elif impacted_count > 5:
risk = "medium"
else:
risk = "low"
key_entities = [
n.name for n in impact["changed_nodes"][:5]
]
# Count test gaps among changed functions.
changed_funcs = [
n for n in impact["changed_nodes"]
if n.kind == "Function" and not n.is_test
]
test_edges = [
e for e in impact["edges"] if e.kind == "TESTED_BY"
]
tested_qualified = {e.source_qualified for e in test_edges}
test_gap_count = sum(
1 for f in changed_funcs
if f.qualified_name not in tested_qualified
)
summary_parts = [
f"Review context for {len(changed_files)} changed file(s):",
f" - Risk: {risk}",
f" - {len(impact['impacted_nodes'])} impacted nodes"
f" in {len(impact['impacted_files'])} files",
]
result = {
"status": "ok",
"summary": "\n".join(summary_parts),
"risk": risk,
"changed_file_count": len(changed_files),
"impacted_file_count": len(impact["impacted_files"]),
"key_entities": key_entities,
"test_gaps": test_gap_count,
"next_tool_suggestions": [
"detect_changes",
"get_affected_flows",
"get_impact_radius",
],
}
attach_context_savings(result, original_tokens=original_tokens)
return result
# Build review context
context: dict[str, Any] = {
"changed_files": changed_files,
"impacted_files": impact["impacted_files"],
"graph": {
"changed_nodes": [
node_to_dict(n) for n in impact["changed_nodes"]
],
"impacted_nodes": [
node_to_dict(n) for n in impact["impacted_nodes"]
],
"edges": [edge_to_dict(e) for e in impact["edges"]],
},
}
# Add source snippets for changed files
if include_source:
snippets = {}
for rel_path in changed_files:
full_path = root / rel_path
if full_path.is_file():
try:
lines = full_path.read_text(
errors="replace"
).splitlines()
if len(lines) > max_lines_per_file:
# Include only the relevant functions/classes
relevant_lines = _extract_relevant_lines(
lines,
impact["changed_nodes"],
str(full_path),
)
snippets[rel_path] = relevant_lines
else:
snippets[rel_path] = "\n".join(
f"{i+1}: {line}"
for i, line in enumerate(lines)
)
except (OSError, UnicodeDecodeError):
snippets[rel_path] = "(could not read file)"
context["source_snippets"] = snippets
# Generate review guidance
guidance = _generate_review_guidance(impact, changed_files)
context["review_guidance"] = guidance
summary_parts = [
f"Review context for {len(changed_files)} changed file(s):",
f" - {len(impact['changed_nodes'])} directly changed nodes",
f" - {len(impact['impacted_nodes'])} impacted nodes"
f" in {len(impact['impacted_files'])} files",
"",
"Review guidance:",
guidance,
]
result = {
"status": "ok",
"summary": "\n".join(summary_parts),
"context": context,
}
attach_context_savings(result, original_tokens=original_tokens)
return result
finally:
store.close()
def _extract_relevant_lines(
lines: list[str], nodes: list, file_path: str
) -> str:
"""Extract only the lines relevant to changed nodes."""
ranges = []
for n in nodes:
if n.file_path == file_path:
start = max(0, n.line_start - 3) # 2 lines context before
end = min(len(lines), n.line_end + 2) # 1 line context after
ranges.append((start, end))
if not ranges:
# Show first N lines as fallback
return "\n".join(
f"{i+1}: {line}" for i, line in enumerate(lines[:50])
)
# Merge overlapping ranges
ranges.sort()
merged = [ranges[0]]
for start, end in ranges[1:]:
if start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
parts: list[str] = []
for start, end in merged:
if parts:
parts.append("...")
for i in range(start, end):
parts.append(f"{i+1}: {lines[i]}")
return "\n".join(parts)
def _generate_review_guidance(
impact: dict, changed_files: list[str]
) -> str:
"""Generate review guidance based on the impact analysis."""
guidance_parts = []
# Check for test coverage
changed_funcs = [
n for n in impact["changed_nodes"] if n.kind == "Function"
]
test_edges = [e for e in impact["edges"] if e.kind == "TESTED_BY"]
tested_funcs = {e.source_qualified for e in test_edges}
untested = [
f for f in changed_funcs
if f.qualified_name not in tested_funcs and not f.is_test
]
if untested:
guidance_parts.append(
f"- {len(untested)} changed function(s) lack test coverage: "
+ ", ".join(n.name for n in untested[:5])
)
# Check for wide blast radius
if len(impact["impacted_nodes"]) > 20:
guidance_parts.append(
f"- Wide blast radius: {len(impact['impacted_nodes'])} "
"nodes impacted. "
"Review callers and dependents carefully."
)
# Check for inheritance changes
inheritance_edges = [
e for e in impact["edges"]
if e.kind in ("INHERITS", "IMPLEMENTS")
]
if inheritance_edges:
guidance_parts.append(
f"- {len(inheritance_edges)} inheritance/implementation "
"relationship(s) affected. "
"Check for Liskov substitution violations."
)
# Check for cross-file impact
impacted_file_count = len(impact["impacted_files"])
if impacted_file_count > 3:
guidance_parts.append(
f"- Changes impact {impacted_file_count} other files."
" Consider splitting into smaller PRs."
)
if not guidance_parts:
guidance_parts.append(
"- Changes appear well-contained with minimal blast radius."
)
return "\n".join(guidance_parts)
# ---------------------------------------------------------------------------
# Tool 12: get_affected_flows [REVIEW]
# ---------------------------------------------------------------------------
def get_affected_flows_func(
changed_files: list[str] | None = None,
base: str = "HEAD~1",
repo_root: str | None = None,
) -> dict[str, Any]:
"""Find execution flows affected by changed files.
[REVIEW] Identifies which execution flows pass through nodes in the
changed files. Useful during code review to understand which user-facing
or critical paths are affected by a change.
Args:
changed_files: List of changed file paths (relative to repo root).
Auto-detected from git diff if omitted.
base: Git ref for auto-detecting changes (default: HEAD~1).
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Affected flows sorted by criticality, with step details.
"""
store, root = _get_store(repo_root)
try:
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changed files detected.",
"affected_flows": [],
"total": 0,
}
# Convert to absolute paths for graph lookup
abs_files = [str(root / f) for f in changed_files]
result = _get_affected_flows(store, abs_files)
total = result["total"]
out = {
"status": "ok",
"summary": (
f"{total} flow(s) affected by changes "
f"in {len(changed_files)} file(s)"
),
"changed_files": changed_files,
"affected_flows": result["affected_flows"],
"total": total,
}
out["_hints"] = generate_hints(
"get_affected_flows", out, get_session()
)
return out
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool 16: detect_changes [REVIEW]
# ---------------------------------------------------------------------------
def detect_changes_func(
base: str = "HEAD~1",
changed_files: list[str] | None = None,
include_source: bool = False,
max_depth: int = 2,
repo_root: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""Detect changes and produce risk-scored review guidance.
[REVIEW] Primary tool for code review. Maps git diffs to affected
functions, flows, communities, and test coverage gaps. Returns
priority-ordered review guidance with risk scores.
Args:
base: Git ref to diff against (default: HEAD~1).
changed_files: Explicit list of changed file paths (relative to repo
root). Auto-detected from git diff if omitted.
include_source: If True, include source code snippets for changed
functions. Default: False.
max_depth: Impact radius depth for BFS traversal. Default: 2.
repo_root: Repository root path. Auto-detected if omitted.
detail_level: Output detail level. "standard" returns full analysis;
"minimal" returns only summary, risk_score, changed_file_count,
test_gap_count, and top 3 review priorities (text only).
Default: "standard".
Returns:
Risk-scored analysis with changed functions, affected flows,
test gaps, and review priorities.
"""
store, root = _get_store(repo_root)
try:
# Detect changed files if not provided.
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changed files detected.",
"risk_score": 0.0,
"changed_functions": [],
"affected_flows": [],
"test_gaps": [],
"review_priorities": [],
}
original_tokens = estimate_file_tokens(root, changed_files)
# Convert to absolute paths for graph lookup.
abs_files = [str(root / f) for f in changed_files]
# Parse diff ranges for line-level mapping.
diff_ranges = parse_diff_ranges(str(root), base)
# Remap to absolute paths so they match graph file_paths.
abs_ranges: dict[str, list[tuple[int, int]]] = {}
for rel_path, ranges in diff_ranges.items():
abs_path = str(root / rel_path)
abs_ranges[abs_path] = ranges
analysis = analyze_changes(
store,
changed_files=abs_files,
changed_ranges=abs_ranges if abs_ranges else None,
repo_root=str(root),
base=base,
)
# Optionally include source snippets for changed functions.
if include_source:
for func in analysis.get("changed_functions", []):
fp = func.get("file_path")
ls = func.get("line_start")
le = func.get("line_end")
if fp and ls and le:
file_path = Path(fp)
if file_path.is_file():
try:
lines = file_path.read_text(
errors="replace"
).splitlines()
start = max(0, ls - 1)
end = min(len(lines), le)
func["source"] = "\n".join(
f"{i + 1}: {lines[i]}"
for i in range(start, end)
)
except (OSError, UnicodeDecodeError):
func["source"] = "(could not read file)"
if detail_level == "minimal":
priorities = analysis.get("review_priorities", [])
top_priorities = [
p.get("name", p.get("qualified_name", ""))
for p in priorities[:3]
]
result: dict[str, Any] = {
"status": "ok",
"summary": analysis.get("summary", ""),
"risk_score": analysis.get("risk_score", 0.0),
"changed_file_count": len(changed_files),
"test_gap_count": len(analysis.get("test_gaps", [])),
"review_priorities": top_priorities,
}
else:
result = {
"status": "ok",
"changed_files": changed_files,
**analysis,
}
result["_hints"] = generate_hints(
"detect_changes", result, get_session()
)
attach_context_savings(result, original_tokens=original_tokens)
return result
except Exception as exc:
return {"status": "error", "error": str(exc)}
finally:
store.close()