chore: restore original directory structure (project under code-review-graph-main/)

This commit is contained in:
AuraK Developer
2026-08-31 13:08:20 +08:00
parent ecc55158c1
commit ecfd03a21c
404 changed files with 0 additions and 0 deletions
@@ -0,0 +1,182 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 33 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
29. score_review - objective Layer-2 review metrics for changed files
30. dedupe_findings - fingerprint dedup + confidence merge for findings
31. generate_report - render the HTML and/or Markdown review report
32. coverage - risk-weighted deep-read coverage (project-review G3)
33. community_health - nodes.community_id attribution health check
"""
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,
with_provenance,
)
# -- 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,
)
# -- scoring (unified-review) ------------------------------------------------
from .scoring_tools import (
community_health_func,
coverage_func,
dedupe_findings_func,
deep_read_plan_func,
generate_report_func,
save_coverage_index_func,
score_review_func,
)
__all__ = [
# _common
"_BUILTIN_CALL_NAMES",
"_get_store",
"_validate_repo_root",
"with_provenance",
# 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",
# scoring (unified-review)
"score_review_func",
"dedupe_findings_func",
"generate_report_func",
"coverage_func",
"deep_read_plan_func",
"save_coverage_index_func",
"community_health_func",
# 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,292 @@
"""Shared utilities for tool sub-modules."""
from __future__ import annotations
import logging
import sqlite3
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from ..graph import GraphStore
from ..incremental import find_project_root, get_db_path
from ..parser import normalize_file_path
_PROVENANCE_READ_TIMEOUT_SECONDS = 0.05
_PROVENANCE_GIT_TIMEOUT_SECONDS = 1.0
logger = logging.getLogger(__name__)
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}
def _read_live_git_head(root: Path) -> str | None:
"""Return the checked-out commit without making provenance mandatory.
``head_matches_build`` deliberately compares commits only. It does not
claim that staged, unstaged, or untracked files are represented by the
graph, avoiding the misleading ``is_stale=False`` contract from #458.
"""
if not (root / ".git").exists():
return None
try:
result = subprocess.run(
["git", "rev-parse", "--verify", "HEAD"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(root),
timeout=_PROVENANCE_GIT_TIMEOUT_SECONDS,
stdin=subprocess.DEVNULL,
check=False,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
logger.debug("Could not read live Git HEAD for graph provenance", exc_info=True)
return None
if result.returncode != 0:
logger.debug("git rev-parse failed while reading graph provenance")
return None
head_sha = result.stdout.strip()
return head_sha or None
def graph_provenance(repo_root: str | None = None) -> dict[str, Any] | None:
"""Return best-effort build metadata for one repository's graph.
The metadata read is deliberately read-only. Missing, incomplete, or
unreadable graph databases must never make the enclosing tool call fail.
"""
try:
root = _resolve_root(repo_root)
db_path = get_db_path(root, read_only=True)
if not db_path.exists():
return None
# ``as_uri`` escapes URI-significant path characters before the
# read-only mode query is appended. It also handles Windows drives.
database_uri = f"{db_path.resolve().as_uri()}?mode=ro"
# Provenance is optional and reads only three local metadata rows.
# Allow a brief commit boundary, but never inherit sqlite3's 5-second
# default wait when a build or migration holds an exclusive lock.
connection = sqlite3.connect(
database_uri,
uri=True,
timeout=_PROVENANCE_READ_TIMEOUT_SECONDS,
)
try:
rows = dict(connection.execute(
"SELECT key, value FROM metadata WHERE key IN "
"('last_updated', 'git_branch', 'git_head_sha')"
).fetchall())
finally:
connection.close()
provenance: dict[str, Any] = {}
updated_at = rows.get("last_updated")
if isinstance(updated_at, str) and updated_at:
provenance["updated_at"] = updated_at
try:
built_at = datetime.fromisoformat(updated_at)
# Match aware timestamps with an aware ``now`` in the same
# timezone; None preserves the stored naive/local format.
now = datetime.now(tz=built_at.tzinfo)
provenance["age_seconds"] = max(
0, int((now - built_at).total_seconds()),
)
except (OverflowError, TypeError, ValueError):
# A malformed timestamp only removes the derived age. The raw
# timestamp and independently valid branch/SHA remain useful.
pass
head_sha = rows.get("git_head_sha")
if isinstance(head_sha, str) and head_sha:
provenance["built_at_sha"] = head_sha
if provenance:
branch = rows.get("git_branch")
if isinstance(branch, str) and branch:
provenance["built_on_branch"] = branch
live_head_sha = _read_live_git_head(root)
if live_head_sha:
provenance["head_sha"] = live_head_sha
if isinstance(head_sha, str) and head_sha:
provenance["head_matches_build"] = live_head_sha == head_sha
return provenance or None
except Exception:
return None
def with_provenance(result: Any, repo_root: str | None = None) -> Any:
"""Attach a ``_graph`` envelope without changing existing fields."""
if not isinstance(result, dict) or "_graph" in result:
return result
provenance = graph_provenance(repo_root)
if provenance:
result["_graph"] = provenance
return result
# 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(normalize_file_path(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,702 @@
"""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,
resolve_incremental_base,
)
from ._common import _get_store
logger = logging.getLogger(__name__)
def _run_embedding_refresh(
store: Any,
result: dict[str, Any],
warnings: list[str],
*,
provider: str | None,
model: str | None,
) -> None:
"""Run a provider-scoped embedding refresh only when explicitly requested."""
if provider is None and model is None:
return
if not provider or not model:
warning = "Embedding refresh requires both an explicit provider and model."
logger.warning(warning)
warnings.append(warning)
return
try:
from code_review_graph.embeddings import refresh_embeddings
refreshed = refresh_embeddings(store, provider=provider, model=model)
if refreshed is not None:
result["embeddings_refreshed"] = refreshed["embedded"]
result["embeddings_purged"] = refreshed["purged"]
except Exception as exc:
logger.warning("Embedding refresh failed: %s", exc)
warnings.append(
f"Embedding refresh failed: {type(exc).__name__}: {exc}",
)
def _run_postprocess(
store: Any,
build_result: dict[str, Any],
postprocess: str,
full_rebuild: bool = False,
changed_files: list[str] | None = None,
embedding_provider: str | None = None,
embedding_model: 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.
Records structured stage durations in ``build_result["postprocess_timing"]``.
Minimal processing reports ``signatures_s`` and ``fts_s``; full processing
additionally reports ``flows_s``, ``communities_s``, and ``summaries_s``.
Each duration is a nonnegative float measured in seconds.
Returns a list of warning strings (empty on success).
"""
warnings: list[str] = []
build_result["postprocess_level"] = postprocess
if postprocess == "none":
_run_embedding_refresh(
store,
build_result,
warnings,
provider=embedding_provider,
model=embedding_model,
)
return warnings
# Resolve bare and C++ scoped call targets before derived graph steps.
try:
resolved = store.resolve_bare_call_targets()
resolved += store.resolve_bare_tested_by_sources()
build_result["bare_edges_resolved"] = resolved
build_result["cpp_scoped_edges_resolved"] = (
store.resolve_cpp_scoped_call_targets()
)
except sqlite3.OperationalError as e:
logger.warning("Call-target resolution failed: %s", e)
warnings.append(
f"Call-target resolution failed: {type(e).__name__}: {e}"
)
# -- Signatures + FTS (fast, always run unless "none") --
timing: dict[str, float] = {}
stage_started = time.perf_counter()
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}")
timing["signatures_s"] = max(
0.0,
round(time.perf_counter() - stage_started, 6),
)
stage_started = time.perf_counter()
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}")
timing["fts_s"] = max(
0.0,
round(time.perf_counter() - stage_started, 6),
)
if postprocess == "minimal":
_run_embedding_refresh(
store,
build_result,
warnings,
provider=embedding_provider,
model=embedding_model,
)
build_result["postprocess_timing"] = timing
return warnings
# -- Expensive: flows + communities (only for "full") --
use_incremental = not full_rebuild and bool(changed_files)
stage_started = time.perf_counter()
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}")
timing["flows_s"] = max(
0.0,
round(time.perf_counter() - stage_started, 6),
)
stage_started = time.perf_counter()
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}")
timing["communities_s"] = max(
0.0,
round(time.perf_counter() - stage_started, 6),
)
# -- Compute pre-computed summary tables --
stage_started = time.perf_counter()
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}")
timing["summaries_s"] = max(
0.0,
round(time.perf_counter() - stage_started, 6),
)
_run_embedding_refresh(
store,
build_result,
warnings,
provider=embedding_provider,
model=embedding_model,
)
build_result["postprocess_timing"] = timing
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 | None = None,
postprocess: str = "full",
recurse_submodules: bool | None = None,
embedding_provider: str | None = None,
embedding_model: str | 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 the incremental diff. When None (default), the base
is resolved automatically to the commit the graph was last built
at, so a single update reconciles everything since the last sync
rather than only the most recent commit. Pass an explicit ref to
override. Ignored when full_rebuild is True.
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.
embedding_provider: Exact provider to use for an explicitly requested
post-build refresh. Must be supplied together with
``embedding_model``; omitted by default so builds never transmit
source-derived text or load an embedding model unexpectedly.
embedding_model: Exact model for an explicitly requested post-build
embedding refresh. Must be supplied with ``embedding_provider``.
Returns:
Summary with files_parsed/updated, node/edge counts, and errors.
"""
store, root = _get_store(repo_root)
try:
if not full_rebuild and not store.has_nodes():
full_rebuild = True
# An automatic (base is None) incremental update resolves its diff base
# to the last-synced commit. When no usable anchor exists, fall back to
# a full rebuild rather than a wrong HEAD~1 diff that could report the
# graph as up to date while it is actually stale.
base_resolved: str | None = base
if not full_rebuild and base is None:
base_resolved = resolve_incremental_base(root, store)
if base_resolved is None:
full_rebuild = True
if full_rebuild:
result = full_build(root, store, recurse_submodules)
build_result = {
**result,
"status": "ok",
"build_type": "full",
"base_resolved": None,
"summary": (
f"Full build complete: parsed {result['files_parsed']} files, "
f"created {result['total_nodes']} nodes and "
f"{result['total_edges']} edges."
),
}
else:
result = incremental_update(root, store, base=base_resolved)
if result["files_updated"] == 0:
return {
**result,
"status": "ok",
"build_type": "incremental",
"base_resolved": base_resolved,
"summary": "No changes detected. Graph is up to date.",
"postprocess_level": postprocess,
}
build_result = {
**result,
"status": "ok",
"build_type": "incremental",
"base_resolved": base_resolved,
"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']}."
),
}
# 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,
embedding_provider=embedding_provider,
embedding_model=embedding_model,
)
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,
embedding_provider: str | None = None,
embedding_model: 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.
embedding_provider: Exact provider for an explicit refresh. Must be
supplied with ``embedding_model``. Default: disabled.
embedding_model: Exact model for an explicit refresh. Must be supplied
with ``embedding_provider``. Default: disabled.
Returns:
Summary of what was computed.
"""
store, _root = _get_store(repo_root)
result: dict[str, Any] = {"status": "ok"}
warnings: list[str] = []
try:
try:
resolved = store.resolve_bare_call_targets()
resolved += store.resolve_bare_tested_by_sources()
result["bare_edges_resolved"] = resolved
result["cpp_scoped_edges_resolved"] = (
store.resolve_cpp_scoped_call_targets()
)
except sqlite3.OperationalError as e:
logger.warning("Call-target resolution failed: %s", e)
warnings.append(
f"Call-target resolution failed: {type(e).__name__}: {e}"
)
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}")
_run_embedding_refresh(
store,
result,
warnings,
provider=embedding_provider,
model=embedding_model,
)
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,189 @@
"""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 ..incremental import get_db_path
from ..parser import normalize_file_path
from ._common import _get_store, _resolve_root, compact_response, graph_provenance
logger = logging.getLogger(__name__)
def _not_ready(reason: str, summary: str) -> dict[str, Any]:
"""Return a compact response that directs callers to initialize the graph."""
return {
"status": "not_ready",
"reason": reason,
"summary": summary,
"next_tool_suggestions": ["build_or_update_graph"],
}
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.
Returns:
Compact graph context, or ``status: not_ready`` when the graph is
missing, empty, or known to have been built at a different Git commit.
"""
root = _resolve_root(repo_root)
db_path = get_db_path(root, read_only=True)
if not db_path.is_file():
return _not_ready(
"missing_graph",
"No graph database found. Build the graph before requesting context.",
)
store, root = _get_store(str(root))
try:
# 1. Quick stats
stats = store.get_stats()
if stats.total_nodes == 0:
return _not_ready(
"empty_graph",
"The graph database contains no nodes. Build the graph before requesting context.",
)
provenance = graph_provenance(str(root))
if provenance and provenance.get("head_matches_build") is False:
return _not_ready(
"stale_graph",
"The graph was built at a different Git commit. "
"Update it before requesting context.",
)
# 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 = [normalize_file_path(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,284 @@
"""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`` / ``voyage`` use
stdlib ``urllib``).
Default model: all-MiniLM-L6-v2. Override via ``model`` param or
provider-specific env vars such as CRG_EMBEDDING_MODEL, CRG_OPENAI_MODEL, or
CRG_VOYAGE_MODEL.
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; for voyage: Voyage model ID
(e.g. ``voyage-code-3``). Falls back to CRG_EMBEDDING_MODEL /
CRG_OPENAI_MODEL / CRG_VOYAGE_MODEL env vars as appropriate.
provider: Provider name: ``local`` (default), ``openai``, ``google``,
``minimax``, or ``voyage``. ``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.).
``voyage`` requires VOYAGE_API_KEY and defaults to
voyage-code-3 unless a model arg or CRG_VOYAGE_MODEL is
supplied.
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", "voyage"):
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' "
"/ 'voyage'."
)
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, unified-review, project-review, score-review,
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
if in_pkg_docs.exists():
in_pkg_root = in_pkg_docs.parent.parent
if in_pkg_root not in search_roots:
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", "unified-review",
"project-review", "score-review", "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,1016 @@
"""Tools 2, 3, 5, 6, 9: query / search / stats helpers."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
from ..config_keys import normalize_spring_config_key
from ..context_savings import attach_context_savings, estimate_file_tokens
from ..embeddings import EmbeddingStore
from ..graph import GraphNode, GraphStore, _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 ..parser import normalize_file_path
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",
"references_to": "Find all nodes that reference a given symbol",
"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",
"triggers_of": "Find methods invoked by a scheduler or other trigger",
"triggered_by": "Find schedulers or other triggers that invoke a method",
"publishers_of": "Find methods that publish an event",
"listeners_of": "Find methods that listen for an event",
"handlers_of": "Find methods that handle an endpoint",
"endpoints_for": "Find endpoints handled by a method",
"consumers_of": "Find classes that consume a Spring configuration property",
"file_summary": "Get a summary of all nodes in a file",
}
_JAVA_FQN_PART = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
_MAX_FQN_CANDIDATES = 100
def _looks_like_java_method_fqn(target: str) -> bool:
"""Return whether *target* has a package/Class/method-like shape."""
if "::" in target:
return False
parts = target.split(".")
if len(parts) < 2 or not all(_JAVA_FQN_PART.fullmatch(part) for part in parts):
return False
# Two segments are accepted only for the conventional Class.method form;
# this keeps ordinary dotted filenames/modules on the legacy path.
return len(parts) >= 3 or parts[-2][:1].isupper()
def _java_fqn_candidates(store: GraphStore, target: str) -> list[GraphNode] | None:
"""Resolve Java FQNs using language plus class/file evidence.
``None`` means that the target is not Java-FQN-shaped. An empty list means
it is shaped like one but no safe match exists, so callers must not fall
back to an unrelated globally unique method name.
"""
if not _looks_like_java_method_fqn(target):
return None
parts = target.split(".")
class_name, method_name = parts[-2:]
matches: list[GraphNode] = []
for candidate in store.search_nodes(method_name, limit=_MAX_FQN_CANDIDATES):
if candidate.language.lower() != "java" or candidate.name != method_name:
continue
parent_name = candidate.parent_name or ""
parent_match = parent_name.rsplit(".", 1)[-1] == class_name
file_match = Path(candidate.file_path).stem == class_name
qualified_tail = candidate.qualified_name.rsplit("::", 1)[-1]
qualified_match = qualified_tail.endswith(f"{class_name}.{method_name}")
if parent_match or file_match or qualified_match:
matches.append(candidate)
return matches
def _rank_disambiguation_candidates(
candidates: list[GraphNode], target: str,
) -> list[dict[str, Any]]:
"""Return deterministic, sanitized candidates ordered by match quality."""
target_lower = target.lower()
def score(node: GraphNode) -> tuple[int, str]:
if node.qualified_name == target:
rank = 0
elif node.name == target:
rank = 1
elif target_lower in node.qualified_name.lower():
rank = 2
else:
rank = 3
return rank, node.qualified_name
return [node_to_dict(node) for node in sorted(candidates, key=score)]
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.
"""
if isinstance(max_results, bool) or max_results < 1:
raise ValueError("max_results must be an integer greater than or equal to 1")
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
)
impact_scores = result.get("impact_scores", {})
changed_dicts = [node_to_dict(n) for n in result["changed_nodes"]]
impacted_dicts = []
for node in result["impacted_nodes"]:
node_dict = node_to_dict(node)
score = impact_scores.get(node.qualified_name)
if score is not None:
node_dict["impact_score"] = score
impacted_dicts.append(node_dict)
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,
"nodes_omitted": max(0, total_impacted - len(impacted_dicts)),
}
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,
"nodes_omitted": max(0, total_impacted - len(impacted_dicts)),
}
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",
max_results: int = 100,
) -> dict[str, Any]:
"""Run a predefined graph query.
Args:
pattern: Query pattern. One of: callers_of, references_to, callees_of,
imports_of, importers_of, children_of, tests_for, inheritors_of,
triggers_of, triggered_by, publishers_of, listeners_of,
handlers_of, endpoints_for, consumers_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).
max_results: Maximum results to return. Minimal mode additionally caps
visible results at five and reports the exact omitted count.
Returns:
Matching nodes and their aligned edges, with total and omitted counts.
"""
if isinstance(max_results, bool) or max_results < 1:
raise ValueError("max_results must be an integer greater than or equal to 1")
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())}"
),
}
response_limit = min(max_results, 5) if detail_level == "minimal" else max_results
results: list[dict[str, Any]] = []
edges_out: list[dict[str, Any]] = []
total_results = 0
def add_result(result: dict[str, Any], edge: Any | None = None) -> None:
"""Count every logical result but retain only the bounded prefix."""
nonlocal total_results
total_results += 1
if len(results) >= response_limit:
return
results.append(result)
if edge is not None:
edges_out.append(edge_to_dict(edge))
# 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."
),
"result_count": 0,
"results_omitted": 0,
"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
raw_config_target = pattern == "consumers_of" and "::" not in target
if pattern != "file_summary" and not raw_config_target:
node = store.get_node(target)
if not node:
abs_target = normalize_file_path(root / target)
node = store.get_node(abs_target)
if not node:
java_candidates = _java_fqn_candidates(store, target)
candidates = (
java_candidates
if java_candidates is not None
else store.search_nodes(target, limit=20)
)
if pattern == "inheritors_of" and "::" not in target:
exact_type_candidates = [
candidate
for candidate in candidates
if candidate.name == target
and candidate.kind
in {"Class", "Interface", "Type", "Struct", "Enum", "Trait"}
]
if exact_type_candidates:
candidates = exact_type_candidates
if len(candidates) == 1:
node = candidates[0]
target = node.qualified_name
elif len(candidates) > 1:
candidate_count = (
len(candidates)
if java_candidates is not None
else store.count_search_nodes(target)
)
ranked = _rank_disambiguation_candidates(candidates, target)
return {
"status": "ambiguous",
"summary": (
f"'{target}' matches {candidate_count} node(s). "
"Re-run with a qualified_name from disambiguation."
),
# Preserve the established key while adding the clearer
# agent-facing name introduced by #458.
"candidates": ranked,
"disambiguation": ranked,
"candidate_count": candidate_count,
"candidates_truncated": candidate_count > len(candidates),
"hint": (
"Use a qualified_name from disambiguation as the "
"target parameter."
),
}
if not node and pattern not in ("consumers_of", "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.iter_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:
add_result(node_to_dict(caller), 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:
cpp_overload_count = (
store.count_nodes_by_name(
node.name,
language="cpp",
kinds=("Function", "Test"),
)
if node.language == "cpp"
else 0
)
for e in store.iter_edges_by_target_name(
node.name,
language=node.language or None,
):
# A C++ overload set deliberately keeps the target bare.
# Its candidates support disambiguation, but do not prove
# that any one exact overload was called.
if (
"ambiguous_targets" in e.extra
or "unresolved_targets" in e.extra
or (node.language == "cpp" and e.extra.get("receiver"))
):
continue
if cpp_overload_count > 1:
continue
if e.source_qualified not in seen_sources:
seen_sources.add(e.source_qualified)
caller = store.get_node(e.source_qualified)
if caller:
caller_result = node_to_dict(caller)
caller_result["target_resolution"] = "unresolved"
add_result(caller_result, e)
elif pattern == "references_to":
seen_reference_sources: set[str] = set()
for e in store.iter_edges_by_target(qn):
if (
e.kind != "REFERENCES"
or e.source_qualified in seen_reference_sources
):
continue
source = store.get_node(e.source_qualified)
if source:
seen_reference_sources.add(e.source_qualified)
add_result(node_to_dict(source), e)
elif pattern == "callees_of":
seen_targets: set[str] = set()
for e in store.iter_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:
add_result(node_to_dict(callee), e)
elif (
isinstance(e.extra.get("ambiguous_targets"), list)
or isinstance(e.extra.get("unresolved_targets"), list)
or "::" not in e.target_qualified
or (node is not None and node.language == "cpp")
):
unresolved = (
e.extra.get("ambiguous_targets")
or e.extra.get("unresolved_targets")
)
result: dict[str, Any] = {
"kind": "Function",
"name": e.target_qualified,
"qualified_name": e.target_qualified,
}
if isinstance(unresolved, list):
resolution = (
"ambiguous"
if e.extra.get("ambiguous_targets")
else "unresolved"
)
result["resolution"] = resolution
result["candidates"] = [
_sanitize_name(candidate)
for candidate in unresolved[:20]
if isinstance(candidate, str)
]
candidate_count = e.extra.get(
f"{resolution}_target_count",
)
if not isinstance(candidate_count, int):
candidate_count = len(unresolved)
result["candidate_count"] = candidate_count
result["candidates_truncated"] = bool(
e.extra.get(
f"{resolution}_targets_truncated",
)
or candidate_count > len(result["candidates"])
)
add_result(result, e)
elif pattern == "imports_of":
for e in store.iter_edges_by_source(qn):
if e.kind == "IMPORTS_FROM":
add_result({"import_target": e.target_qualified}, 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
)
seen_importers: set[str] = set()
for e in store.iter_edges_by_target(abs_target):
if e.kind == "IMPORTS_FROM":
if e.source_qualified in seen_importers:
continue
seen_importers.add(e.source_qualified)
add_result({
"importer": e.source_qualified,
"file": e.file_path,
}, e)
# C# fallback: `using X.Y;` directives produce IMPORTS_FROM edges
# whose target is the raw namespace string, not a file path, so
# the path lookup above misses them. Resolve the target file's
# declared namespace(s) and also search edges by namespace.
# See: #310
if node is not None and node.language == "csharp":
declared_ns: list[str] = []
for n in store.iter_nodes_by_file(node.file_path):
if n.kind == "File":
declared_ns = list(
n.extra.get("csharp_namespaces", []) or []
)
break
for ns in declared_ns:
for e in store.iter_edges_by_target(ns):
if e.kind != "IMPORTS_FROM":
continue
if e.source_qualified in seen_importers:
continue
seen_importers.add(e.source_qualified)
add_result({
"importer": e.source_qualified,
"file": e.file_path,
}, e)
elif pattern == "children_of":
for e in store.iter_edges_by_source(qn):
if e.kind == "CONTAINS":
child = store.get_node(e.target_qualified)
if child:
add_result(node_to_dict(child))
elif pattern == "tests_for":
# Keep the normal sanitized node response while adding the
# direct/indirect marker returned by the bounded store lookup.
seen: set[str] = set()
for match in store.get_transitive_tests(qn):
test_qn = match.get("qualified_name")
if not isinstance(test_qn, str) or test_qn in seen:
continue
test = store.get_node(test_qn)
if test:
result = node_to_dict(test)
result["indirect"] = bool(match.get("indirect", False))
add_result(result)
seen.add(test_qn)
# Also search by naming convention
name = node.name if node else target
cpp_overload_set = bool(
node
and node.language == "cpp"
and store.count_nodes_by_name(
node.name,
language="cpp",
kinds=("Function", "Test"),
) > 1
)
test_nodes = []
if not cpp_overload_set:
test_nodes = store.search_nodes(f"test_{name}", limit=10)
test_nodes += store.search_nodes(f"Test{name}", limit=10)
for t in test_nodes:
if t.qualified_name not in seen and t.is_test:
result = node_to_dict(t)
result["indirect"] = False
result["inferred_by"] = "naming_convention"
add_result(result)
seen.add(t.qualified_name)
elif pattern == "inheritors_of":
for e in store.iter_edges_by_target(qn):
if e.kind in ("INHERITS", "IMPLEMENTS"):
child = store.get_node(e.source_qualified)
if child:
add_result(node_to_dict(child), 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 total_results == 0 and node:
for kind in ("INHERITS", "IMPLEMENTS"):
for e in store.iter_edges_by_target_name(
node.name, kind=kind, language=node.language or None,
):
child = store.get_node(e.source_qualified)
if child:
add_result(node_to_dict(child), e)
elif pattern == "triggers_of":
for edge in store.get_edges_by_source(qn):
if edge.kind != "TRIGGERS":
continue
triggered = store.get_node(edge.target_qualified)
if triggered:
add_result(node_to_dict(triggered), edge)
else:
edges_out.append(edge_to_dict(edge))
elif pattern == "triggered_by":
for edge in store.get_edges_by_target(qn):
if edge.kind != "TRIGGERS":
continue
trigger = store.get_node(edge.source_qualified)
if trigger:
add_result(node_to_dict(trigger), edge)
else:
edges_out.append(edge_to_dict(edge))
elif pattern in ("publishers_of", "listeners_of"):
edge_kind = "PUBLISHES" if pattern == "publishers_of" else "HANDLES"
for edge in store.get_edges_by_target(qn):
if edge.kind != edge_kind:
continue
source = store.get_node(edge.source_qualified)
if source:
add_result(node_to_dict(source), edge)
else:
edges_out.append(edge_to_dict(edge))
elif pattern == "handlers_of":
for edge in store.get_edges_by_target(qn):
if edge.kind != "HANDLES":
continue
handler = store.get_node(edge.source_qualified)
if handler:
add_result(node_to_dict(handler), edge)
else:
edges_out.append(edge_to_dict(edge))
elif pattern == "endpoints_for":
for edge in store.get_edges_by_source(qn):
if edge.kind != "HANDLES":
continue
endpoint = store.get_node(edge.target_qualified)
if endpoint and endpoint.kind == "Endpoint":
add_result(node_to_dict(endpoint), edge)
elif endpoint is None:
edges_out.append(edge_to_dict(edge))
elif pattern == "consumers_of":
raw_key = node.name if node else target.removeprefix("config:")
raw_key = raw_key.removesuffix(".*")
key = normalize_spring_config_key(raw_key)
seen_config_sources: set[str] = set()
for edge in store.get_config_consumers(key):
consumer = store.get_node(edge.source_qualified)
if consumer and consumer.qualified_name not in seen_config_sources:
add_result(node_to_dict(consumer), edge)
seen_config_sources.add(consumer.qualified_name)
elif consumer is None:
edges_out.append(edge_to_dict(edge))
elif pattern == "file_summary":
graph_paths = _resolve_graph_file_paths(store, root, [target])
for graph_path in graph_paths:
for n in store.iter_nodes_by_file(graph_path):
add_result(node_to_dict(n))
results_omitted = max(0, total_results - len(results))
summary = (
f"Found {total_results} result(s) "
f"for {pattern}('{target}')"
)
if results_omitted:
summary += f" — showing {len(results)}, {results_omitted} omitted"
if detail_level == "minimal":
minimal_results = [
{
k: r[k]
for k in ("name", "kind", "file_path", "indirect")
if k in r
}
for r in results
]
return {
"status": "ok",
"pattern": pattern,
"target": target,
"description": _QUERY_PATTERNS[pattern],
"summary": summary,
"result_count": total_results,
"results_omitted": results_omitted,
"results": minimal_results,
}
return {
"status": "ok",
"pattern": pattern,
"target": target,
"description": _QUERY_PATTERNS[pattern],
"summary": summary,
"result_count": total_results,
"results_omitted": results_omitted,
"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:
mode_out: list[str] = []
results = hybrid_search(
store, query, kind=kind, limit=limit, context_files=context_files,
model=model, provider=provider, _out_mode=mode_out,
)
search_mode = mode_out[0] if mode_out else "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_count": len(results),
"results_omitted": max(0, len(results) - len(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": [],
}
ranked_results: list[tuple[int, int, dict[str, Any]]] = []
searched_repos: list[str] = []
for repo_index, repo_entry in enumerate(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 local_rank, r in enumerate(results):
r["repo"] = alias
r["repo_path"] = str(repo_path)
ranked_results.append((local_rank, repo_index, r))
searched_repos.append(alias)
finally:
store.close()
except Exception as exc:
logger.warning(
"Search failed for %s: %s", repo_path, exc
)
# Scores from different search paths are not comparable across repos.
# Merge by each repo's local rank and use registry order as a stable tie-breaker.
ranked_results.sort(key=lambda item: (item[0], item[1]))
all_results = [result for _, _, result in ranked_results]
return {
"status": "ok",
"summary": (
f"Found {len(all_results)} result(s) across "
f"{len(searched_repos)} repo(s) for '{query}'"
),
"results": all_results,
"repos_searched": searched_repos,
}
except Exception as exc:
return {"status": "error", "error": str(exc)}
@@ -0,0 +1,480 @@
"""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 ..parser import normalize_file_path
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. Graph identity uses
# POSIX separators (#774), so normalize the joined paths.
abs_files = [normalize_file_path(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. Graph identity uses
# POSIX separators (#774), so normalize the joined paths.
abs_files = [normalize_file_path(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 = normalize_file_path(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()
@@ -0,0 +1,720 @@
"""MCP tool wrappers for the unified-review scoring workflow.
Wraps :mod:`code_review_graph.scoring` (score_review / dedupe_findings /
build_report_data) into the three MCP tools consumed by the
``unified-review`` skill:
* ``score_review_tool`` - objective Layer-2 metrics for changed files
* ``dedupe_findings_tool`` - fingerprint dedup + confidence merge
* ``generate_report_tool`` - render the HTML and/or Markdown review report
"""
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional
from ..incremental import get_changed_files, get_staged_and_unstaged
from ..scoring import (
build_report_data,
check_community_health,
compute_coverage,
dedupe_findings,
deep_read_plan,
render_markdown_report,
score_review,
)
from ._common import _get_store, _error_response
#: Persistent cross-round deep-read index (relative path -> per-file SHA).
COVERAGE_INDEX_REL = ".code-review-graph/coverage-index.json"
def _git_head_sha(repo_root: Path) -> str | None:
"""Current git HEAD SHA (None when not a repo / git unavailable)."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _git_file_sha(repo_root: Path, rel_path: str) -> str | None:
"""Blob SHA of the *current working-tree* content of ``rel_path``.
Uses ``git hash-object`` (content hash, does not touch the index) so an
uncommitted edit still counts as stale. Returns None when the file does
not exist in the working tree or git is unavailable.
"""
try:
result = subprocess.run(
["git", "hash-object", rel_path],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _coverage_index_path(repo_root: Path) -> Path:
return repo_root / COVERAGE_INDEX_REL
def _load_coverage_index(repo_root: Path) -> list[str]:
"""Return the relative paths whose per-file SHA still matches HEAD.
Files that changed since the last review are automatically excluded
(stale), so an incremental round never masks new code with old findings.
SHA check uses the graph's ``nodes.file_hash`` when the graph is
current, else falls back to a single batched ``git hash-object``
invocation (never one subprocess per file, see #46/#136).
"""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return []
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
entries = payload.get("entries") or {}
if not entries:
return []
return [
rel
for rel, info in entries.items()
if _index_entry_current(repo_root, rel, info)
]
def _load_coverage_index_full(
repo_root: Path,
) -> tuple[list[str], dict[str, list[list[int]]]]:
"""Load (current rel paths, their persisted read ranges)."""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return [], {}
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return [], {}
entries = payload.get("entries") or {}
if not entries:
return [], {}
current, ranges = [], {}
for rel, info in entries.items():
if _index_entry_current(repo_root, rel, info):
current.append(rel)
if info.get("ranges"):
ranges[rel] = info["ranges"]
return current, ranges
def _index_entry_current(repo_root: Path, rel: str, info: dict) -> bool:
"""True when a persisted entry's SHA matches the current file content."""
if not (repo_root / rel).is_file():
return False
expected = info.get("sha")
if not expected:
return False
# Prefer graph file_hash (single SQL fetch, no subprocess). Fall back to
# a batched git hash-object for files not in the graph.
graph_hash = _graph_file_hashes(repo_root).get(rel)
if graph_hash:
return graph_hash == expected
return _git_file_sha(repo_root, rel) == expected
_GRAPH_HASH_CACHE: dict[str, dict[str, str]] = {}
def _graph_file_hashes(repo_root: Path) -> dict[str, str]:
"""All File-node content hashes for a repo, cached (one SQL fetch)."""
key = str(repo_root)
if key in _GRAPH_HASH_CACHE:
return _GRAPH_HASH_CACHE[key]
result: dict[str, str] = {}
try:
store, root = _get_store(str(repo_root))
try:
rows = store._conn.execute(
"SELECT file_path, file_hash FROM nodes WHERE kind='File'"
).fetchall()
for r in rows:
fp = str(r["file_path"])
rel = fp.replace("\\", "/")
if rel.startswith(str(root).replace("\\", "/") + "/"):
rel = rel[len(str(root).replace("\\", "/")) + 1:].replace("/", "\\")
rel = rel.replace("\\", "/")
result[rel] = r["file_hash"] or ""
finally:
store.close()
except Exception:
result = {}
_GRAPH_HASH_CACHE[key] = result
return result
try:
from importlib.resources import files as _pkg_files # Python 3.9+
_HAS_IMPORTLIB_RESOURCES = True
except ImportError: # pragma: no cover
_HAS_IMPORTLIB_RESOURCES = False
# ---------------------------------------------------------------------------
# Tool: score_review
# ---------------------------------------------------------------------------
def score_review_func(
changed_files: list[str] | None = None,
base: str = "HEAD~1",
include_churn: bool = True,
repo_root: str | None = None,
detail_level: str = "standard",
all_files: bool = False,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability). These five objective metrics form the full report
metric set; ``llm_judged`` is returned empty for compatibility.
Args:
changed_files: Files to score (auto-detected from git diff if
omitted).
base: Git ref to diff against (default: HEAD~1).
include_churn: Include git-churn risk factors (default: True).
repo_root: Repository root. Auto-detected if omitted.
detail_level: Output detail level. ``minimal`` returns only grades
and values; ``standard`` includes evidence.
all_files: When True, score every source file in the graph,
ignoring ``changed_files`` and the git diff. Used for
whole-project reviews (default: False).
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
"""
store, root = _get_store(repo_root)
try:
if all_files:
changed_files = store.get_all_files()
elif 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. Nothing to score.",
"metrics": {},
"objective_grade": "good",
}
result = score_review(
store,
root,
changed_files,
include_churn=include_churn,
progress_cb=progress_cb,
)
if detail_level == "minimal":
return {
"status": "ok",
"summary": result["summary"],
"objective_grade": result["objective_grade"],
"metrics": {
name: {"value": m["value"], "grade": m["grade"]}
for name, m in result["metrics"].items()
},
"llm_judged": result["llm_judged"],
}
result["changed_files"] = changed_files
result["next_tool_suggestions"] = [
"dedupe_findings -- merge specialist findings",
"detect_changes -- risk-scored impact analysis",
"generate_report -- export HTML report",
]
return result
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: dedupe_findings
# ---------------------------------------------------------------------------
def dedupe_findings_func(
findings: list[dict[str, Any]],
suppress_prior: list[dict[str, Any]] | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Merge review findings by fingerprint and boost multi-source confidence.
Mirrors the gstack-review "collect and merge" step: findings with the
same ``path:line:category`` fingerprint are merged (highest confidence
wins); findings confirmed by more than one source get confidence +1
(cap 10). Confidence gates route low-confidence findings to the
appendix or suppress them, and a PR quality score is computed as
``max(0, 10 - (critical*2 + informational*0.5))``.
Args:
findings: Raw finding dicts with ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Previously user-skipped findings (from a prior
review-log) to suppress when their file has not changed.
repo_root: Repository root (used to resolve changed files when
suppressing prior findings).
"""
try:
suppressed_prior_list: list[dict[str, Any]] = []
if suppress_prior:
for f in suppress_prior:
suppressed_prior_list.append(f)
result = dedupe_findings(findings, suppressed_prior_list)
result["next_tool_suggestions"] = [
"generate_report -- export HTML report",
"detect_changes -- risk-scored impact analysis",
]
return result
except Exception as exc:
return _error_response(str(exc))
# ---------------------------------------------------------------------------
# Tool: coverage
# ---------------------------------------------------------------------------
def coverage_func(
deep_read_files: list[str],
all_files: bool = True,
include_churn: bool = True,
gate: str = "high_risk",
include_prior: bool = False,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
repo_root: str | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / number of all source files.
The high-risk coverage uses only the signal-flagged subset (also
file-count based). Per-file risk weights still rank the priority
deep-read list so the highest-risk files are read first. This backs
the project-review Step 7.5 coverage self-check (G3).
Args:
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths). Required.
all_files: When True, the denominator is every source file in the
graph (default: True, whole-project semantics).
include_churn: Include git-churn as the w3 weight term.
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
``"both"`` (both overall and high-risk must meet the target) /
``"both+line"`` (both + per-file line coverage >= line_target
and unit-completeness gap-free) / ``"line+unit"`` (line + unit
only, feature reviews; file-count / high-risk coverage are
skipped and returned as ``None``).
include_prior: When True, merge the cross-round coverage index
(files whose SHA is unchanged since the last review) into the
deep-read set.
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
sub-agent actually read per deep-read file (for gate="both+line").
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
reported semantic units per deep-read file (gate="both+line").
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with coverage_pct, grade, deep_read_count, total_files,
target_reached, target, remaining_files_to_target,
priority_deep_read_files, uncovered_files, silent_files and note.
``silent_files`` feeds the G2 spot-check sampling. For
``gate="both+line"`` / ``"line+unit"`` also returns
line_coverage_pct, unit_coverage_pct, line_gap_files,
unit_gap_files, unit_exempt_files.
"""
store, root = _get_store(repo_root)
try:
deep_read = list(deep_read_files or [])
ranges = dict(file_read_ranges or {})
units = dict(file_semantic_units or {})
if include_prior:
prior, prior_ranges = _load_coverage_index_full(root)
deep_read += prior
for rel, rr in prior_ranges.items():
ranges.setdefault(rel, rr)
return compute_coverage(
store,
root,
deep_read_files=deep_read,
include_churn=include_churn,
gate=gate,
file_read_ranges=ranges,
file_semantic_units=units,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def deep_read_plan_func(
repo_root: str | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
include_prior: bool = False,
deep_read_files: list[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects the highest-risk files not yet deep-read until the
file-count overall coverage target is reached, then groups them by
parent directory (≤ ``batch_size`` per group) so each group can be
dispatched to one parallel sub-agent.
Args:
repo_root: Repository root. Auto-detected if omitted.
target_coverage: Overall coverage target percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
include_prior: Exclude files already covered by the cross-round
coverage index (SHA unchanged) from the plan.
deep_read_files: Files already deep-read this round.
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with current_coverage_pct, current/target/remaining file
counts (plus compatible weight counts), planned_files
(priority-ordered), directory groups and estimated_batches.
"""
store, root = _get_store(repo_root)
try:
prior = set(_load_coverage_index(root)) if include_prior else None
return deep_read_plan(
store,
root,
deep_read_files=deep_read_files,
target_coverage=target_coverage,
batch_size=batch_size,
include_churn=include_churn,
prior_covered=prior,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def save_coverage_index_func(
deep_read_files: list[str],
repo_root: str | None = None,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
) -> dict[str, Any]:
"""Persist the deep-read file list to the cross-round coverage index.
Records each file's per-file SHA at HEAD so a later review can tell
which previously-deep-read files are still current (SHA unchanged)
and which are stale and must be re-read.
Version 2: SHA comes from the graph's ``nodes.file_hash`` (one SQL
query, zero subprocesses) instead of one ``git hash-object`` per file.
Line ranges (``file_read_ranges``) are persisted so a later review can
reuse the line coverage of SHA-unchanged files.
Args:
deep_read_files: Files deep-read this round (relative or absolute).
repo_root: Repository root. Auto-detected if omitted.
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
persist per file (index v2, reused by include_prior).
file_semantic_units: Optional per-file semantic units; persisted
for informational purposes (unit completeness is recomputed
against the live graph each round, so this is advisory only).
Returns:
Dict with index_path, entry count and head_sha.
"""
store, root = _get_store(repo_root)
store.close()
file_hashes = _graph_file_hashes(root)
def _hash_for(rel: str) -> str | None:
return file_hashes.get(rel) or _git_file_sha(root, rel)
entries: dict[str, dict[str, Any]] = {}
for f in deep_read_files or []:
p = Path(f)
rel = str(p.relative_to(root)) if p.is_absolute() else str(p)
rel = rel.replace("\\", "/")
entry: dict[str, Any] = {"sha": _hash_for(rel) or _git_file_sha(root, rel)}
ranges = (file_read_ranges or {}).get(rel)
if ranges:
entry["ranges"] = ranges
units = (file_semantic_units or {}).get(rel)
if units:
entry["units"] = units
entries[rel] = entry
payload = {
"version": 2,
"last_updated": datetime.now().isoformat(timespec="seconds"),
"head_sha": _git_head_sha(root),
"entries": entries,
}
index_path = _coverage_index_path(root)
try:
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
return _error_response(f"Failed to write coverage index: {exc}")
return {
"status": "ok",
"index_path": str(index_path),
"entries": len(entries),
"head_sha": payload["head_sha"],
}
def community_health_func(
repo_root: str | None = None,
) -> dict[str, Any]:
"""Check node->community attribution health (nodes.community_id).
Detects the desync where ``communities.size`` is correct but
``nodes.community_id`` is mostly NULL (e.g. after an incremental
rebuild). Returns ``needs_postprocess``; the review skill should run
``code-review-graph postprocess`` when True before computing coverage.
Args:
repo_root: Repository root. Auto-detected if omitted.
Returns:
Dict with total_nodes, attributed_nodes, non_file_nodes,
attribution_pct, needs_postprocess and note.
"""
store, root = _get_store(repo_root)
try:
return check_community_health(store)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: generate_report
# ---------------------------------------------------------------------------
def _load_report_template() -> str:
"""Load ``report-template.html`` from the installed package assets.
Falls back to the bundled template string when the asset cannot be
loaded, so the tool never fails solely because the package data is
missing.
"""
if _HAS_IMPORTLIB_RESOURCES:
try:
data = _pkg_files("code_review_graph").joinpath(
"assets/report-template.html"
).read_text(encoding="utf-8")
if data:
return data
except (FileNotFoundError, OSError):
pass
return _FALLBACK_TEMPLATE
# Minimal self-contained template (used if the asset file is unavailable).
_FALLBACK_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Code Review Report</title>
<style>
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
margin:2rem auto;max-width:900px;color:#1f2328;line-height:1.5}
h1{font-size:1.5rem} .verdict{font-weight:700}
.pass{color:#1a7f37}.fail{color:#cf222e}
table{border-collapse:collapse;width:100%;margin:1rem 0}
th,td{border:1px solid #d0d7de;padding:.4rem .6rem;text-align:left;font-size:.9rem}
th{background:#f6f8fa}
.good{color:#1a7f37}.warn{color:#bf8700}.fail{color:#cf222e}.na{color:#57606a}
.issue{margin:.5rem 0;padding:.6rem;border-radius:6px;background:#f6f8fa}
.tag{font-weight:700;margin-right:.4rem}
.location{color:#57606a;font-size:.85rem}
</style>
</head>
<body>
<div id="report"></div>
<script>
const data = {{REPORT_DATA}};
const el = document.getElementById("report");
let html = `<h1>Code Review Report</h1>
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
&middot; Tier: ${data.tier || "standard"} &middot; Scope: ${data.scope || "change-level"}</p>`;
if (data.files || (data.reviewed_files && data.reviewed_files.length)) {
if (Array.isArray(data.reviewed_files) && data.reviewed_files.length) {
html += `<details><summary><b>Files (${data.reviewed_files.length})</b></summary><ul>`
+ data.reviewed_files.map(f => `<li><code>${f}</code></li>`).join("")
+ `</ul></details>`;
} else {
html += `<p><b>Files:</b> ${data.files}</p>`;
}
}
if (data.summary) html += `<p>${data.summary}</p>`;
const cov = data.coverage || {};
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const covOk = cov.target_reached;
let c = `<p><b>Coverage:</b> ${covOk ? "target reached" : "coverage insufficient"}`;
if (cov.coverage_pct != null) {
c += ` &middot; overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
+ ` &middot; high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
}
if (cov.line_coverage_pct != null) {
c += ` &middot; line ${cov.line_coverage_pct}% &middot; unit ${cov.unit_coverage_pct}%`;
}
c += `</p>`;
html += c;
}
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th></tr>`;
for (const [k, m] of Object.entries(data.metrics || {})) {
html += `<tr><td>${k}</td><td>${m.value ?? "N/A"}</td>
<td class="${m.grade || "na"}">${m.grade || "N/A"}</td></tr>`;
}
html += `</table>`;
html += `<h2>Issues (${(data.issues || []).length})</h2>`;
for (const i of data.issues || []) {
html += `<div class="issue"><span class="tag ${i.severity}">${i.severity}</span>
<span>${i.message || ""}</span>
<div class="location">${i.location || ""}</div></div>`;
}
if (data.llm_judged && data.llm_judged.length) {
html += `<p><b>LLM-judged:</b> ${data.llm_judged.join(", ")}</p>`;
}
el.innerHTML = html;
</script>
</body>
</html>
"""
def generate_report_func(
review_data: dict[str, Any],
output_path: str | None = None,
repo_root: str | None = None,
format: str = "both",
) -> dict[str, Any]:
"""Generate code review reports (HTML and/or Markdown).
Renders ``review_data`` (the output of ``score_review_tool`` plus
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
a self-contained HTML report (``report-template.html``) and/or a
standalone Chinese Markdown report. Both are written by default.
``output_path`` is treated as the basename/directory of the output:
the extension is decided by ``format``, so ``both`` writes
``<base>.html`` and ``<base>.md``.
Args:
review_data: Review data dict (metrics, findings, verdict, tier,
scope, files, baseline, timestamp, manual_review).
output_path: Output base path (without an extension). Defaults to
``<repo_root>/code-review-report``.
repo_root: Repository root. Auto-detected if omitted.
format: Output format. ``html``, ``markdown``, or ``both``
(default: both).
"""
if format not in ("html", "markdown", "both"):
return _error_response(
f"Invalid format {format!r}; expected 'html', 'markdown' or 'both'."
)
store, root = _get_store(repo_root)
try:
data = build_report_data(review_data)
data["summary"] = review_data.get("summary", "")
if output_path:
base = Path(output_path)
if not base.is_absolute():
base = root / base
else:
base = root / "code-review-report"
written: list[dict[str, Any]] = []
if format in ("html", "both"):
template = _load_report_template()
# Escape "<" as "\u003c" so finding text can never close the
# surrounding <script> tag (e.g. a literal "</script>" in a
# message/fix). JSON keeps it a valid escape; the JS template
# string re-parses it as "<" before esc() HTML-escapes it.
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
rendered = template.replace("{{REPORT_DATA}}", payload)
html_path = base.with_suffix(".html")
html_path.parent.mkdir(parents=True, exist_ok=True)
html_path.write_text(rendered, encoding="utf-8")
written.append({
"format": "html",
"output_path": str(html_path),
"size_bytes": len(rendered),
})
if format in ("markdown", "both"):
rendered_md = render_markdown_report(review_data)
md_path = base.with_suffix(".md")
md_path.parent.mkdir(parents=True, exist_ok=True)
md_path.write_text(rendered_md, encoding="utf-8")
written.append({
"format": "markdown",
"output_path": str(md_path),
"size_bytes": len(rendered_md),
})
paths = [w["output_path"] for w in written]
return {
"status": "ok",
"summary": f"Report written to {', '.join(paths)}",
"output_path": paths[0] if len(paths) == 1 else paths,
"files": written,
"report_size_bytes": sum(w["size_bytes"] for w in written),
}
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()