chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user