code-review-graph-codeblackwell 2.3.6.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
code_review_graph/cli.py
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
"""CLI entry point for code-review-graph.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
code-review-graph install
|
|
5
|
+
code-review-graph init
|
|
6
|
+
code-review-graph build [--base BASE]
|
|
7
|
+
code-review-graph update [--base BASE]
|
|
8
|
+
code-review-graph watch
|
|
9
|
+
code-review-graph status
|
|
10
|
+
code-review-graph serve [--auto-watch] [--http] [--host ADDR] [--port PORT]
|
|
11
|
+
code-review-graph mcp [--auto-watch]
|
|
12
|
+
code-review-graph visualize
|
|
13
|
+
code-review-graph wiki
|
|
14
|
+
code-review-graph detect-changes [--base BASE] [--brief]
|
|
15
|
+
code-review-graph register <path> [--alias name]
|
|
16
|
+
code-review-graph unregister <path_or_alias>
|
|
17
|
+
code-review-graph repos
|
|
18
|
+
code-review-graph daemon start [--foreground]
|
|
19
|
+
code-review-graph daemon stop
|
|
20
|
+
code-review-graph daemon restart [--foreground]
|
|
21
|
+
code-review-graph daemon status
|
|
22
|
+
code-review-graph daemon logs [--repo ALIAS] [--follow] [--lines N]
|
|
23
|
+
code-review-graph daemon add <path> [--alias NAME]
|
|
24
|
+
code-review-graph daemon remove <path_or_alias>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
# Python version check — must come before any other imports
|
|
32
|
+
if sys.version_info < (3, 10):
|
|
33
|
+
print("code-review-graph requires Python 3.10 or higher.")
|
|
34
|
+
print(f" You are running Python {sys.version}")
|
|
35
|
+
print()
|
|
36
|
+
print("Install Python 3.10+: https://www.python.org/downloads/")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
from importlib.metadata import PackageNotFoundError
|
|
44
|
+
from importlib.metadata import version as pkg_version
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Shared platform choices for install and init commands
|
|
50
|
+
_PLATFORM_CHOICES = [
|
|
51
|
+
"codex", "claude", "claude-code", "cursor", "windsurf", "zed",
|
|
52
|
+
"continue", "opencode", "antigravity", "gemini-cli", "qwen", "kiro", "qoder",
|
|
53
|
+
"copilot", "copilot-cli", "all",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_version() -> str:
|
|
58
|
+
"""Get the installed package version.
|
|
59
|
+
|
|
60
|
+
Tries ``importlib.metadata`` first (canonical source from the installed
|
|
61
|
+
dist-info), falling back to the package's ``__version__`` attribute if
|
|
62
|
+
metadata is unavailable or corrupt. This matters for editable installs
|
|
63
|
+
on filesystems where iCloud / OneDrive can leave orphan dist-info dirs
|
|
64
|
+
behind that confuse importlib.metadata's lookup.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
v = pkg_version("code-review-graph")
|
|
68
|
+
if v:
|
|
69
|
+
return v
|
|
70
|
+
except PackageNotFoundError as exc:
|
|
71
|
+
logger.debug("Package metadata unavailable: %s", exc)
|
|
72
|
+
# Fallback: read __version__ directly from the package.
|
|
73
|
+
try:
|
|
74
|
+
from . import __version__ as fallback_version
|
|
75
|
+
if fallback_version:
|
|
76
|
+
return fallback_version
|
|
77
|
+
except ImportError:
|
|
78
|
+
pass
|
|
79
|
+
return "dev"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _supports_color() -> bool:
|
|
83
|
+
"""Check if the terminal likely supports ANSI colors."""
|
|
84
|
+
if os.environ.get("NO_COLOR"):
|
|
85
|
+
return False
|
|
86
|
+
if not hasattr(sys.stdout, "isatty"):
|
|
87
|
+
return False
|
|
88
|
+
return sys.stdout.isatty()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _print_disambiguated(result: dict, limit: int = 10) -> None:
|
|
92
|
+
"""Report duplicate symbol names that were disambiguated during a build."""
|
|
93
|
+
dups = result.get("disambiguated_nodes") or []
|
|
94
|
+
if not dups:
|
|
95
|
+
return
|
|
96
|
+
shown = ", ".join(dups[:limit])
|
|
97
|
+
more = f" (+{len(dups) - limit} more)" if len(dups) > limit else ""
|
|
98
|
+
print(f"Disambiguated {len(dups)} duplicate symbol name(s): {shown}{more}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _print_banner() -> None:
|
|
102
|
+
"""Print the startup banner with graph art and available commands."""
|
|
103
|
+
color = _supports_color()
|
|
104
|
+
version = _get_version()
|
|
105
|
+
|
|
106
|
+
# ANSI escape codes
|
|
107
|
+
c = "\033[36m" if color else "" # cyan — graph art
|
|
108
|
+
y = "\033[33m" if color else "" # yellow — center node
|
|
109
|
+
b = "\033[1m" if color else "" # bold
|
|
110
|
+
d = "\033[2m" if color else "" # dim
|
|
111
|
+
g = "\033[32m" if color else "" # green — commands
|
|
112
|
+
r = "\033[0m" if color else "" # reset
|
|
113
|
+
|
|
114
|
+
print(f"""
|
|
115
|
+
{c} ●──●──●{r}
|
|
116
|
+
{c} │╲ │ ╱│{r} {b}code-review-graph{r} {d}v{version}{r}
|
|
117
|
+
{c} ●──{y}◆{c}──●{r}
|
|
118
|
+
{c} │╱ │ ╲│{r} {d}Structural knowledge graph for{r}
|
|
119
|
+
{c} ●──●──●{r} {d}smarter code reviews{r}
|
|
120
|
+
|
|
121
|
+
{b}Commands:{r}
|
|
122
|
+
{g}install{r} Set up MCP server for AI coding platforms
|
|
123
|
+
{g}init{r} Alias for install
|
|
124
|
+
{g}build{r} Full graph build {d}(parse all files){r}
|
|
125
|
+
{g}update{r} Incremental update {d}(changed files only){r}
|
|
126
|
+
{g}watch{r} Auto-update on file changes
|
|
127
|
+
{g}status{r} Show graph statistics
|
|
128
|
+
{g}visualize{r} Generate interactive HTML graph
|
|
129
|
+
{g}wiki{r} Generate markdown wiki from communities
|
|
130
|
+
{g}detect-changes{r} Analyze change impact {d}(risk-scored review){r}
|
|
131
|
+
{g}register{r} Register a repository in the multi-repo registry
|
|
132
|
+
{g}unregister{r} Remove a repository from the registry
|
|
133
|
+
{g}repos{r} List registered repositories
|
|
134
|
+
{g}postprocess{r} Run post-processing {d}(flows, communities, FTS){r}
|
|
135
|
+
{g}daemon{r} Multi-repo watch daemon management
|
|
136
|
+
{g}eval{r} Run evaluation benchmarks
|
|
137
|
+
{g}serve{r} Start MCP server {d}(stdio, or {g}--http{r} on localhost:5555){r}
|
|
138
|
+
|
|
139
|
+
{d}Run{r} {b}code-review-graph <command> --help{r} {d}for details{r}
|
|
140
|
+
""")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _instruction_files_to_modify(
|
|
144
|
+
repo_root: Path,
|
|
145
|
+
target: str,
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
"""Return the list of instruction files that ``install`` would write
|
|
148
|
+
or modify, given the current state of the repo and the selected
|
|
149
|
+
platform target. Used for the dry-run / confirm preview (#173).
|
|
150
|
+
"""
|
|
151
|
+
from .skills import _CLAUDE_MD_SECTION_MARKER, _PLATFORM_INSTRUCTION_FILES
|
|
152
|
+
|
|
153
|
+
targets: list[str] = []
|
|
154
|
+
|
|
155
|
+
if target in ("claude", "all"):
|
|
156
|
+
claude_md = repo_root / "CLAUDE.md"
|
|
157
|
+
if claude_md.exists():
|
|
158
|
+
content = claude_md.read_text(encoding="utf-8")
|
|
159
|
+
if _CLAUDE_MD_SECTION_MARKER not in content:
|
|
160
|
+
targets.append("CLAUDE.md (append)")
|
|
161
|
+
else:
|
|
162
|
+
targets.append("CLAUDE.md (new)")
|
|
163
|
+
|
|
164
|
+
for filename, owners in _PLATFORM_INSTRUCTION_FILES.items():
|
|
165
|
+
if target != "all" and target not in owners:
|
|
166
|
+
continue
|
|
167
|
+
path = repo_root / filename
|
|
168
|
+
if path.exists():
|
|
169
|
+
content = path.read_text(encoding="utf-8")
|
|
170
|
+
if _CLAUDE_MD_SECTION_MARKER not in content:
|
|
171
|
+
targets.append(f"{filename} (append)")
|
|
172
|
+
else:
|
|
173
|
+
targets.append(f"{filename} (new)")
|
|
174
|
+
|
|
175
|
+
return targets
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _confirm_yes_no(prompt: str, default_yes: bool = True) -> bool:
|
|
179
|
+
"""Prompt the user [Y/n] and return True for yes.
|
|
180
|
+
|
|
181
|
+
Non-interactive environments (no TTY on stdin, e.g. an MCP wrapper
|
|
182
|
+
piping the CLI) return ``default_yes`` without blocking — the
|
|
183
|
+
stdio transport cannot safely read from stdin without corrupting
|
|
184
|
+
the JSON-RPC stream. See: #173, #174
|
|
185
|
+
"""
|
|
186
|
+
if not sys.stdin.isatty():
|
|
187
|
+
return default_yes
|
|
188
|
+
suffix = "[Y/n]" if default_yes else "[y/N]"
|
|
189
|
+
try:
|
|
190
|
+
answer = input(f"{prompt} {suffix} ").strip().lower()
|
|
191
|
+
except (EOFError, KeyboardInterrupt):
|
|
192
|
+
print()
|
|
193
|
+
return False
|
|
194
|
+
if not answer:
|
|
195
|
+
return default_yes
|
|
196
|
+
return answer in ("y", "yes")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _handle_init(args: argparse.Namespace) -> None:
|
|
200
|
+
"""Set up MCP config for detected AI coding platforms."""
|
|
201
|
+
from .incremental import ensure_repo_gitignore_excludes_crg, find_repo_root
|
|
202
|
+
from .skills import install_platform_configs
|
|
203
|
+
|
|
204
|
+
repo_root = Path(args.repo) if args.repo else find_repo_root()
|
|
205
|
+
if not repo_root:
|
|
206
|
+
repo_root = Path.cwd()
|
|
207
|
+
|
|
208
|
+
dry_run = getattr(args, "dry_run", False)
|
|
209
|
+
target = getattr(args, "platform", "all") or "all"
|
|
210
|
+
if target == "claude-code":
|
|
211
|
+
target = "claude"
|
|
212
|
+
auto_yes = getattr(args, "yes", False)
|
|
213
|
+
skip_instructions = getattr(args, "no_instructions", False)
|
|
214
|
+
|
|
215
|
+
print("Installing MCP server config...")
|
|
216
|
+
configured = install_platform_configs(repo_root, target=target, dry_run=dry_run)
|
|
217
|
+
|
|
218
|
+
if not configured:
|
|
219
|
+
print("No platforms detected.")
|
|
220
|
+
else:
|
|
221
|
+
print(f"\nConfigured {len(configured)} platform(s): {', '.join(configured)}")
|
|
222
|
+
|
|
223
|
+
# Preview the instruction files that would be touched (#173).
|
|
224
|
+
instr_targets = _instruction_files_to_modify(repo_root, target)
|
|
225
|
+
if instr_targets:
|
|
226
|
+
print()
|
|
227
|
+
print("Graph instructions will be injected into:")
|
|
228
|
+
for t in instr_targets:
|
|
229
|
+
print(f" {t}")
|
|
230
|
+
|
|
231
|
+
if dry_run:
|
|
232
|
+
print("\n[dry-run] Would ensure .gitignore ignores .code-review-graph/.")
|
|
233
|
+
print("[dry-run] No files were modified.")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
gitignore_state = ensure_repo_gitignore_excludes_crg(repo_root)
|
|
237
|
+
if gitignore_state == "created":
|
|
238
|
+
print("Created .gitignore and added .code-review-graph/.")
|
|
239
|
+
elif gitignore_state == "updated":
|
|
240
|
+
print("Updated .gitignore with .code-review-graph/.")
|
|
241
|
+
else:
|
|
242
|
+
print(".gitignore already contains .code-review-graph/.")
|
|
243
|
+
|
|
244
|
+
# Platform-native skills and hooks are installed by default where supported
|
|
245
|
+
# so the graph tools are used proactively. Use --no-skills / --no-hooks /
|
|
246
|
+
# --no-instructions to opt out.
|
|
247
|
+
skip_skills = getattr(args, "no_skills", False)
|
|
248
|
+
skip_hooks = getattr(args, "no_hooks", False)
|
|
249
|
+
# Legacy: --skills/--hooks/--all still accepted (no-op, everything is default)
|
|
250
|
+
|
|
251
|
+
from .skills import (
|
|
252
|
+
PLATFORMS,
|
|
253
|
+
generate_skills,
|
|
254
|
+
inject_claude_md,
|
|
255
|
+
inject_platform_instructions,
|
|
256
|
+
install_codex_hooks,
|
|
257
|
+
install_cursor_hooks,
|
|
258
|
+
install_gemini_cli_hooks,
|
|
259
|
+
install_gemini_cli_skills,
|
|
260
|
+
install_git_hook,
|
|
261
|
+
install_hooks,
|
|
262
|
+
install_opencode_plugin,
|
|
263
|
+
install_qoder_skills,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if not skip_skills:
|
|
267
|
+
# Claude Code skills are only relevant for Claude (or full install).
|
|
268
|
+
if target in ("claude", "all"):
|
|
269
|
+
skills_dir = generate_skills(repo_root)
|
|
270
|
+
print(f"Generated Claude Code skills in {skills_dir}")
|
|
271
|
+
|
|
272
|
+
# Gemini CLI skills are workspace-scoped under .gemini/.
|
|
273
|
+
if target in ("gemini-cli", "all"):
|
|
274
|
+
gemini_skills_dir = install_gemini_cli_skills(repo_root)
|
|
275
|
+
print(f"Installed Gemini CLI skills in {gemini_skills_dir}")
|
|
276
|
+
|
|
277
|
+
# Confirm before writing instruction files (#173). --yes skips the
|
|
278
|
+
# prompt; --no-instructions skips the whole block.
|
|
279
|
+
if not skip_instructions and instr_targets:
|
|
280
|
+
if auto_yes or _confirm_yes_no(
|
|
281
|
+
"Inject graph instructions into the files above?",
|
|
282
|
+
default_yes=True,
|
|
283
|
+
):
|
|
284
|
+
if target in ("claude", "all"):
|
|
285
|
+
inject_claude_md(repo_root)
|
|
286
|
+
inject_platform_instructions(repo_root, target=target)
|
|
287
|
+
# Use the precomputed instr_targets list for the confirmation
|
|
288
|
+
# message; we don't need the fresh return value from
|
|
289
|
+
# inject_platform_instructions here.
|
|
290
|
+
names = [t.split(" ")[0] for t in instr_targets]
|
|
291
|
+
print(f"Injected graph instructions into: {', '.join(names)}")
|
|
292
|
+
else:
|
|
293
|
+
print("Skipped instruction injection (user declined).")
|
|
294
|
+
elif skip_instructions:
|
|
295
|
+
print("Skipped instruction injection (--no-instructions).")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Install Qoder skills (global user-level skills directory)
|
|
299
|
+
if not skip_skills and target in ("qoder", "all"):
|
|
300
|
+
qoder_skills_dir = install_qoder_skills(repo_root)
|
|
301
|
+
if qoder_skills_dir:
|
|
302
|
+
print(f"Installed Qoder skills to {qoder_skills_dir}")
|
|
303
|
+
if not skip_hooks and target in ("codex", "all"):
|
|
304
|
+
hooks_path = install_codex_hooks(repo_root)
|
|
305
|
+
print(f"Installed Codex hooks in {hooks_path}")
|
|
306
|
+
git_hook = install_git_hook(repo_root)
|
|
307
|
+
if git_hook:
|
|
308
|
+
print(f"Installed git pre-commit hook in {git_hook}")
|
|
309
|
+
if not skip_hooks and target in ("claude", "qoder", "all"):
|
|
310
|
+
platforms_to_install = [target] if target != "all" else ["claude", "qoder"]
|
|
311
|
+
for plat in platforms_to_install:
|
|
312
|
+
install_hooks(repo_root, platform=plat)
|
|
313
|
+
print(f"Installed hooks in {repo_root / f'.{plat}' / 'settings.json'}")
|
|
314
|
+
git_hook = install_git_hook(repo_root)
|
|
315
|
+
if git_hook:
|
|
316
|
+
print(f"Installed git pre-commit hook in {git_hook}")
|
|
317
|
+
|
|
318
|
+
# Cursor hooks (user-level, only if ~/.cursor exists — matching MCP detect)
|
|
319
|
+
if not skip_hooks and target in ("all", "cursor") and PLATFORMS["cursor"]["detect"]():
|
|
320
|
+
try:
|
|
321
|
+
hooks_path = install_cursor_hooks()
|
|
322
|
+
print(f"Installed Cursor hooks in {hooks_path}")
|
|
323
|
+
except Exception as exc:
|
|
324
|
+
logger.warning("Could not install Cursor hooks: %s", exc)
|
|
325
|
+
|
|
326
|
+
if not skip_hooks and target in ("gemini-cli", "all"):
|
|
327
|
+
try:
|
|
328
|
+
gemini_settings = install_gemini_cli_hooks(repo_root)
|
|
329
|
+
print(f"Installed Gemini CLI hooks in {gemini_settings}")
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
logger.warning("Could not install Gemini CLI hooks: %s", exc)
|
|
332
|
+
|
|
333
|
+
# OpenCode plugin (user-level, gated by same detect() as MCP config)
|
|
334
|
+
if not skip_hooks and target in ("all", "opencode") and PLATFORMS["opencode"]["detect"]():
|
|
335
|
+
try:
|
|
336
|
+
plugin_path = install_opencode_plugin()
|
|
337
|
+
print(f"Installed OpenCode plugin in {plugin_path}")
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
logger.warning("Could not install OpenCode plugin: %s", exc)
|
|
340
|
+
|
|
341
|
+
print()
|
|
342
|
+
print("Next steps:")
|
|
343
|
+
print(" 1. code-review-graph build # build the knowledge graph")
|
|
344
|
+
print(" 2. Restart your AI coding tool to pick up the new config")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _handle_data_dir_option(args, repo_root: Path) -> None:
|
|
348
|
+
"""Handle --data-dir option by updating registry if specified."""
|
|
349
|
+
if hasattr(args, "data_dir") and args.data_dir:
|
|
350
|
+
try:
|
|
351
|
+
from .registry import Registry
|
|
352
|
+
data_dir_path = Path(args.data_dir).expanduser().resolve()
|
|
353
|
+
data_dir_path.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
Registry().set_data_dir(str(repo_root), str(data_dir_path))
|
|
355
|
+
logging.info(f"Graph database will be stored at: {data_dir_path}")
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
logging.error(f"Failed to set data directory: {exc}")
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main() -> None:
|
|
362
|
+
"""Main CLI entry point."""
|
|
363
|
+
ap = argparse.ArgumentParser(
|
|
364
|
+
prog="code-review-graph",
|
|
365
|
+
description="Persistent incremental knowledge graph for code reviews",
|
|
366
|
+
)
|
|
367
|
+
ap.add_argument("-v", "--version", action="store_true", help="Show version and exit")
|
|
368
|
+
sub = ap.add_subparsers(dest="command")
|
|
369
|
+
|
|
370
|
+
# install (primary) + init (alias)
|
|
371
|
+
install_cmd = sub.add_parser("install", help="Register MCP server with AI coding platforms")
|
|
372
|
+
install_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
373
|
+
install_cmd.add_argument(
|
|
374
|
+
"--dry-run",
|
|
375
|
+
action="store_true",
|
|
376
|
+
help="Show what would be done without writing files",
|
|
377
|
+
)
|
|
378
|
+
install_cmd.add_argument(
|
|
379
|
+
"--no-skills",
|
|
380
|
+
action="store_true",
|
|
381
|
+
help="Skip generating platform-native skill files",
|
|
382
|
+
)
|
|
383
|
+
install_cmd.add_argument(
|
|
384
|
+
"--no-hooks",
|
|
385
|
+
action="store_true",
|
|
386
|
+
help="Skip installing platform-native hooks",
|
|
387
|
+
)
|
|
388
|
+
install_cmd.add_argument(
|
|
389
|
+
"--no-instructions",
|
|
390
|
+
action="store_true",
|
|
391
|
+
help="Skip injecting graph instructions into CLAUDE.md / AGENTS.md / etc.",
|
|
392
|
+
)
|
|
393
|
+
install_cmd.add_argument(
|
|
394
|
+
"-y",
|
|
395
|
+
"--yes",
|
|
396
|
+
action="store_true",
|
|
397
|
+
help="Auto-confirm instruction injection without an interactive prompt",
|
|
398
|
+
)
|
|
399
|
+
# Legacy flags (kept for backwards compat, now no-ops since all is default)
|
|
400
|
+
install_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS)
|
|
401
|
+
install_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS)
|
|
402
|
+
install_cmd.add_argument(
|
|
403
|
+
"--all", action="store_true", dest="install_all", help=argparse.SUPPRESS
|
|
404
|
+
)
|
|
405
|
+
install_cmd.add_argument(
|
|
406
|
+
"--platform",
|
|
407
|
+
choices=_PLATFORM_CHOICES,
|
|
408
|
+
default="all",
|
|
409
|
+
help="Target platform for MCP config (default: all detected)",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
init_cmd = sub.add_parser("init", help="Alias for install")
|
|
413
|
+
init_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
414
|
+
init_cmd.add_argument(
|
|
415
|
+
"--dry-run",
|
|
416
|
+
action="store_true",
|
|
417
|
+
help="Show what would be done without writing files",
|
|
418
|
+
)
|
|
419
|
+
init_cmd.add_argument(
|
|
420
|
+
"--no-skills",
|
|
421
|
+
action="store_true",
|
|
422
|
+
help="Skip generating platform-native skill files",
|
|
423
|
+
)
|
|
424
|
+
init_cmd.add_argument(
|
|
425
|
+
"--no-hooks",
|
|
426
|
+
action="store_true",
|
|
427
|
+
help="Skip installing platform-native hooks",
|
|
428
|
+
)
|
|
429
|
+
init_cmd.add_argument(
|
|
430
|
+
"--no-instructions",
|
|
431
|
+
action="store_true",
|
|
432
|
+
help="Skip injecting graph instructions into CLAUDE.md / AGENTS.md / etc.",
|
|
433
|
+
)
|
|
434
|
+
init_cmd.add_argument(
|
|
435
|
+
"-y",
|
|
436
|
+
"--yes",
|
|
437
|
+
action="store_true",
|
|
438
|
+
help="Auto-confirm instruction injection without an interactive prompt",
|
|
439
|
+
)
|
|
440
|
+
init_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS)
|
|
441
|
+
init_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS)
|
|
442
|
+
init_cmd.add_argument("--all", action="store_true", dest="install_all", help=argparse.SUPPRESS)
|
|
443
|
+
init_cmd.add_argument(
|
|
444
|
+
"--platform",
|
|
445
|
+
choices=_PLATFORM_CHOICES,
|
|
446
|
+
default="all",
|
|
447
|
+
help="Target platform for MCP config (default: all detected)",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# build
|
|
451
|
+
build_cmd = sub.add_parser("build", help="Full graph build (re-parse all files)")
|
|
452
|
+
build_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
453
|
+
build_cmd.add_argument(
|
|
454
|
+
"--skip-flows",
|
|
455
|
+
action="store_true",
|
|
456
|
+
help="Skip flow/community detection (signatures + FTS only)",
|
|
457
|
+
)
|
|
458
|
+
build_cmd.add_argument(
|
|
459
|
+
"--skip-postprocess",
|
|
460
|
+
action="store_true",
|
|
461
|
+
help="Skip all post-processing (raw parse only)",
|
|
462
|
+
)
|
|
463
|
+
build_cmd.add_argument(
|
|
464
|
+
"--data-dir",
|
|
465
|
+
default=None,
|
|
466
|
+
help="External directory to store graph database (useful for network shares)"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# update
|
|
470
|
+
update_cmd = sub.add_parser("update", help="Incremental update (only changed files)")
|
|
471
|
+
update_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)")
|
|
472
|
+
update_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
473
|
+
update_cmd.add_argument(
|
|
474
|
+
"--skip-flows",
|
|
475
|
+
action="store_true",
|
|
476
|
+
help="Skip flow/community detection (signatures + FTS only)",
|
|
477
|
+
)
|
|
478
|
+
update_cmd.add_argument(
|
|
479
|
+
"--skip-postprocess",
|
|
480
|
+
action="store_true",
|
|
481
|
+
help="Skip all post-processing (raw parse only)",
|
|
482
|
+
)
|
|
483
|
+
update_cmd.add_argument(
|
|
484
|
+
"--brief",
|
|
485
|
+
action="store_true",
|
|
486
|
+
help="After re-parsing changed files into the graph, also print the "
|
|
487
|
+
"risk summary + Token Savings panel that 'detect-changes --brief' "
|
|
488
|
+
"prints. Use this after a rebase or large change set when you "
|
|
489
|
+
"want to refresh the graph AND see the impact in one command; "
|
|
490
|
+
"use 'detect-changes --brief' alone when the graph is already "
|
|
491
|
+
"up to date (analysis only, no re-parse).",
|
|
492
|
+
)
|
|
493
|
+
update_cmd.add_argument(
|
|
494
|
+
"--verify",
|
|
495
|
+
action="store_true",
|
|
496
|
+
help="Calibrate the estimated savings against tiktoken's "
|
|
497
|
+
"cl100k_base tokenizer (the GPT-4 family tokenizer). Adds a "
|
|
498
|
+
"second row to the panel with the real token counts. Requires "
|
|
499
|
+
"`pip install tiktoken`.",
|
|
500
|
+
)
|
|
501
|
+
update_cmd.add_argument(
|
|
502
|
+
"--data-dir",
|
|
503
|
+
default=None,
|
|
504
|
+
help="External directory to store graph database (useful for network shares)"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# postprocess
|
|
508
|
+
pp_cmd = sub.add_parser(
|
|
509
|
+
"postprocess",
|
|
510
|
+
help="Run post-processing on existing graph (flows, communities, FTS)",
|
|
511
|
+
)
|
|
512
|
+
pp_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
513
|
+
pp_cmd.add_argument("--no-flows", action="store_true", help="Skip flow detection")
|
|
514
|
+
pp_cmd.add_argument("--no-communities", action="store_true", help="Skip community detection")
|
|
515
|
+
pp_cmd.add_argument("--no-fts", action="store_true", help="Skip FTS rebuild")
|
|
516
|
+
pp_cmd.add_argument(
|
|
517
|
+
"--data-dir",
|
|
518
|
+
default=None,
|
|
519
|
+
help="External directory to store graph database (useful for network shares)"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# embed
|
|
523
|
+
embed_cmd = sub.add_parser(
|
|
524
|
+
"embed",
|
|
525
|
+
help="Compute vector embeddings for semantic search",
|
|
526
|
+
)
|
|
527
|
+
embed_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
528
|
+
embed_cmd.add_argument(
|
|
529
|
+
"--provider",
|
|
530
|
+
choices=["local", "openai", "google", "minimax"],
|
|
531
|
+
default=None,
|
|
532
|
+
help="Embedding provider (default: local, needs code-review-graph[embeddings])",
|
|
533
|
+
)
|
|
534
|
+
embed_cmd.add_argument(
|
|
535
|
+
"--model",
|
|
536
|
+
default=None,
|
|
537
|
+
help="Embedding model. For local: HuggingFace ID (default all-MiniLM-L6-v2); "
|
|
538
|
+
"for openai/google/minimax: provider-specific model ID.",
|
|
539
|
+
)
|
|
540
|
+
embed_cmd.add_argument(
|
|
541
|
+
"--data-dir",
|
|
542
|
+
default=None,
|
|
543
|
+
help="External directory to store graph database (useful for network shares)"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# watch
|
|
547
|
+
watch_cmd = sub.add_parser("watch", help="Watch for changes and auto-update")
|
|
548
|
+
watch_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
549
|
+
watch_cmd.add_argument(
|
|
550
|
+
"--data-dir",
|
|
551
|
+
default=None,
|
|
552
|
+
help="External directory to store graph database (useful for network shares)"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# status
|
|
556
|
+
status_cmd = sub.add_parser("status", help="Show graph statistics")
|
|
557
|
+
status_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
558
|
+
status_cmd.add_argument(
|
|
559
|
+
"--data-dir",
|
|
560
|
+
default=None,
|
|
561
|
+
help="External directory to store graph database (useful for network shares)"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# visualize
|
|
565
|
+
vis_cmd = sub.add_parser("visualize", help="Generate interactive HTML graph visualization")
|
|
566
|
+
vis_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
567
|
+
vis_cmd.add_argument(
|
|
568
|
+
"--mode",
|
|
569
|
+
choices=["auto", "full", "community", "file"],
|
|
570
|
+
default="auto",
|
|
571
|
+
help="Rendering mode: auto (default), full, community, or file",
|
|
572
|
+
)
|
|
573
|
+
vis_cmd.add_argument(
|
|
574
|
+
"--serve",
|
|
575
|
+
action="store_true",
|
|
576
|
+
help="Start a local HTTP server to view the visualization (localhost:8765)",
|
|
577
|
+
)
|
|
578
|
+
vis_cmd.add_argument(
|
|
579
|
+
"--format",
|
|
580
|
+
choices=["html", "graphml", "cypher", "obsidian", "svg"],
|
|
581
|
+
default="html",
|
|
582
|
+
help="Export format (default: html)",
|
|
583
|
+
)
|
|
584
|
+
vis_cmd.add_argument(
|
|
585
|
+
"--data-dir",
|
|
586
|
+
default=None,
|
|
587
|
+
help="External directory to store graph database (useful for network shares)"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# wiki
|
|
591
|
+
wiki_cmd = sub.add_parser("wiki", help="Generate markdown wiki from community structure")
|
|
592
|
+
wiki_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
593
|
+
wiki_cmd.add_argument(
|
|
594
|
+
"--force",
|
|
595
|
+
action="store_true",
|
|
596
|
+
help="Regenerate all pages even if content unchanged",
|
|
597
|
+
)
|
|
598
|
+
wiki_cmd.add_argument(
|
|
599
|
+
"--data-dir",
|
|
600
|
+
default=None,
|
|
601
|
+
help="External directory to store graph database (useful for network shares)"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# register
|
|
605
|
+
register_cmd = sub.add_parser(
|
|
606
|
+
"register", help="Register a repository in the multi-repo registry"
|
|
607
|
+
)
|
|
608
|
+
register_cmd.add_argument("path", help="Path to the repository root")
|
|
609
|
+
register_cmd.add_argument("--alias", default=None, help="Short alias for the repository")
|
|
610
|
+
|
|
611
|
+
# unregister
|
|
612
|
+
unregister_cmd = sub.add_parser(
|
|
613
|
+
"unregister", help="Remove a repository from the multi-repo registry"
|
|
614
|
+
)
|
|
615
|
+
unregister_cmd.add_argument("path_or_alias", help="Repository path or alias to remove")
|
|
616
|
+
|
|
617
|
+
# repos
|
|
618
|
+
sub.add_parser("repos", help="List registered repositories")
|
|
619
|
+
|
|
620
|
+
# eval
|
|
621
|
+
eval_cmd = sub.add_parser("eval", help="Run evaluation benchmarks")
|
|
622
|
+
eval_cmd.add_argument(
|
|
623
|
+
"--benchmark",
|
|
624
|
+
default=None,
|
|
625
|
+
help="Comma-separated benchmarks to run (token_efficiency, impact_accuracy, "
|
|
626
|
+
"agent_baseline, flow_completeness, search_quality, build_performance, "
|
|
627
|
+
"multi_hop_retrieval)",
|
|
628
|
+
)
|
|
629
|
+
eval_cmd.add_argument("--repo", default=None, help="Comma-separated repo config names")
|
|
630
|
+
eval_cmd.add_argument("--all", action="store_true", dest="run_all", help="Run all benchmarks")
|
|
631
|
+
eval_cmd.add_argument("--report", action="store_true", help="Generate report from results")
|
|
632
|
+
eval_cmd.add_argument("--output-dir", default=None, help="Output directory for results")
|
|
633
|
+
|
|
634
|
+
# detect-changes
|
|
635
|
+
detect_cmd = sub.add_parser(
|
|
636
|
+
"detect-changes",
|
|
637
|
+
help="Analyze change impact against the existing graph (read-only). "
|
|
638
|
+
"Does NOT re-parse files — for that, use 'update --brief'.",
|
|
639
|
+
)
|
|
640
|
+
detect_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)")
|
|
641
|
+
detect_cmd.add_argument(
|
|
642
|
+
"--brief",
|
|
643
|
+
action="store_true",
|
|
644
|
+
help="Show the risk summary + Token Savings panel instead of the "
|
|
645
|
+
"full JSON. Read-only against the existing graph.",
|
|
646
|
+
)
|
|
647
|
+
detect_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
648
|
+
detect_cmd.add_argument(
|
|
649
|
+
"--verify",
|
|
650
|
+
action="store_true",
|
|
651
|
+
help="Calibrate the estimated savings against tiktoken's "
|
|
652
|
+
"cl100k_base tokenizer (the GPT-4 family tokenizer). Adds a "
|
|
653
|
+
"second row to the panel with the real token counts. Requires "
|
|
654
|
+
"`pip install tiktoken`.",
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
# serve / mcp
|
|
658
|
+
serve_cmd = sub.add_parser(
|
|
659
|
+
"serve",
|
|
660
|
+
help="Start MCP server (stdio by default, or HTTP on localhost with --http)",
|
|
661
|
+
)
|
|
662
|
+
serve_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
663
|
+
serve_cmd.add_argument(
|
|
664
|
+
"--auto-watch",
|
|
665
|
+
action="store_true",
|
|
666
|
+
help="Start filesystem watch in a daemon thread while MCP server runs",
|
|
667
|
+
)
|
|
668
|
+
serve_cmd.add_argument(
|
|
669
|
+
"--tools", default=None,
|
|
670
|
+
help=(
|
|
671
|
+
"Comma-separated list of tool names to expose "
|
|
672
|
+
"(e.g. query_graph_tool,semantic_search_nodes_tool). "
|
|
673
|
+
"Unlisted tools are removed. Falls back to CRG_TOOLS env var. "
|
|
674
|
+
"When unset, all tools are available."
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
serve_cmd.add_argument(
|
|
678
|
+
"--http",
|
|
679
|
+
action="store_true",
|
|
680
|
+
help="Listen for MCP over Streamable HTTP on localhost (default port 5555)",
|
|
681
|
+
)
|
|
682
|
+
serve_cmd.add_argument(
|
|
683
|
+
"--host",
|
|
684
|
+
default=None,
|
|
685
|
+
metavar="ADDR",
|
|
686
|
+
help="Bind address for --http (default: 127.0.0.1)",
|
|
687
|
+
)
|
|
688
|
+
serve_cmd.add_argument(
|
|
689
|
+
"--port",
|
|
690
|
+
type=int,
|
|
691
|
+
default=None,
|
|
692
|
+
metavar="PORT",
|
|
693
|
+
help="Port for --http (default: 5555)",
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
mcp_cmd = sub.add_parser("mcp", help="Alias for serve")
|
|
697
|
+
mcp_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)")
|
|
698
|
+
mcp_cmd.add_argument(
|
|
699
|
+
"--auto-watch",
|
|
700
|
+
action="store_true",
|
|
701
|
+
help="Start filesystem watch in a daemon thread while MCP server runs",
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# daemon
|
|
705
|
+
daemon_cmd = sub.add_parser(
|
|
706
|
+
"daemon",
|
|
707
|
+
help="Multi-repo watch daemon (start/stop/status/add/remove)",
|
|
708
|
+
)
|
|
709
|
+
daemon_sub = daemon_cmd.add_subparsers(dest="daemon_command")
|
|
710
|
+
|
|
711
|
+
daemon_start = daemon_sub.add_parser(
|
|
712
|
+
"start",
|
|
713
|
+
help="Start the watch daemon",
|
|
714
|
+
)
|
|
715
|
+
daemon_start.add_argument(
|
|
716
|
+
"--foreground",
|
|
717
|
+
action="store_true",
|
|
718
|
+
help="Run in foreground instead of daemonizing",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
daemon_sub.add_parser(
|
|
722
|
+
"stop",
|
|
723
|
+
help="Stop the watch daemon",
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
daemon_restart = daemon_sub.add_parser(
|
|
727
|
+
"restart",
|
|
728
|
+
help="Restart the watch daemon",
|
|
729
|
+
)
|
|
730
|
+
daemon_restart.add_argument(
|
|
731
|
+
"--foreground",
|
|
732
|
+
action="store_true",
|
|
733
|
+
help="Run in foreground instead of daemonizing",
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
daemon_sub.add_parser("status", help="Show daemon and watcher status")
|
|
737
|
+
|
|
738
|
+
daemon_logs = daemon_sub.add_parser(
|
|
739
|
+
"logs",
|
|
740
|
+
help="View daemon or watcher logs",
|
|
741
|
+
)
|
|
742
|
+
daemon_logs.add_argument(
|
|
743
|
+
"--repo",
|
|
744
|
+
default=None,
|
|
745
|
+
help="Show logs for a specific repo alias",
|
|
746
|
+
)
|
|
747
|
+
daemon_logs.add_argument(
|
|
748
|
+
"--follow",
|
|
749
|
+
action="store_true",
|
|
750
|
+
help="Follow log output (tail -f)",
|
|
751
|
+
)
|
|
752
|
+
daemon_logs.add_argument(
|
|
753
|
+
"--lines",
|
|
754
|
+
type=int,
|
|
755
|
+
default=50,
|
|
756
|
+
help="Number of lines to show (default: 50)",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
daemon_add = daemon_sub.add_parser(
|
|
760
|
+
"add",
|
|
761
|
+
help="Add a repo to the watch config",
|
|
762
|
+
)
|
|
763
|
+
daemon_add.add_argument("path", help="Path to the repository")
|
|
764
|
+
daemon_add.add_argument(
|
|
765
|
+
"--alias",
|
|
766
|
+
default=None,
|
|
767
|
+
help="Short alias for the repo",
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
daemon_remove = daemon_sub.add_parser(
|
|
771
|
+
"remove",
|
|
772
|
+
help="Remove a repo from the watch config",
|
|
773
|
+
)
|
|
774
|
+
daemon_remove.add_argument(
|
|
775
|
+
"path_or_alias",
|
|
776
|
+
help="Repository path or alias to remove",
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
args = ap.parse_args()
|
|
780
|
+
|
|
781
|
+
if args.version:
|
|
782
|
+
print(f"code-review-graph {_get_version()}")
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
if not args.command:
|
|
786
|
+
_print_banner()
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
if args.command in ("serve", "mcp"):
|
|
790
|
+
from .main import main as serve_main
|
|
791
|
+
|
|
792
|
+
auto_watch = getattr(args, "auto_watch", False)
|
|
793
|
+
if args.command == "serve":
|
|
794
|
+
if args.port is not None and not args.http:
|
|
795
|
+
serve_cmd.error("--port requires --http")
|
|
796
|
+
if args.host is not None and not args.http:
|
|
797
|
+
serve_cmd.error("--host requires --http")
|
|
798
|
+
if args.http:
|
|
799
|
+
host = args.host if args.host is not None else "127.0.0.1"
|
|
800
|
+
port = args.port if args.port is not None else 5555
|
|
801
|
+
serve_main(
|
|
802
|
+
repo_root=args.repo,
|
|
803
|
+
auto_watch=auto_watch,
|
|
804
|
+
transport="streamable-http",
|
|
805
|
+
host=host,
|
|
806
|
+
port=port,
|
|
807
|
+
tools=args.tools,
|
|
808
|
+
)
|
|
809
|
+
else:
|
|
810
|
+
serve_main(repo_root=args.repo, auto_watch=auto_watch, tools=args.tools)
|
|
811
|
+
else:
|
|
812
|
+
serve_main(repo_root=args.repo, auto_watch=auto_watch)
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
if args.command == "daemon":
|
|
816
|
+
if not args.daemon_command:
|
|
817
|
+
daemon_cmd.print_help()
|
|
818
|
+
return
|
|
819
|
+
from .daemon_cli import (
|
|
820
|
+
_handle_add,
|
|
821
|
+
_handle_logs,
|
|
822
|
+
_handle_remove,
|
|
823
|
+
_handle_restart,
|
|
824
|
+
_handle_start,
|
|
825
|
+
_handle_status,
|
|
826
|
+
_handle_stop,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
handlers = {
|
|
830
|
+
"start": _handle_start,
|
|
831
|
+
"stop": _handle_stop,
|
|
832
|
+
"restart": _handle_restart,
|
|
833
|
+
"status": _handle_status,
|
|
834
|
+
"logs": _handle_logs,
|
|
835
|
+
"add": _handle_add,
|
|
836
|
+
"remove": _handle_remove,
|
|
837
|
+
}
|
|
838
|
+
handler = handlers.get(args.daemon_command)
|
|
839
|
+
if handler:
|
|
840
|
+
handler(args)
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
if args.command == "eval":
|
|
844
|
+
from .eval.reporter import generate_full_report, generate_readme_tables
|
|
845
|
+
from .eval.runner import run_eval
|
|
846
|
+
|
|
847
|
+
if getattr(args, "report", False):
|
|
848
|
+
output_dir = Path(getattr(args, "output_dir", None) or "evaluate/results")
|
|
849
|
+
report = generate_full_report(output_dir)
|
|
850
|
+
report_path = Path("evaluate/reports/summary.md")
|
|
851
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
852
|
+
report_path.write_text(report, encoding="utf-8")
|
|
853
|
+
print(f"Report written to {report_path}")
|
|
854
|
+
|
|
855
|
+
tables = generate_readme_tables(output_dir)
|
|
856
|
+
print("\n--- README Tables (copy-paste) ---\n")
|
|
857
|
+
print(tables)
|
|
858
|
+
else:
|
|
859
|
+
repos = (
|
|
860
|
+
[r.strip() for r in args.repo.split(",")] if getattr(args, "repo", None) else None
|
|
861
|
+
)
|
|
862
|
+
benchmarks = (
|
|
863
|
+
[b.strip() for b in args.benchmark.split(",")]
|
|
864
|
+
if getattr(args, "benchmark", None)
|
|
865
|
+
else None
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
if not repos and not benchmarks and not getattr(args, "run_all", False):
|
|
869
|
+
print("Specify --all, --repo, or --benchmark. See --help.")
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
results = run_eval(
|
|
873
|
+
repos=repos,
|
|
874
|
+
benchmarks=benchmarks,
|
|
875
|
+
output_dir=getattr(args, "output_dir", None),
|
|
876
|
+
)
|
|
877
|
+
print(f"\nCompleted {len(results)} benchmark(s).")
|
|
878
|
+
print("Run 'code-review-graph eval --report' to generate tables.")
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
if args.command in ("init", "install"):
|
|
882
|
+
_handle_init(args)
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
if args.command in ("register", "unregister", "repos"):
|
|
886
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
887
|
+
from .registry import Registry
|
|
888
|
+
|
|
889
|
+
registry = Registry()
|
|
890
|
+
if args.command == "register":
|
|
891
|
+
try:
|
|
892
|
+
entry = registry.register(args.path, alias=args.alias)
|
|
893
|
+
alias_info = f" (alias: {entry['alias']})" if entry.get("alias") else ""
|
|
894
|
+
print(f"Registered: {entry['path']}{alias_info}")
|
|
895
|
+
except ValueError as exc:
|
|
896
|
+
logging.error(str(exc))
|
|
897
|
+
sys.exit(1)
|
|
898
|
+
elif args.command == "unregister":
|
|
899
|
+
if registry.unregister(args.path_or_alias):
|
|
900
|
+
print(f"Unregistered: {args.path_or_alias}")
|
|
901
|
+
else:
|
|
902
|
+
print(f"Not found: {args.path_or_alias}")
|
|
903
|
+
sys.exit(1)
|
|
904
|
+
elif args.command == "repos":
|
|
905
|
+
repos = registry.list_repos()
|
|
906
|
+
if not repos:
|
|
907
|
+
print("No repositories registered.")
|
|
908
|
+
print("Use: code-review-graph register <path> [--alias name]")
|
|
909
|
+
else:
|
|
910
|
+
for entry in repos:
|
|
911
|
+
alias = entry.get("alias", "")
|
|
912
|
+
alias_str = f" ({alias})" if alias else ""
|
|
913
|
+
print(f" {entry['path']}{alias_str}")
|
|
914
|
+
return
|
|
915
|
+
|
|
916
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
917
|
+
|
|
918
|
+
from .graph import GraphStore
|
|
919
|
+
from .incremental import (
|
|
920
|
+
find_project_root,
|
|
921
|
+
find_repo_root,
|
|
922
|
+
get_db_path,
|
|
923
|
+
watch,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
if args.command == "postprocess":
|
|
927
|
+
repo_root = Path(args.repo) if args.repo else find_project_root()
|
|
928
|
+
_handle_data_dir_option(args, repo_root)
|
|
929
|
+
db_path = get_db_path(repo_root)
|
|
930
|
+
store = GraphStore(db_path)
|
|
931
|
+
try:
|
|
932
|
+
from .tools.build import run_postprocess
|
|
933
|
+
|
|
934
|
+
result = run_postprocess(
|
|
935
|
+
flows=not getattr(args, "no_flows", False),
|
|
936
|
+
communities=not getattr(args, "no_communities", False),
|
|
937
|
+
fts=not getattr(args, "no_fts", False),
|
|
938
|
+
repo_root=str(repo_root),
|
|
939
|
+
)
|
|
940
|
+
parts = []
|
|
941
|
+
if result.get("flows_detected"):
|
|
942
|
+
parts.append(f"{result['flows_detected']} flows")
|
|
943
|
+
if result.get("communities_detected"):
|
|
944
|
+
parts.append(f"{result['communities_detected']} communities")
|
|
945
|
+
if result.get("fts_indexed"):
|
|
946
|
+
parts.append(f"{result['fts_indexed']} FTS entries")
|
|
947
|
+
print(f"Post-processing: {', '.join(parts) or 'done'}")
|
|
948
|
+
finally:
|
|
949
|
+
store.close()
|
|
950
|
+
return
|
|
951
|
+
|
|
952
|
+
if args.command == "embed":
|
|
953
|
+
repo_root = Path(args.repo) if args.repo else find_project_root()
|
|
954
|
+
_handle_data_dir_option(args, repo_root)
|
|
955
|
+
from .tools.docs import embed_graph
|
|
956
|
+
|
|
957
|
+
result = embed_graph(
|
|
958
|
+
repo_root=str(repo_root),
|
|
959
|
+
model=args.model,
|
|
960
|
+
provider=args.provider,
|
|
961
|
+
)
|
|
962
|
+
if result.get("status") == "error":
|
|
963
|
+
logging.error(result.get("error", "embed_graph failed"))
|
|
964
|
+
sys.exit(1)
|
|
965
|
+
print(result.get("summary", "Embedding done."))
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
if args.command in ("update", "detect-changes"):
|
|
969
|
+
# update and detect-changes require git for diffing
|
|
970
|
+
repo_root = Path(args.repo) if args.repo else find_repo_root()
|
|
971
|
+
if not repo_root:
|
|
972
|
+
logging.error(
|
|
973
|
+
"Not in a git repository. '%s' requires git for diffing.",
|
|
974
|
+
args.command,
|
|
975
|
+
)
|
|
976
|
+
logging.error("Use 'build' for a full parse, or run 'git init' first.")
|
|
977
|
+
sys.exit(1)
|
|
978
|
+
else:
|
|
979
|
+
repo_root = Path(args.repo) if args.repo else find_project_root()
|
|
980
|
+
|
|
981
|
+
# Handle --data-dir for commands that support it
|
|
982
|
+
_data_dir_cmds = ("build", "update", "detect-changes", "status", "watch", "visualize", "wiki")
|
|
983
|
+
if args.command in _data_dir_cmds:
|
|
984
|
+
_handle_data_dir_option(args, repo_root)
|
|
985
|
+
|
|
986
|
+
db_path = get_db_path(repo_root)
|
|
987
|
+
store = GraphStore(db_path)
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
if args.command == "build":
|
|
991
|
+
pp = (
|
|
992
|
+
"none"
|
|
993
|
+
if getattr(args, "skip_postprocess", False)
|
|
994
|
+
else ("minimal" if getattr(args, "skip_flows", False) else "full")
|
|
995
|
+
)
|
|
996
|
+
from .tools.build import build_or_update_graph
|
|
997
|
+
|
|
998
|
+
result = build_or_update_graph(
|
|
999
|
+
full_rebuild=True,
|
|
1000
|
+
repo_root=str(repo_root),
|
|
1001
|
+
postprocess=pp,
|
|
1002
|
+
)
|
|
1003
|
+
parsed = result.get("files_parsed", 0)
|
|
1004
|
+
nodes = result.get("total_nodes", 0)
|
|
1005
|
+
edges = result.get("total_edges", 0)
|
|
1006
|
+
print(f"Full build: {parsed} files, {nodes} nodes, {edges} edges (postprocess={pp})")
|
|
1007
|
+
_print_disambiguated(result)
|
|
1008
|
+
if result.get("errors"):
|
|
1009
|
+
print(f"Errors: {len(result['errors'])}")
|
|
1010
|
+
|
|
1011
|
+
elif args.command == "update":
|
|
1012
|
+
pp = (
|
|
1013
|
+
"none"
|
|
1014
|
+
if getattr(args, "skip_postprocess", False)
|
|
1015
|
+
else ("minimal" if getattr(args, "skip_flows", False) else "full")
|
|
1016
|
+
)
|
|
1017
|
+
from .tools.build import build_or_update_graph
|
|
1018
|
+
|
|
1019
|
+
result = build_or_update_graph(
|
|
1020
|
+
full_rebuild=False,
|
|
1021
|
+
repo_root=str(repo_root),
|
|
1022
|
+
base=args.base,
|
|
1023
|
+
postprocess=pp,
|
|
1024
|
+
)
|
|
1025
|
+
updated = result.get("files_updated", 0)
|
|
1026
|
+
nodes = result.get("total_nodes", 0)
|
|
1027
|
+
edges = result.get("total_edges", 0)
|
|
1028
|
+
print(
|
|
1029
|
+
f"Incremental: {updated} files updated, "
|
|
1030
|
+
f"{nodes} nodes, {edges} edges"
|
|
1031
|
+
f" (postprocess={pp})"
|
|
1032
|
+
)
|
|
1033
|
+
_print_disambiguated(result)
|
|
1034
|
+
|
|
1035
|
+
# --brief: append a one-line change-impact summary with the same
|
|
1036
|
+
# estimated context-savings approximation that detect-changes uses.
|
|
1037
|
+
# Same baseline (changed files vs analysis response), so the two
|
|
1038
|
+
# commands are directly comparable.
|
|
1039
|
+
if getattr(args, "brief", False):
|
|
1040
|
+
from .changes import analyze_changes
|
|
1041
|
+
from .context_savings import (
|
|
1042
|
+
attach_context_savings,
|
|
1043
|
+
estimate_file_tokens,
|
|
1044
|
+
format_context_savings_panel,
|
|
1045
|
+
)
|
|
1046
|
+
from .incremental import (
|
|
1047
|
+
get_changed_files,
|
|
1048
|
+
get_staged_and_unstaged,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
changed = get_changed_files(repo_root, args.base)
|
|
1052
|
+
if not changed:
|
|
1053
|
+
changed = get_staged_and_unstaged(repo_root)
|
|
1054
|
+
if changed:
|
|
1055
|
+
impact = analyze_changes(
|
|
1056
|
+
store,
|
|
1057
|
+
changed,
|
|
1058
|
+
repo_root=str(repo_root),
|
|
1059
|
+
base=args.base,
|
|
1060
|
+
)
|
|
1061
|
+
original_tokens = estimate_file_tokens(repo_root, changed)
|
|
1062
|
+
attach_context_savings(
|
|
1063
|
+
impact,
|
|
1064
|
+
original_tokens=original_tokens,
|
|
1065
|
+
)
|
|
1066
|
+
summary = impact.get("summary", "")
|
|
1067
|
+
if summary:
|
|
1068
|
+
print(summary)
|
|
1069
|
+
verified = None
|
|
1070
|
+
if getattr(args, "verify", False):
|
|
1071
|
+
from .context_savings import verify_with_tiktoken
|
|
1072
|
+
verified = verify_with_tiktoken(
|
|
1073
|
+
repo_root, changed, impact,
|
|
1074
|
+
)
|
|
1075
|
+
if verified is None:
|
|
1076
|
+
print(
|
|
1077
|
+
"Note: --verify requires tiktoken. "
|
|
1078
|
+
"Install with `pip install tiktoken`.",
|
|
1079
|
+
)
|
|
1080
|
+
panel = format_context_savings_panel(
|
|
1081
|
+
impact.get("context_savings"),
|
|
1082
|
+
original_tokens=original_tokens,
|
|
1083
|
+
response=impact,
|
|
1084
|
+
verified=verified,
|
|
1085
|
+
)
|
|
1086
|
+
if panel:
|
|
1087
|
+
print(panel)
|
|
1088
|
+
|
|
1089
|
+
elif args.command == "status":
|
|
1090
|
+
stats = store.get_stats()
|
|
1091
|
+
print(f"Nodes: {stats.total_nodes}")
|
|
1092
|
+
print(f"Edges: {stats.total_edges}")
|
|
1093
|
+
print(f"Files: {stats.files_count}")
|
|
1094
|
+
print(f"Languages: {', '.join(stats.languages)}")
|
|
1095
|
+
print(f"Last updated: {stats.last_updated or 'never'}")
|
|
1096
|
+
# Show branch info and warn if stale
|
|
1097
|
+
stored_branch = store.get_metadata("git_branch")
|
|
1098
|
+
stored_sha = store.get_metadata("git_head_sha")
|
|
1099
|
+
if stored_branch:
|
|
1100
|
+
print(f"Built on branch: {stored_branch}")
|
|
1101
|
+
if stored_sha:
|
|
1102
|
+
print(f"Built at commit: {stored_sha[:12]}")
|
|
1103
|
+
from .incremental import _git_branch_info, detect_vcs
|
|
1104
|
+
vcs = detect_vcs(repo_root)
|
|
1105
|
+
if vcs == "git":
|
|
1106
|
+
current_branch, current_sha = _git_branch_info(repo_root)
|
|
1107
|
+
if stored_branch and current_branch and stored_branch != current_branch:
|
|
1108
|
+
print(
|
|
1109
|
+
f"WARNING: Graph was built on '{stored_branch}' "
|
|
1110
|
+
f"but you are now on '{current_branch}'. "
|
|
1111
|
+
f"Run 'code-review-graph build' to rebuild."
|
|
1112
|
+
)
|
|
1113
|
+
elif vcs == "svn":
|
|
1114
|
+
stored_rev = store.get_metadata("svn_revision")
|
|
1115
|
+
stored_svn_branch = store.get_metadata("svn_branch")
|
|
1116
|
+
if stored_svn_branch:
|
|
1117
|
+
print(f"SVN branch: {stored_svn_branch}")
|
|
1118
|
+
if stored_rev:
|
|
1119
|
+
print(f"SVN revision at build: {stored_rev}")
|
|
1120
|
+
|
|
1121
|
+
elif args.command == "watch":
|
|
1122
|
+
from .postprocessing import run_post_processing
|
|
1123
|
+
|
|
1124
|
+
try:
|
|
1125
|
+
watch(repo_root, store, on_files_updated=run_post_processing)
|
|
1126
|
+
except RuntimeError as exc:
|
|
1127
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1128
|
+
sys.exit(1)
|
|
1129
|
+
|
|
1130
|
+
elif args.command == "visualize":
|
|
1131
|
+
from .incremental import get_data_dir
|
|
1132
|
+
|
|
1133
|
+
data_dir = get_data_dir(repo_root)
|
|
1134
|
+
fmt = getattr(args, "format", "html") or "html"
|
|
1135
|
+
|
|
1136
|
+
if fmt == "graphml":
|
|
1137
|
+
from .exports import export_graphml
|
|
1138
|
+
|
|
1139
|
+
out = data_dir / "graph.graphml"
|
|
1140
|
+
export_graphml(store, out)
|
|
1141
|
+
print(f"GraphML exported: {out}")
|
|
1142
|
+
elif fmt == "cypher":
|
|
1143
|
+
from .exports import export_neo4j_cypher
|
|
1144
|
+
|
|
1145
|
+
out = data_dir / "graph.cypher"
|
|
1146
|
+
export_neo4j_cypher(store, out)
|
|
1147
|
+
print(f"Neo4j Cypher exported: {out}")
|
|
1148
|
+
elif fmt == "obsidian":
|
|
1149
|
+
from .exports import export_obsidian_vault
|
|
1150
|
+
|
|
1151
|
+
out = data_dir / "obsidian"
|
|
1152
|
+
export_obsidian_vault(store, out)
|
|
1153
|
+
print(f"Obsidian vault exported: {out}")
|
|
1154
|
+
elif fmt == "svg":
|
|
1155
|
+
from .exports import export_svg
|
|
1156
|
+
|
|
1157
|
+
out = data_dir / "graph.svg"
|
|
1158
|
+
export_svg(store, out)
|
|
1159
|
+
print(f"SVG exported: {out}")
|
|
1160
|
+
else:
|
|
1161
|
+
from .visualization import generate_html
|
|
1162
|
+
|
|
1163
|
+
html_path = data_dir / "graph.html"
|
|
1164
|
+
vis_mode = getattr(args, "mode", "auto") or "auto"
|
|
1165
|
+
generate_html(store, html_path, mode=vis_mode)
|
|
1166
|
+
print(f"Visualization ({vis_mode}): {html_path}")
|
|
1167
|
+
if getattr(args, "serve", False):
|
|
1168
|
+
import functools
|
|
1169
|
+
import http.server
|
|
1170
|
+
|
|
1171
|
+
serve_dir = html_path.parent
|
|
1172
|
+
port = 8765
|
|
1173
|
+
http_handler = functools.partial(
|
|
1174
|
+
http.server.SimpleHTTPRequestHandler,
|
|
1175
|
+
directory=str(serve_dir),
|
|
1176
|
+
)
|
|
1177
|
+
print(f"Serving at http://localhost:{port}/graph.html")
|
|
1178
|
+
print("Press Ctrl+C to stop.")
|
|
1179
|
+
with http.server.HTTPServer(("localhost", port), http_handler) as httpd:
|
|
1180
|
+
try:
|
|
1181
|
+
httpd.serve_forever()
|
|
1182
|
+
except KeyboardInterrupt:
|
|
1183
|
+
print("\nServer stopped.")
|
|
1184
|
+
else:
|
|
1185
|
+
print("Open in browser to explore.")
|
|
1186
|
+
|
|
1187
|
+
elif args.command == "wiki":
|
|
1188
|
+
from .incremental import get_data_dir
|
|
1189
|
+
from .wiki import generate_wiki
|
|
1190
|
+
|
|
1191
|
+
wiki_dir = get_data_dir(repo_root) / "wiki"
|
|
1192
|
+
result = generate_wiki(store, wiki_dir, force=args.force)
|
|
1193
|
+
total = result["pages_generated"] + result["pages_updated"] + result["pages_unchanged"]
|
|
1194
|
+
print(
|
|
1195
|
+
f"Wiki: {result['pages_generated']} new, "
|
|
1196
|
+
f"{result['pages_updated']} updated, "
|
|
1197
|
+
f"{result['pages_unchanged']} unchanged "
|
|
1198
|
+
f"({total} total pages)"
|
|
1199
|
+
)
|
|
1200
|
+
print(f"Output: {wiki_dir}")
|
|
1201
|
+
|
|
1202
|
+
elif args.command == "detect-changes":
|
|
1203
|
+
from .changes import analyze_changes
|
|
1204
|
+
from .context_savings import (
|
|
1205
|
+
attach_context_savings,
|
|
1206
|
+
estimate_file_tokens,
|
|
1207
|
+
)
|
|
1208
|
+
from .incremental import get_changed_files, get_staged_and_unstaged
|
|
1209
|
+
|
|
1210
|
+
base = args.base
|
|
1211
|
+
changed = get_changed_files(repo_root, base)
|
|
1212
|
+
if not changed:
|
|
1213
|
+
changed = get_staged_and_unstaged(repo_root)
|
|
1214
|
+
|
|
1215
|
+
if not changed:
|
|
1216
|
+
print("No changes detected.")
|
|
1217
|
+
else:
|
|
1218
|
+
result = analyze_changes(
|
|
1219
|
+
store,
|
|
1220
|
+
changed,
|
|
1221
|
+
repo_root=str(repo_root),
|
|
1222
|
+
base=base,
|
|
1223
|
+
)
|
|
1224
|
+
original_tokens = estimate_file_tokens(repo_root, changed)
|
|
1225
|
+
attach_context_savings(
|
|
1226
|
+
result,
|
|
1227
|
+
original_tokens=original_tokens,
|
|
1228
|
+
)
|
|
1229
|
+
if args.brief:
|
|
1230
|
+
from .context_savings import (
|
|
1231
|
+
format_context_savings_panel,
|
|
1232
|
+
verify_with_tiktoken,
|
|
1233
|
+
)
|
|
1234
|
+
print(result.get("summary", "No summary available."))
|
|
1235
|
+
verified = None
|
|
1236
|
+
if getattr(args, "verify", False):
|
|
1237
|
+
verified = verify_with_tiktoken(repo_root, changed, result)
|
|
1238
|
+
if verified is None:
|
|
1239
|
+
print(
|
|
1240
|
+
"Note: --verify requires tiktoken. "
|
|
1241
|
+
"Install with `pip install tiktoken`.",
|
|
1242
|
+
)
|
|
1243
|
+
panel = format_context_savings_panel(
|
|
1244
|
+
result.get("context_savings"),
|
|
1245
|
+
original_tokens=original_tokens,
|
|
1246
|
+
response=result,
|
|
1247
|
+
verified=verified,
|
|
1248
|
+
)
|
|
1249
|
+
if panel:
|
|
1250
|
+
print(panel)
|
|
1251
|
+
else:
|
|
1252
|
+
print(json.dumps(result, indent=2, default=str))
|
|
1253
|
+
|
|
1254
|
+
finally:
|
|
1255
|
+
store.close()
|