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.
Files changed (74) hide show
  1. code_review_graph/__init__.py +20 -0
  2. code_review_graph/__main__.py +4 -0
  3. code_review_graph/analysis.py +410 -0
  4. code_review_graph/changes.py +409 -0
  5. code_review_graph/cli.py +1255 -0
  6. code_review_graph/communities.py +874 -0
  7. code_review_graph/constants.py +23 -0
  8. code_review_graph/context_savings.py +317 -0
  9. code_review_graph/custom_languages.py +322 -0
  10. code_review_graph/daemon.py +1009 -0
  11. code_review_graph/daemon_cli.py +320 -0
  12. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
  13. code_review_graph/embeddings.py +1006 -0
  14. code_review_graph/enrich.py +303 -0
  15. code_review_graph/eval/__init__.py +33 -0
  16. code_review_graph/eval/benchmarks/__init__.py +1 -0
  17. code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
  18. code_review_graph/eval/benchmarks/build_performance.py +60 -0
  19. code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
  20. code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
  21. code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
  22. code_review_graph/eval/benchmarks/search_quality.py +59 -0
  23. code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
  24. code_review_graph/eval/configs/code-review-graph.yaml +50 -0
  25. code_review_graph/eval/configs/express.yaml +45 -0
  26. code_review_graph/eval/configs/fastapi.yaml +48 -0
  27. code_review_graph/eval/configs/flask.yaml +50 -0
  28. code_review_graph/eval/configs/gin.yaml +51 -0
  29. code_review_graph/eval/configs/httpx.yaml +48 -0
  30. code_review_graph/eval/reporter.py +301 -0
  31. code_review_graph/eval/runner.py +211 -0
  32. code_review_graph/eval/scorer.py +85 -0
  33. code_review_graph/eval/token_benchmark.py +182 -0
  34. code_review_graph/exports.py +409 -0
  35. code_review_graph/flows.py +698 -0
  36. code_review_graph/graph.py +1427 -0
  37. code_review_graph/graph_diff.py +122 -0
  38. code_review_graph/hints.py +384 -0
  39. code_review_graph/incremental.py +1245 -0
  40. code_review_graph/jedi_resolver.py +303 -0
  41. code_review_graph/main.py +1079 -0
  42. code_review_graph/memory.py +142 -0
  43. code_review_graph/migrations.py +284 -0
  44. code_review_graph/parser.py +6957 -0
  45. code_review_graph/postprocessing.py +134 -0
  46. code_review_graph/prompts.py +159 -0
  47. code_review_graph/refactor.py +852 -0
  48. code_review_graph/registry.py +319 -0
  49. code_review_graph/rescript_resolver.py +206 -0
  50. code_review_graph/search.py +447 -0
  51. code_review_graph/skills.py +1481 -0
  52. code_review_graph/spring_resolver.py +200 -0
  53. code_review_graph/temporal_resolver.py +199 -0
  54. code_review_graph/token_benchmark.py +125 -0
  55. code_review_graph/tools/__init__.py +156 -0
  56. code_review_graph/tools/_common.py +176 -0
  57. code_review_graph/tools/analysis_tools.py +184 -0
  58. code_review_graph/tools/build.py +541 -0
  59. code_review_graph/tools/community_tools.py +246 -0
  60. code_review_graph/tools/context.py +152 -0
  61. code_review_graph/tools/docs.py +274 -0
  62. code_review_graph/tools/flows_tools.py +176 -0
  63. code_review_graph/tools/query.py +692 -0
  64. code_review_graph/tools/refactor_tools.py +168 -0
  65. code_review_graph/tools/registry_tools.py +125 -0
  66. code_review_graph/tools/review.py +477 -0
  67. code_review_graph/tsconfig_resolver.py +257 -0
  68. code_review_graph/visualization.py +2184 -0
  69. code_review_graph/wiki.py +305 -0
  70. code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
  71. code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
  72. code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
  73. code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
  74. code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
@@ -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()