polycodegraph 0.1.0__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 (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
codegraph/cli.py ADDED
@@ -0,0 +1,1506 @@
1
+ """codegraph CLI entry point."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ import networkx as nx
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from codegraph import __version__
14
+
15
+ if TYPE_CHECKING:
16
+ from codegraph.review.differ import EdgeChange, GraphDiff, NodeChange
17
+ from codegraph.review.rules import Finding
18
+
19
+ app = typer.Typer(
20
+ name="codegraph",
21
+ help="Build, analyze, review, and visualize code graphs across languages.",
22
+ no_args_is_help=True,
23
+ add_completion=False,
24
+ invoke_without_command=True,
25
+ )
26
+ query_app = typer.Typer(help="Query small, focused subgraphs.", no_args_is_help=True)
27
+ baseline_app = typer.Typer(help="Manage baseline snapshots.", no_args_is_help=True)
28
+ hook_app = typer.Typer(help="Manage git hooks.", no_args_is_help=True)
29
+ mcp_app = typer.Typer(help="Run codegraph as an MCP server.", no_args_is_help=True)
30
+ dataflow_app = typer.Typer(
31
+ help="Trace data flows across frontend / backend / db layers.",
32
+ no_args_is_help=True,
33
+ )
34
+ workspace_app = typer.Typer(
35
+ help="Manage multi-repo workspaces (cross-repo state, diff, blast radius).",
36
+ no_args_is_help=True,
37
+ )
38
+ app.add_typer(query_app, name="query")
39
+ app.add_typer(baseline_app, name="baseline")
40
+ app.add_typer(hook_app, name="hook")
41
+ app.add_typer(mcp_app, name="mcp")
42
+ app.add_typer(dataflow_app, name="dataflow")
43
+ app.add_typer(workspace_app, name="workspace")
44
+
45
+ console = Console()
46
+
47
+ _DATA_DIR_STATE: dict[str, Path | None] = {"value": None}
48
+
49
+
50
+ def _get_data_dir(repo_root: Path) -> Path:
51
+ val = _DATA_DIR_STATE.get("value")
52
+ if val is not None:
53
+ return val
54
+ from codegraph.config import default_data_dir
55
+ return default_data_dir(repo_root)
56
+
57
+
58
+ @app.callback()
59
+ def _root(
60
+ ctx: typer.Context,
61
+ version: bool = typer.Option(False, "--version", help="Show version and exit."),
62
+ data_dir: str | None = typer.Option(
63
+ None, "--data-dir", help="Override .codegraph/ data directory."
64
+ ),
65
+ ) -> None:
66
+ if data_dir:
67
+ _DATA_DIR_STATE["value"] = Path(data_dir)
68
+ else:
69
+ _DATA_DIR_STATE["value"] = None
70
+ if version:
71
+ console.print(f"codegraph {__version__}")
72
+ raise typer.Exit()
73
+ if ctx.invoked_subcommand is None:
74
+ console.print(ctx.get_help())
75
+ raise typer.Exit()
76
+
77
+
78
+ def _detect_languages(repo_root: Path, limit: int = 5000) -> dict[str, int]:
79
+ ext_map: dict[str, int] = {}
80
+ count = 0
81
+ for p in repo_root.rglob("*"):
82
+ if count >= limit:
83
+ break
84
+ if not p.is_file():
85
+ continue
86
+ parts = p.relative_to(repo_root).parts
87
+ if any(
88
+ part.startswith(".")
89
+ or part in ("node_modules", "venv", "__pycache__", "dist", "build")
90
+ for part in parts
91
+ ):
92
+ continue
93
+ ext = p.suffix.lower()
94
+ if ext:
95
+ ext_map[ext] = ext_map.get(ext, 0) + 1
96
+ count += 1
97
+ return ext_map
98
+
99
+
100
+ def _detect_branch(repo_root: Path) -> str:
101
+ try:
102
+ r = subprocess.run(
103
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
104
+ cwd=repo_root, capture_output=True, text=True, timeout=5,
105
+ )
106
+ if r.returncode == 0:
107
+ branch = r.stdout.strip()
108
+ if "/" in branch:
109
+ branch = branch.split("/", 1)[1]
110
+ return branch
111
+ except Exception:
112
+ pass
113
+ try:
114
+ r = subprocess.run(
115
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
116
+ cwd=repo_root, capture_output=True, text=True, timeout=5,
117
+ )
118
+ if r.returncode == 0:
119
+ return r.stdout.strip()
120
+ except Exception:
121
+ pass
122
+ return "main"
123
+
124
+
125
+ def _update_gitignore(repo_root: Path) -> None:
126
+ gi_path = repo_root / ".gitignore"
127
+ entry = ".codegraph/"
128
+ if gi_path.exists():
129
+ content = gi_path.read_text()
130
+ if entry not in content:
131
+ with gi_path.open("a") as f:
132
+ f.write(f"\n{entry}\n")
133
+ else:
134
+ gi_path.write_text(f"{entry}\n")
135
+
136
+
137
+ @app.command()
138
+ def init(
139
+ non_interactive: bool = typer.Option(
140
+ False, "--non-interactive",
141
+ help="Write default config without prompting.",
142
+ ),
143
+ ) -> None:
144
+ """Interactive setup: detect languages, write `.codegraph.yml`."""
145
+ import questionary
146
+
147
+ from codegraph.config import load_config, save_config
148
+
149
+ repo_root = Path.cwd()
150
+ cfg = load_config(repo_root)
151
+
152
+ if non_interactive:
153
+ save_config(repo_root, cfg)
154
+ _update_gitignore(repo_root)
155
+ console.print("[green]✓[/green] Wrote .codegraph.yml with defaults.")
156
+ console.print("Next step: [bold]codegraph build[/bold]")
157
+ return
158
+
159
+ ext_map = _detect_languages(repo_root)
160
+ lang_exts = {
161
+ "python": [".py"],
162
+ "typescript": [".ts", ".tsx"],
163
+ "javascript": [".js", ".jsx", ".mjs", ".cjs"],
164
+ "go": [".go"],
165
+ }
166
+ detected: list[str] = []
167
+ for lang, exts in lang_exts.items():
168
+ if any(ext_map.get(e, 0) > 0 for e in exts):
169
+ detected.append(lang)
170
+
171
+ console.print("\n[bold]Detected languages:[/bold]")
172
+ for lang in detected:
173
+ exts = lang_exts.get(lang, [])
174
+ total = sum(ext_map.get(e, 0) for e in exts)
175
+ console.print(f" {lang}: {total} files")
176
+
177
+ confirmed = questionary.checkbox(
178
+ "Confirm languages to include:",
179
+ choices=list(lang_exts.keys()),
180
+ ).ask() or detected
181
+ cfg.languages = confirmed
182
+
183
+ default_branch = _detect_branch(repo_root)
184
+ branch = questionary.text(
185
+ "Default branch:", default=default_branch
186
+ ).ask() or default_branch
187
+ cfg.default_branch = branch
188
+
189
+ backend = questionary.select(
190
+ "Baseline backend (s3/sql land in Phase 4):",
191
+ choices=["local", "none"],
192
+ default="local",
193
+ ).ask() or "local"
194
+ cfg.baseline = {"backend": backend}
195
+
196
+ extra = questionary.text(
197
+ "Extra ignore patterns (comma/newline separated, optional):",
198
+ default="",
199
+ ).ask() or ""
200
+ cfg.ignore = [
201
+ p.strip() for p in extra.replace("\n", ",").split(",") if p.strip()
202
+ ]
203
+
204
+ install_hook = questionary.confirm(
205
+ "Install git pre-push hook? (Phase 2 implementation)", default=False
206
+ ).ask() or False
207
+ cfg.install_hook = install_hook
208
+
209
+ register_mcp = questionary.confirm(
210
+ "Register MCP server in .mcp.json? (Phase 3 implementation)",
211
+ default=False,
212
+ ).ask() or False
213
+ cfg.register_mcp = register_mcp
214
+
215
+ save_config(repo_root, cfg)
216
+ _update_gitignore(repo_root)
217
+
218
+ console.print("\n[green]✓[/green] Wrote .codegraph.yml")
219
+ console.print("Next step: [bold]codegraph build[/bold]")
220
+
221
+
222
+ @app.command()
223
+ def build(
224
+ incremental: bool = typer.Option(True, help="Incremental build when possible."),
225
+ ) -> None:
226
+ """Parse the repo and (re)build the graph."""
227
+ import time
228
+
229
+ from codegraph.config import load_config
230
+ from codegraph.graph.builder import GraphBuilder
231
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
232
+
233
+ repo_root = Path.cwd()
234
+ cfg = load_config(repo_root)
235
+ data_dir = _get_data_dir(repo_root)
236
+ data_dir.mkdir(parents=True, exist_ok=True)
237
+ db_path = data_dir / "graph.db"
238
+
239
+ console.print(f"[bold]Building graph[/bold] in {db_path}...")
240
+ store = SQLiteGraphStore(db_path)
241
+ builder = GraphBuilder(repo_root, store, ignore=cfg.ignore)
242
+
243
+ t0 = time.monotonic()
244
+ stats = builder.build(incremental=incremental)
245
+ elapsed = time.monotonic() - t0
246
+
247
+ table = Table(title="Build Summary")
248
+ table.add_column("Metric", style="cyan")
249
+ table.add_column("Value", justify="right")
250
+ table.add_row("Files scanned", str(stats.files_scanned))
251
+ table.add_row("Files parsed", str(stats.files_parsed))
252
+ table.add_row("Files skipped (unchanged)", str(stats.files_skipped))
253
+ table.add_row("Nodes added", str(stats.nodes_added))
254
+ table.add_row("Edges added", str(stats.edges_added))
255
+ table.add_row("Errors", str(len(stats.errors)))
256
+ table.add_row("Time", f"{elapsed:.2f}s")
257
+ console.print(table)
258
+
259
+ if stats.errors:
260
+ console.print(f"[yellow]Warnings ({len(stats.errors)}):[/yellow]")
261
+ for e in stats.errors[:10]:
262
+ console.print(f" {e}")
263
+
264
+ store.close()
265
+
266
+
267
+ @app.command()
268
+ def status() -> None:
269
+ """Show graph freshness, last build, and drift indicators."""
270
+ import hashlib
271
+
272
+ from codegraph.graph.schema import NodeKind
273
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
274
+
275
+ repo_root = Path.cwd()
276
+ data_dir = _get_data_dir(repo_root)
277
+ db_path = data_dir / "graph.db"
278
+
279
+ if not db_path.exists():
280
+ console.print(
281
+ "[yellow]No graph database found. "
282
+ "Run [bold]codegraph build[/bold] first.[/yellow]"
283
+ )
284
+ raise typer.Exit(1)
285
+
286
+ store = SQLiteGraphStore(db_path)
287
+ n_nodes = store.count_nodes()
288
+ n_edges = store.count_edges()
289
+ last_build = store.get_meta("last_build_time") or "unknown"
290
+ last_sha = store.get_meta("last_git_sha") or "unknown"
291
+
292
+ drift = 0
293
+ for file_node in store.iter_nodes(kind=NodeKind.FILE):
294
+ file_path = repo_root / file_node.file
295
+ if file_path.exists() and file_node.content_hash:
296
+ h = hashlib.sha256()
297
+ with file_path.open("rb") as f:
298
+ for chunk in iter(lambda: f.read(65536), b""):
299
+ h.update(chunk)
300
+ if h.hexdigest() != file_node.content_hash:
301
+ drift += 1
302
+ elif not file_path.exists():
303
+ drift += 1
304
+
305
+ table = Table(title="Graph Status")
306
+ table.add_column("Metric", style="cyan")
307
+ table.add_column("Value", justify="right")
308
+ table.add_row("Nodes", str(n_nodes))
309
+ table.add_row("Edges", str(n_edges))
310
+ table.add_row("Last build", last_build)
311
+ table.add_row("Git SHA", last_sha)
312
+ table.add_row("Drifted files", str(drift))
313
+ console.print(table)
314
+ store.close()
315
+
316
+
317
+ @app.command()
318
+ def viz(
319
+ out: str = typer.Option("mermaid", "--out", help="mermaid|html|svg"),
320
+ scope: str = typer.Option("", "--scope", help="Path or symbol to focus on."),
321
+ limit: int = typer.Option(
322
+ 300, "--limit", help="Max nodes to render (top-N by degree)."
323
+ ),
324
+ output: str | None = typer.Option(
325
+ None, "--output", help="Write to file (required for html/svg)."
326
+ ),
327
+ no_cluster: bool = typer.Option(
328
+ False, "--no-cluster", help="Disable file-based clustering (mermaid)."
329
+ ),
330
+ include_unresolved: bool = typer.Option(
331
+ False,
332
+ "--include-unresolved",
333
+ help="Include unresolved::* phantom nodes (debug only).",
334
+ ),
335
+ include_files: bool = typer.Option(
336
+ False,
337
+ "--include-files",
338
+ help="Include FILE nodes (rendered as bare paths; off by default).",
339
+ ),
340
+ ) -> None:
341
+ """Render a graph visualization (mermaid stdout, html / svg to file)."""
342
+ from codegraph.graph.store_networkx import subgraph_around, to_digraph
343
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
344
+
345
+ repo_root = Path.cwd()
346
+ data_dir = _get_data_dir(repo_root)
347
+ db_path = data_dir / "graph.db"
348
+
349
+ if not db_path.exists():
350
+ console.print(
351
+ "[yellow]No graph found. Run codegraph build first.[/yellow]"
352
+ )
353
+ raise typer.Exit(1)
354
+
355
+ store = SQLiteGraphStore(db_path)
356
+ g = to_digraph(store)
357
+ store.close()
358
+
359
+ drop: list[str] = []
360
+ for nid, attrs in g.nodes(data=True):
361
+ if not include_unresolved and isinstance(nid, str) and nid.startswith(
362
+ "unresolved::"
363
+ ):
364
+ drop.append(nid)
365
+ continue
366
+ if not include_files and str(attrs.get("kind") or "") == "FILE":
367
+ drop.append(nid)
368
+ if drop:
369
+ g = cast("nx.MultiDiGraph", g.copy())
370
+ g.remove_nodes_from(drop)
371
+
372
+ if scope:
373
+ target_id: str | None = None
374
+ for nid, attrs in g.nodes(data=True):
375
+ if (
376
+ attrs.get("name") == scope
377
+ or attrs.get("qualname") == scope
378
+ or attrs.get("file") == scope
379
+ ):
380
+ target_id = nid
381
+ break
382
+ if target_id:
383
+ g = subgraph_around(g, target_id, depth=2)
384
+ else:
385
+ console.print(
386
+ f"[yellow]Symbol '{scope}' not found in graph.[/yellow]"
387
+ )
388
+
389
+ nodes_to_show = list(g.nodes())
390
+ if len(nodes_to_show) > limit:
391
+ degree_sorted = sorted(
392
+ g.degree(), key=lambda x: x[1], reverse=True
393
+ )
394
+ top_ids = {n for n, _ in degree_sorted[:limit]}
395
+ g = cast("nx.MultiDiGraph", g.subgraph(top_ids).copy())
396
+
397
+ if out == "mermaid":
398
+ from codegraph.viz import render_mermaid
399
+ text = render_mermaid(g, cluster_by_file=not no_cluster)
400
+ if output:
401
+ Path(output).write_text(text)
402
+ console.print(f"[green]✓[/green] wrote mermaid to {output}")
403
+ else:
404
+ print(text)
405
+ return
406
+
407
+ if out == "html":
408
+ from codegraph.viz import render_html
409
+ out_path = Path(output) if output else data_dir / "graph.html"
410
+ result_path = render_html(g, out_path)
411
+ console.print(
412
+ f"[green]✓[/green] wrote interactive graph to {result_path} "
413
+ f"({g.number_of_nodes()} nodes, {g.number_of_edges()} edges)"
414
+ )
415
+ console.print(f"[dim]Open with:[/dim] open {result_path}")
416
+ return
417
+
418
+ if out == "svg":
419
+ from codegraph.viz import GraphvizUnavailableError, render_svg
420
+ out_path = Path(output) if output else data_dir / "graph.svg"
421
+ try:
422
+ result_path = render_svg(g, out_path)
423
+ except GraphvizUnavailableError as exc:
424
+ console.print(f"[yellow]SVG unavailable:[/yellow] {exc}")
425
+ raise typer.Exit(1) from exc
426
+ console.print(f"[green]✓[/green] wrote SVG to {result_path}")
427
+ return
428
+
429
+ console.print(f"[red]Unknown --out value:[/red] {out}")
430
+ raise typer.Exit(2)
431
+
432
+
433
+ # ---- analyze + query ----
434
+
435
+
436
+ def _open_graph(repo_root: Path) -> nx.MultiDiGraph | None:
437
+ from codegraph.graph.store_networkx import to_digraph
438
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
439
+
440
+ data_dir = _get_data_dir(repo_root)
441
+ db_path = data_dir / "graph.db"
442
+ if not db_path.exists():
443
+ console.print(
444
+ "[yellow]No graph found. Run [bold]codegraph build[/bold] first.[/yellow]"
445
+ )
446
+ return None
447
+ store = SQLiteGraphStore(db_path)
448
+ try:
449
+ return to_digraph(store)
450
+ finally:
451
+ store.close()
452
+
453
+
454
+ @app.command()
455
+ def analyze(
456
+ fmt: str = typer.Option("markdown", "--format", help="markdown|json"),
457
+ output: str | None = typer.Option(
458
+ None, "--output", help="Write report to file instead of stdout."
459
+ ),
460
+ hotspot_limit: int = typer.Option(20, "--hotspots", help="Top-N hotspots."),
461
+ ) -> None:
462
+ """Whole-project audit: dead code, cycles, untested, hotspots, metrics."""
463
+ from codegraph.analysis.report import (
464
+ report_to_json,
465
+ report_to_markdown,
466
+ run_full_analyze,
467
+ )
468
+
469
+ graph = _open_graph(Path.cwd())
470
+ if graph is None:
471
+ raise typer.Exit(1)
472
+
473
+ report = run_full_analyze(graph, hotspot_limit=hotspot_limit)
474
+ text = (
475
+ report_to_json(report) if fmt == "json" else report_to_markdown(report)
476
+ )
477
+ if output:
478
+ Path(output).write_text(text)
479
+ console.print(f"[green]✓[/green] wrote report to {output}")
480
+ else:
481
+ print(text)
482
+
483
+
484
+ @app.command()
485
+ def explore(
486
+ output: str = typer.Option(
487
+ ".codegraph/explore", "--output", "-o", help="Output directory."
488
+ ),
489
+ top_files: int = typer.Option(
490
+ 25, "--top-files", help="How many file-detail pages to generate."
491
+ ),
492
+ callgraph_limit: int = typer.Option(
493
+ 400,
494
+ "--callgraph-limit",
495
+ help="Cap nodes shown on the callgraph page (degree-ranked).",
496
+ ),
497
+ ) -> None:
498
+ """Build an interactive multi-page dashboard (overview + drill-downs)."""
499
+ from codegraph.viz.explore import render_explore
500
+
501
+ graph = _open_graph(Path.cwd())
502
+ if graph is None:
503
+ raise typer.Exit(1)
504
+
505
+ out_dir = Path(output)
506
+ if not out_dir.is_absolute():
507
+ out_dir = Path.cwd() / out_dir
508
+ result = render_explore(
509
+ graph,
510
+ out_dir,
511
+ top_files=top_files,
512
+ callgraph_limit=callgraph_limit,
513
+ )
514
+ console.print(
515
+ f"[green]✓[/green] dashboard written to {result.out_dir} "
516
+ f"({len(result.pages)} pages)"
517
+ )
518
+ console.print(f"[bold]Open:[/bold] open {result.out_dir / 'index.html'}")
519
+
520
+
521
+ @app.command()
522
+ def serve(
523
+ port: int = typer.Option(8765, "--port", "-p", help="Port to bind."),
524
+ host: str = typer.Option("127.0.0.1", "--host", help="Host to bind."),
525
+ no_open: bool = typer.Option(
526
+ False, "--no-open", help="Don't auto-open the browser."
527
+ ),
528
+ explore_dir: str = typer.Option(
529
+ ".codegraph/explore",
530
+ "--explore-dir",
531
+ help="Folder of pyvis pages (architecture/callgraph/...) to also serve.",
532
+ ),
533
+ ) -> None:
534
+ """Run the interactive dashboard as a local web app."""
535
+ from codegraph.graph.builder import GraphBuilder
536
+ from codegraph.graph.store_networkx import to_digraph
537
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
538
+ from codegraph.viz.explore import render_explore
539
+ from codegraph.web import DashboardState
540
+ from codegraph.web import serve as run_server
541
+
542
+ repo_root = Path.cwd()
543
+ data_dir = _get_data_dir(repo_root)
544
+ db_path = data_dir / "graph.db"
545
+ if not db_path.exists():
546
+ console.print(
547
+ "[yellow]No graph found. Run [bold]codegraph build[/bold] first.[/yellow]"
548
+ )
549
+ raise typer.Exit(1)
550
+
551
+ explore_path = Path(explore_dir)
552
+ if not explore_path.is_absolute():
553
+ explore_path = repo_root / explore_path
554
+
555
+ def _load_graph() -> nx.MultiDiGraph:
556
+ store = SQLiteGraphStore(db_path)
557
+ try:
558
+ return to_digraph(store)
559
+ finally:
560
+ store.close()
561
+
562
+ def _rebuild() -> nx.MultiDiGraph:
563
+ store = SQLiteGraphStore(db_path)
564
+ try:
565
+ GraphBuilder(repo_root, store).build(incremental=False)
566
+ graph = to_digraph(store)
567
+ finally:
568
+ store.close()
569
+ # Refresh pyvis pages too so the Files / Explorers tabs stay in sync.
570
+ try:
571
+ render_explore(graph, explore_path)
572
+ except Exception as exc:
573
+ console.print(
574
+ f"[yellow]warn:[/yellow] failed to refresh explore pages: {exc}"
575
+ )
576
+ return graph
577
+
578
+ # Make sure pyvis pages exist on first run.
579
+ if not (explore_path / "architecture.html").exists():
580
+ console.print("[dim]First run: generating pyvis pages...[/dim]")
581
+ try:
582
+ render_explore(_load_graph(), explore_path)
583
+ except Exception as exc:
584
+ console.print(f"[yellow]warn:[/yellow] {exc}")
585
+
586
+ state = DashboardState(
587
+ repo_root=repo_root,
588
+ explore_dir=explore_path,
589
+ graph_loader=_load_graph,
590
+ rebuild=_rebuild,
591
+ )
592
+ run_server(state, host=host, port=port, open_browser=not no_open)
593
+
594
+
595
+ @app.command()
596
+ def review(
597
+ target: str = typer.Option("main", help="Target branch to PR into."),
598
+ block_on: str = typer.Option(
599
+ "high", "--block-on", help="critical|high|med|low"
600
+ ),
601
+ fail_on: str | None = typer.Option(
602
+ None,
603
+ "--fail-on",
604
+ help="Exit non-zero if any finding has at least this severity. "
605
+ "Defaults to --block-on.",
606
+ ),
607
+ baseline: str | None = typer.Option(
608
+ None, "--baseline", help="Path to baseline graph.db (default: .codegraph/baseline.db)."
609
+ ),
610
+ fmt: str = typer.Option(
611
+ "markdown", "--format", help="markdown|json|sarif"
612
+ ),
613
+ output: str | None = typer.Option(
614
+ None, "--output", help="Write report to file instead of stdout."
615
+ ),
616
+ rules_file: str | None = typer.Option(
617
+ None, "--rules", help="Path to rules YAML (default: .codegraph/rules.yml)."
618
+ ),
619
+ ) -> None:
620
+ """Diff vs baseline; produce a risk-scored PR review."""
621
+ from codegraph.review.baseline import load_baseline
622
+ from codegraph.review.differ import diff_graphs
623
+ from codegraph.review.rules import (
624
+ evaluate_rules,
625
+ load_rules,
626
+ severity_at_least,
627
+ )
628
+
629
+ repo_root = Path.cwd()
630
+ data_dir = _get_data_dir(repo_root)
631
+ db_path = data_dir / "graph.db"
632
+ if not db_path.exists():
633
+ console.print(
634
+ "[yellow]No graph found. Run [bold]codegraph build[/bold] first.[/yellow]"
635
+ )
636
+ raise typer.Exit(1)
637
+
638
+ baseline_path = Path(baseline) if baseline else data_dir / "baseline.db"
639
+ old_graph = load_baseline(baseline_path)
640
+ if old_graph is None:
641
+ console.print(
642
+ f"[yellow]No baseline found at {baseline_path}. "
643
+ f"Run [bold]codegraph baseline save[/bold] first.[/yellow]"
644
+ )
645
+ raise typer.Exit(2)
646
+
647
+ new_graph = _open_graph(repo_root)
648
+ if new_graph is None:
649
+ raise typer.Exit(1)
650
+
651
+ diff = diff_graphs(old_graph, new_graph)
652
+ rules = load_rules(Path(rules_file) if rules_file else None)
653
+ findings = evaluate_rules(
654
+ diff, new_graph=new_graph, old_graph=old_graph, rules=rules
655
+ )
656
+
657
+ threshold = (fail_on or block_on).lower()
658
+ text = _render_review(diff, findings, fmt=fmt, target=target)
659
+ if output:
660
+ Path(output).write_text(text)
661
+ console.print(f"[green]✓[/green] wrote review to {output}")
662
+ else:
663
+ print(text)
664
+
665
+ blocking = [f for f in findings if severity_at_least(f.severity, threshold)]
666
+ if blocking:
667
+ raise typer.Exit(1)
668
+
669
+
670
+ def _render_review(
671
+ diff: GraphDiff,
672
+ findings: list[Finding],
673
+ *,
674
+ fmt: str,
675
+ target: str,
676
+ ) -> str:
677
+ import json
678
+
679
+ if fmt == "json":
680
+ payload = {
681
+ "target": target,
682
+ "diff": {
683
+ "added_nodes": [_nc_to_dict(n) for n in diff.added_nodes],
684
+ "removed_nodes": [_nc_to_dict(n) for n in diff.removed_nodes],
685
+ "modified_nodes": [_nc_to_dict(n) for n in diff.modified_nodes],
686
+ "added_edges": [_ec_to_dict(e) for e in diff.added_edges],
687
+ "removed_edges": [_ec_to_dict(e) for e in diff.removed_edges],
688
+ },
689
+ "findings": [_finding_to_dict(f) for f in findings],
690
+ }
691
+ return json.dumps(payload, indent=2, sort_keys=True)
692
+ if fmt == "sarif":
693
+ return _render_sarif(findings)
694
+ return _render_markdown(diff, findings, target=target)
695
+
696
+
697
+ def _nc_to_dict(n: NodeChange) -> dict[str, object]:
698
+ return {
699
+ "qualname": n.qualname,
700
+ "kind": n.kind,
701
+ "file": n.file,
702
+ "line_start": n.line_start,
703
+ "signature": n.signature,
704
+ "change_kind": n.change_kind,
705
+ "details": n.details,
706
+ }
707
+
708
+
709
+ def _ec_to_dict(e: EdgeChange) -> dict[str, object]:
710
+ return {
711
+ "src_qualname": e.src_qualname,
712
+ "dst_qualname": e.dst_qualname,
713
+ "kind": e.kind,
714
+ "change_kind": e.change_kind,
715
+ }
716
+
717
+
718
+ def _finding_to_dict(f: Finding) -> dict[str, object]:
719
+ return {
720
+ "rule_id": f.rule_id,
721
+ "severity": f.severity,
722
+ "message": f.message,
723
+ "qualname": f.qualname,
724
+ "file": f.file,
725
+ "line": f.line,
726
+ "score": f.score,
727
+ "reasons": list(f.reasons),
728
+ }
729
+
730
+
731
+ def _render_markdown(
732
+ diff: GraphDiff, findings: list[Finding], *, target: str
733
+ ) -> str:
734
+ lines: list[str] = [f"# codegraph review (target: {target})", ""]
735
+ lines.append(
736
+ f"**Diff**: +{len(diff.added_nodes)} / -{len(diff.removed_nodes)} / "
737
+ f"~{len(diff.modified_nodes)} nodes, "
738
+ f"+{len(diff.added_edges)} / -{len(diff.removed_edges)} edges"
739
+ )
740
+ lines.append("")
741
+ lines.append(f"## Findings ({len(findings)})")
742
+ if not findings:
743
+ lines.append("")
744
+ lines.append("_No findings._")
745
+ return "\n".join(lines) + "\n"
746
+ lines.append("")
747
+ lines.append("| severity | rule | qualname | file:line | score | message |")
748
+ lines.append("|---|---|---|---|---|---|")
749
+ for f in findings:
750
+ loc = f"{f.file}:{f.line}" if f.file else ""
751
+ lines.append(
752
+ f"| {f.severity} | {f.rule_id} | `{f.qualname}` | {loc} | "
753
+ f"{f.score} | {f.message} |"
754
+ )
755
+ return "\n".join(lines) + "\n"
756
+
757
+
758
+ def _render_sarif(findings: list[Finding]) -> str:
759
+ import json
760
+
761
+ _sev_map = {
762
+ "low": "note",
763
+ "med": "warning",
764
+ "high": "error",
765
+ "critical": "error",
766
+ }
767
+ rule_ids = sorted({f.rule_id for f in findings})
768
+ sarif = {
769
+ "version": "2.1.0",
770
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
771
+ "runs": [
772
+ {
773
+ "tool": {
774
+ "driver": {
775
+ "name": "codegraph",
776
+ "informationUri": "https://github.com/smochan/polycodegraph",
777
+ "rules": [
778
+ {"id": rid, "name": rid} for rid in rule_ids
779
+ ],
780
+ }
781
+ },
782
+ "results": [
783
+ {
784
+ "ruleId": f.rule_id,
785
+ "level": _sev_map.get(f.severity, "warning"),
786
+ "message": {"text": f.message},
787
+ "locations": [
788
+ {
789
+ "physicalLocation": {
790
+ "artifactLocation": {"uri": f.file or ""},
791
+ "region": {
792
+ "startLine": max(1, f.line or 1)
793
+ },
794
+ }
795
+ }
796
+ ]
797
+ if f.file
798
+ else [],
799
+ "properties": {
800
+ "score": f.score,
801
+ "qualname": f.qualname,
802
+ "reasons": list(f.reasons),
803
+ },
804
+ }
805
+ for f in findings
806
+ ],
807
+ }
808
+ ],
809
+ }
810
+ return json.dumps(sarif, indent=2, sort_keys=True)
811
+
812
+
813
+ def _print_node_table(
814
+ graph: nx.MultiDiGraph, node_ids: list[str], title: str
815
+ ) -> None:
816
+ table = Table(title=title)
817
+ table.add_column("kind", style="cyan")
818
+ table.add_column("qualname")
819
+ table.add_column("file")
820
+ table.add_column("line", justify="right")
821
+ for nid in node_ids:
822
+ attrs = graph.nodes.get(nid) or {}
823
+ table.add_row(
824
+ str(attrs.get("kind") or ""),
825
+ str(attrs.get("qualname") or nid),
826
+ str(attrs.get("file") or ""),
827
+ str(attrs.get("line_start") or ""),
828
+ )
829
+ console.print(table)
830
+
831
+
832
+ @query_app.command("callers")
833
+ def query_callers(
834
+ symbol: str,
835
+ depth: int = typer.Option(1, "--depth"),
836
+ ) -> None:
837
+ """Show transitive callers of SYMBOL up to ``depth`` hops."""
838
+ from codegraph.analysis.blast_radius import blast_radius
839
+ from codegraph.analysis.report import find_symbol
840
+
841
+ graph = _open_graph(Path.cwd())
842
+ if graph is None:
843
+ raise typer.Exit(1)
844
+ target = find_symbol(graph, symbol)
845
+ if target is None:
846
+ console.print(f"[yellow]Symbol '{symbol}' not found.[/yellow]")
847
+ raise typer.Exit(1)
848
+ result = blast_radius(graph, target, depth=depth)
849
+ console.print(
850
+ f"[bold]Callers of[/bold] {symbol} "
851
+ f"(depth={depth}): {result.size} nodes across {len(result.files)} files"
852
+ )
853
+ _print_node_table(graph, result.nodes[:50], "Callers")
854
+ if result.size > 50:
855
+ console.print(f"[dim]… {result.size - 50} more[/dim]")
856
+
857
+
858
+ @query_app.command("subgraph")
859
+ def query_subgraph(
860
+ symbol: str,
861
+ depth: int = typer.Option(2, "--depth"),
862
+ ) -> None:
863
+ """Print the symbol's depth-N neighborhood as Mermaid."""
864
+ from codegraph.analysis.report import find_symbol
865
+ from codegraph.graph.store_networkx import subgraph_around
866
+
867
+ graph = _open_graph(Path.cwd())
868
+ if graph is None:
869
+ raise typer.Exit(1)
870
+ target = find_symbol(graph, symbol)
871
+ if target is None:
872
+ console.print(f"[yellow]Symbol '{symbol}' not found.[/yellow]")
873
+ raise typer.Exit(1)
874
+ sub = subgraph_around(graph, target, depth=depth)
875
+ lines = ["flowchart LR"]
876
+ safe: dict[str, str] = {}
877
+ for nid, attrs in sub.nodes(data=True):
878
+ sid = "n_" + str(nid)[:16]
879
+ safe[nid] = sid
880
+ label = str(attrs.get("name") or nid).replace('"', "'")
881
+ kind = attrs.get("kind", "")
882
+ lines.append(f' {sid}["{kind}: {label}"]')
883
+ seen: set[tuple[str, str, str]] = set()
884
+ for src, dst, data in sub.edges(data=True):
885
+ if src not in safe or dst not in safe:
886
+ continue
887
+ ek = str(data.get("kind", ""))
888
+ key = (src, dst, ek)
889
+ if key in seen:
890
+ continue
891
+ seen.add(key)
892
+ lines.append(f" {safe[src]} -->|{ek}| {safe[dst]}")
893
+ print("\n".join(lines))
894
+
895
+
896
+ @query_app.command("untested")
897
+ def query_untested(limit: int = typer.Option(50, "--limit")) -> None:
898
+ """List functions/methods with no test-side caller."""
899
+ from codegraph.analysis import find_untested
900
+
901
+ graph = _open_graph(Path.cwd())
902
+ if graph is None:
903
+ raise typer.Exit(1)
904
+ rows = find_untested(graph)
905
+ console.print(f"[bold]{len(rows)} untested[/bold]")
906
+ table = Table()
907
+ table.add_column("qualname")
908
+ table.add_column("file")
909
+ table.add_column("line", justify="right")
910
+ table.add_column("callers", justify="right")
911
+ for u in rows[:limit]:
912
+ table.add_row(u.qualname, u.file, str(u.line_start), str(u.incoming_calls))
913
+ console.print(table)
914
+
915
+
916
+ @query_app.command("deadcode")
917
+ def query_deadcode(limit: int = typer.Option(50, "--limit")) -> None:
918
+ """List definitions with no incoming reference edges."""
919
+ from codegraph.analysis import find_dead_code
920
+
921
+ graph = _open_graph(Path.cwd())
922
+ if graph is None:
923
+ raise typer.Exit(1)
924
+ rows = find_dead_code(graph)
925
+ console.print(f"[bold]{len(rows)} dead-code candidates[/bold]")
926
+ table = Table()
927
+ table.add_column("kind")
928
+ table.add_column("qualname")
929
+ table.add_column("file")
930
+ table.add_column("line", justify="right")
931
+ for d in rows[:limit]:
932
+ table.add_row(d.kind, d.qualname, d.file, str(d.line_start))
933
+ console.print(table)
934
+
935
+
936
+ @query_app.command("cycles")
937
+ def query_cycles() -> None:
938
+ """List import + call cycles."""
939
+ from codegraph.analysis import find_cycles
940
+
941
+ graph = _open_graph(Path.cwd())
942
+ if graph is None:
943
+ raise typer.Exit(1)
944
+ rep = find_cycles(graph)
945
+ console.print(
946
+ f"[bold]Cycles[/bold]: {len(rep.import_cycles)} import, "
947
+ f"{len(rep.call_cycles)} call"
948
+ )
949
+ for label, cycles in (
950
+ ("Import cycles", rep.import_cycles),
951
+ ("Call cycles", rep.call_cycles),
952
+ ):
953
+ if not cycles:
954
+ continue
955
+ console.print(f"\n[cyan]{label}:[/cyan]")
956
+ for cyc in cycles[:25]:
957
+ console.print(" - " + " → ".join(cyc.qualnames))
958
+
959
+
960
+ @baseline_app.command("save")
961
+ def baseline_save(
962
+ output: str | None = typer.Option(
963
+ None,
964
+ "--output",
965
+ "-o",
966
+ help="Output baseline path (default: .codegraph/baseline.db).",
967
+ ),
968
+ ) -> None:
969
+ """Snapshot the current graph as the local baseline."""
970
+ from codegraph.review.baseline import save_baseline
971
+
972
+ repo_root = Path.cwd()
973
+ data_dir = _get_data_dir(repo_root)
974
+ db_path = data_dir / "graph.db"
975
+ if not db_path.exists():
976
+ console.print(
977
+ "[yellow]No graph found. Run [bold]codegraph build[/bold] first.[/yellow]"
978
+ )
979
+ raise typer.Exit(1)
980
+ out_path = Path(output) if output else data_dir / "baseline.db"
981
+ save_baseline(db_path, out_path)
982
+ console.print(f"[green]✓[/green] saved baseline to {out_path}")
983
+
984
+
985
+ @baseline_app.command("status")
986
+ def baseline_status() -> None:
987
+ """Show whether a local baseline exists."""
988
+ repo_root = Path.cwd()
989
+ data_dir = _get_data_dir(repo_root)
990
+ baseline_path = data_dir / "baseline.db"
991
+ if baseline_path.exists():
992
+ size = baseline_path.stat().st_size
993
+ console.print(
994
+ f"[green]✓[/green] baseline present: {baseline_path} ({size} bytes)"
995
+ )
996
+ else:
997
+ console.print(
998
+ f"[yellow]No baseline at {baseline_path}.[/yellow] "
999
+ f"Run [bold]codegraph baseline save[/bold]."
1000
+ )
1001
+ raise typer.Exit(1)
1002
+
1003
+
1004
+ @baseline_app.command("push")
1005
+ def baseline_push(
1006
+ target: str = typer.Option("main", help="Target branch label."),
1007
+ ) -> None:
1008
+ """Register the current graph as the baseline for ``target`` (CI use)."""
1009
+ from codegraph.review.baseline import save_baseline
1010
+
1011
+ repo_root = Path.cwd()
1012
+ data_dir = _get_data_dir(repo_root)
1013
+ db_path = data_dir / "graph.db"
1014
+ if not db_path.exists():
1015
+ console.print(
1016
+ "[yellow]No graph found. Run [bold]codegraph build[/bold] first.[/yellow]"
1017
+ )
1018
+ raise typer.Exit(1)
1019
+ out_path = data_dir / "baseline.db"
1020
+ save_baseline(db_path, out_path)
1021
+ console.print(
1022
+ f"[green]✓[/green] pushed baseline for [bold]{target}[/bold] -> {out_path}"
1023
+ )
1024
+
1025
+
1026
+ @hook_app.command("install")
1027
+ def hook_install(
1028
+ target: str = typer.Option(
1029
+ "main", "--target", help="Target branch the hook reviews against."
1030
+ ),
1031
+ hook: str = typer.Option(
1032
+ "pre-push", "--hook", help="Git hook name to install (pre-push|pre-commit)."
1033
+ ),
1034
+ force: bool = typer.Option(
1035
+ False, "--force", help="Overwrite an existing non-codegraph hook."
1036
+ ),
1037
+ ) -> None:
1038
+ """Install a git hook that runs ``codegraph review``."""
1039
+ from codegraph.review.hook import install_hook
1040
+
1041
+ repo_root = Path.cwd()
1042
+ try:
1043
+ path = install_hook(repo_root, hook=hook, target=target, force=force)
1044
+ except FileNotFoundError as exc:
1045
+ console.print(f"[red]error:[/red] {exc}")
1046
+ raise typer.Exit(1) from exc
1047
+ except FileExistsError as exc:
1048
+ console.print(f"[red]error:[/red] {exc}")
1049
+ raise typer.Exit(1) from exc
1050
+ console.print(f"[green]✓[/green] installed git hook at {path}")
1051
+
1052
+
1053
+ @hook_app.command("uninstall")
1054
+ def hook_uninstall(
1055
+ hook: str = typer.Option(
1056
+ "pre-push", "--hook", help="Git hook name to remove."
1057
+ ),
1058
+ ) -> None:
1059
+ """Remove the codegraph-managed git hook."""
1060
+ from codegraph.review.hook import uninstall_hook
1061
+
1062
+ repo_root = Path.cwd()
1063
+ if uninstall_hook(repo_root, hook=hook):
1064
+ console.print(f"[green]✓[/green] removed {hook} hook")
1065
+ else:
1066
+ console.print(
1067
+ f"[yellow]No codegraph-managed {hook} hook to remove.[/yellow]"
1068
+ )
1069
+
1070
+
1071
+ @dataflow_app.command("trace")
1072
+ def dataflow_trace_cmd(
1073
+ entry: str = typer.Argument(
1074
+ ...,
1075
+ help=(
1076
+ "Entry point — either a function qualname (e.g. "
1077
+ "'app.handlers.get_user'), or a fetch shape "
1078
+ "('GET /api/users/{id}')."
1079
+ ),
1080
+ ),
1081
+ depth: int = typer.Option(6, "--depth", help="Max trace depth (hops)."),
1082
+ fmt: str = typer.Option("markdown", "--format", help="markdown|json"),
1083
+ ) -> None:
1084
+ """Trace a data flow from an entry point through the call graph and
1085
+ cross-layer edges. Output is markdown or JSON.
1086
+ """
1087
+ import json as _json
1088
+
1089
+ from codegraph.analysis.dataflow import trace as _trace
1090
+
1091
+ repo_root = Path.cwd()
1092
+ graph = _open_graph(repo_root)
1093
+ if graph is None:
1094
+ raise typer.Exit(1)
1095
+
1096
+ flow = _trace(graph, entry, max_depth=depth)
1097
+ if flow is None:
1098
+ console.print(
1099
+ f"[yellow]No symbol or route matched: {entry}[/yellow]"
1100
+ )
1101
+ raise typer.Exit(2)
1102
+
1103
+ if fmt == "json":
1104
+ typer.echo(_json.dumps(flow.to_dict(), indent=2))
1105
+ return
1106
+
1107
+ # Default: rich markdown-ish output
1108
+ console.print(
1109
+ f"\n[bold]Flow trace from:[/bold] {flow.entry} "
1110
+ f"([cyan]confidence: {flow.confidence:.2f}[/cyan])\n"
1111
+ )
1112
+ if not flow.hops:
1113
+ console.print(" [yellow](no hops)[/yellow]")
1114
+ return
1115
+ layer_styles = {
1116
+ "frontend": "magenta",
1117
+ "backend": "cyan",
1118
+ "db": "yellow",
1119
+ }
1120
+ for i, hop in enumerate(flow.hops):
1121
+ prefix = " " if i == 0 else " ↓\n "
1122
+ layer_label = f"[{layer_styles.get(hop.layer, 'white')}][{hop.layer}][/]"
1123
+ loc = f"{hop.file}:{hop.line}" if hop.file else ""
1124
+ role = f" [bold]{hop.role}[/bold]" if hop.role else ""
1125
+ line1 = f"{prefix}{layer_label} {loc} [white]{hop.qualname}[/white]{role}"
1126
+ console.print(line1)
1127
+ if hop.method and hop.path:
1128
+ console.print(
1129
+ f" [dim]{hop.method} {hop.path}[/dim]"
1130
+ )
1131
+ if hop.args or hop.kwargs:
1132
+ args_str = ", ".join(hop.args)
1133
+ kwargs_str = ", ".join(f"{k}={v}" for k, v in hop.kwargs.items())
1134
+ joined = ", ".join(s for s in (args_str, kwargs_str) if s)
1135
+ console.print(f" [dim]args: ({joined})[/dim]")
1136
+ console.print()
1137
+
1138
+
1139
+ @app.command()
1140
+ def embed(
1141
+ model: str = typer.Option(
1142
+ "nomic-ai/CodeRankEmbed",
1143
+ "--model",
1144
+ help="HuggingFace model id (default: nomic-ai/CodeRankEmbed).",
1145
+ ),
1146
+ force: bool = typer.Option(
1147
+ False, "--force", help="Rebuild the index from scratch."
1148
+ ),
1149
+ batch_size: int = typer.Option(
1150
+ 32, "--batch-size", help="Embedding batch size."
1151
+ ),
1152
+ ) -> None:
1153
+ """Build (or refresh) the local embeddings index."""
1154
+ from rich.progress import (
1155
+ BarColumn,
1156
+ Progress,
1157
+ SpinnerColumn,
1158
+ TextColumn,
1159
+ TimeElapsedColumn,
1160
+ )
1161
+
1162
+ repo_root = Path.cwd()
1163
+ data_dir = _get_data_dir(repo_root)
1164
+ db_path = data_dir / "graph.db"
1165
+ if not db_path.exists():
1166
+ console.print(
1167
+ "[yellow]No graph found. "
1168
+ "Run [bold]codegraph build[/bold] first.[/yellow]"
1169
+ )
1170
+ raise typer.Exit(1)
1171
+
1172
+ try:
1173
+ from codegraph.embed.chunker import chunk_repo
1174
+ from codegraph.embed.embedder import Embedder, MissingDependencyError
1175
+ from codegraph.embed.store import EmbeddingStore, StoredChunk
1176
+ except ImportError as exc:
1177
+ console.print(
1178
+ "[red]error:[/red] embeddings dependencies missing.\n"
1179
+ "Install with: [bold]pip install -e \".[embed]\"[/bold]"
1180
+ )
1181
+ raise typer.Exit(1) from exc
1182
+
1183
+ chunks = list(chunk_repo(repo_root, db_path=db_path))
1184
+ console.print(f"[bold]Embedding[/bold] {len(chunks)} chunks with {model}...")
1185
+
1186
+ try:
1187
+ embedder = Embedder(model)
1188
+ except MissingDependencyError as exc:
1189
+ console.print(f"[red]error:[/red] {exc}")
1190
+ raise typer.Exit(1) from exc
1191
+
1192
+ try:
1193
+ with Progress(
1194
+ SpinnerColumn(),
1195
+ TextColumn("[bold]embedding[/bold]"),
1196
+ BarColumn(),
1197
+ TextColumn("{task.completed}/{task.total}"),
1198
+ TimeElapsedColumn(),
1199
+ console=console,
1200
+ transient=False,
1201
+ ) as progress:
1202
+ task_id = progress.add_task("embed", total=max(1, len(chunks)))
1203
+ texts = [c.text for c in chunks]
1204
+ vectors: list[list[float]] = []
1205
+ if texts:
1206
+ # Batch so we can update progress.
1207
+ for i in range(0, len(texts), batch_size):
1208
+ batch = texts[i : i + batch_size]
1209
+ vectors.extend(embedder.embed(batch, batch_size=batch_size))
1210
+ progress.update(task_id, advance=len(batch))
1211
+ else:
1212
+ progress.update(task_id, advance=1)
1213
+
1214
+ rows: list[StoredChunk] = []
1215
+ dim = len(vectors[0]) if vectors else 768
1216
+ for c, v in zip(chunks, vectors, strict=False):
1217
+ rows.append(
1218
+ StoredChunk(
1219
+ id=c.id,
1220
+ qualname=c.qualname,
1221
+ file=c.file,
1222
+ line_start=c.line_start,
1223
+ line_end=c.line_end,
1224
+ kind=c.kind,
1225
+ role=c.role,
1226
+ text=c.text,
1227
+ vector=v,
1228
+ )
1229
+ )
1230
+
1231
+ store = EmbeddingStore(data_dir, dim=dim)
1232
+ if force:
1233
+ store.replace_all(rows)
1234
+ else:
1235
+ store.upsert(rows)
1236
+ except MissingDependencyError as exc:
1237
+ console.print(f"[red]error:[/red] {exc}")
1238
+ raise typer.Exit(1) from exc
1239
+
1240
+ table = Table(title="Embeddings Summary")
1241
+ table.add_column("Metric", style="cyan")
1242
+ table.add_column("Value", justify="right")
1243
+ table.add_row("Chunks indexed", str(len(rows)))
1244
+ table.add_row("Model", model)
1245
+ table.add_row("Dim", str(dim))
1246
+ table.add_row("Backend", store.backend_name)
1247
+ table.add_row("On-disk", f"{store.size_bytes()} bytes")
1248
+ console.print(table)
1249
+
1250
+
1251
+ @mcp_app.command("serve")
1252
+ def mcp_serve(
1253
+ db: str | None = typer.Option(
1254
+ None,
1255
+ "--db",
1256
+ help="Path to graph.db (default: .codegraph/graph.db in cwd).",
1257
+ ),
1258
+ name: str = typer.Option(
1259
+ "codegraph",
1260
+ "--name",
1261
+ help="Server name advertised over MCP.",
1262
+ ),
1263
+ ) -> None:
1264
+ """Run as an MCP stdio server exposing focused subgraph tools to AI assistants."""
1265
+ from codegraph.mcp_server.server import run
1266
+
1267
+ db_path = Path(db) if db else None
1268
+ try:
1269
+ run(db_path=db_path, server_name=name)
1270
+ except KeyboardInterrupt:
1271
+ raise typer.Exit(0) from None
1272
+
1273
+
1274
+ @workspace_app.command("init")
1275
+ def workspace_init(
1276
+ force: bool = typer.Option(
1277
+ False, "--force", "-f", help="Overwrite an existing workspace file."
1278
+ ),
1279
+ ) -> None:
1280
+ """Create an empty workspace file at ``~/.codegraph/workspace.yml``."""
1281
+ from codegraph.workspace.config import (
1282
+ WorkspaceConfig,
1283
+ load_workspace,
1284
+ resolve_workspace_path,
1285
+ save_workspace,
1286
+ )
1287
+
1288
+ cfg_path = resolve_workspace_path()
1289
+ if cfg_path.exists() and not force:
1290
+ existing = load_workspace(cfg_path)
1291
+ console.print(
1292
+ f"[yellow]Workspace already exists at {cfg_path} "
1293
+ f"({len(existing.repos)} repos). Use --force to reset.[/yellow]"
1294
+ )
1295
+ raise typer.Exit(1)
1296
+ save_workspace(WorkspaceConfig(), cfg_path)
1297
+ console.print(f"[green]✓[/green] initialized workspace at {cfg_path}")
1298
+
1299
+
1300
+ @workspace_app.command("add")
1301
+ def workspace_add(
1302
+ repo: str = typer.Argument(..., help="Path to a repository to register."),
1303
+ name: str | None = typer.Option(
1304
+ None, "--name", help="Short label for the repo (default: directory name)."
1305
+ ),
1306
+ ) -> None:
1307
+ """Register a repository in the workspace."""
1308
+ from codegraph.workspace.config import (
1309
+ WorkspaceRepo,
1310
+ load_workspace,
1311
+ resolve_workspace_path,
1312
+ save_workspace,
1313
+ )
1314
+
1315
+ repo_path = Path(repo).expanduser().resolve()
1316
+ if not repo_path.exists():
1317
+ console.print(f"[red]error:[/red] {repo_path} does not exist")
1318
+ raise typer.Exit(1)
1319
+ if not repo_path.is_dir():
1320
+ console.print(f"[red]error:[/red] {repo_path} is not a directory")
1321
+ raise typer.Exit(1)
1322
+
1323
+ cfg_path = resolve_workspace_path()
1324
+ cfg = load_workspace(cfg_path)
1325
+ if cfg.has_repo(repo_path):
1326
+ console.print(f"[yellow]{repo_path} is already registered.[/yellow]")
1327
+ raise typer.Exit(0)
1328
+ cfg.repos.append(WorkspaceRepo(path=str(repo_path), name=name))
1329
+ save_workspace(cfg, cfg_path)
1330
+ label = name or repo_path.name
1331
+ console.print(
1332
+ f"[green]✓[/green] added [bold]{label}[/bold] → {repo_path} "
1333
+ f"({len(cfg.repos)} repos total)"
1334
+ )
1335
+
1336
+
1337
+ @workspace_app.command("remove")
1338
+ def workspace_remove(
1339
+ repo: str = typer.Argument(..., help="Path to the repository to deregister."),
1340
+ ) -> None:
1341
+ """Remove a repository from the workspace."""
1342
+ from codegraph.workspace.config import (
1343
+ load_workspace,
1344
+ resolve_workspace_path,
1345
+ save_workspace,
1346
+ )
1347
+
1348
+ repo_path = Path(repo).expanduser().resolve()
1349
+ cfg_path = resolve_workspace_path()
1350
+ cfg = load_workspace(cfg_path)
1351
+ if not cfg.remove_repo(repo_path):
1352
+ console.print(
1353
+ f"[yellow]{repo_path} is not in the workspace.[/yellow]"
1354
+ )
1355
+ raise typer.Exit(1)
1356
+ save_workspace(cfg, cfg_path)
1357
+ console.print(
1358
+ f"[green]✓[/green] removed {repo_path} ({len(cfg.repos)} repos left)"
1359
+ )
1360
+
1361
+
1362
+ @workspace_app.command("list")
1363
+ def workspace_list() -> None:
1364
+ """List all repositories registered in the workspace."""
1365
+ from codegraph.workspace.config import load_workspace, resolve_workspace_path
1366
+
1367
+ cfg_path = resolve_workspace_path()
1368
+ cfg = load_workspace(cfg_path)
1369
+ if not cfg.repos:
1370
+ console.print(
1371
+ "[yellow]No repositories registered yet.[/yellow] "
1372
+ "Run [bold]codegraph workspace add <path>[/bold]."
1373
+ )
1374
+ return
1375
+
1376
+ table = Table(title=f"workspace ({cfg_path})")
1377
+ table.add_column("Name", style="cyan", no_wrap=True)
1378
+ table.add_column("Path")
1379
+ table.add_column("Graph", justify="center")
1380
+ for r in cfg.repos:
1381
+ path = Path(r.path).expanduser()
1382
+ has_graph = (path / ".codegraph" / "graph.db").exists()
1383
+ graph_cell = "[green]✓[/green]" if has_graph else "[red]—[/red]"
1384
+ table.add_row(r.display_name, str(path), graph_cell)
1385
+ console.print(table)
1386
+
1387
+
1388
+ @workspace_app.command("status")
1389
+ def workspace_status() -> None:
1390
+ """Show git + graph status for every registered repo."""
1391
+ from codegraph.workspace.config import load_workspace, resolve_workspace_path
1392
+ from codegraph.workspace.operations import workspace_state
1393
+
1394
+ cfg = load_workspace(resolve_workspace_path())
1395
+ if not cfg.repos:
1396
+ console.print(
1397
+ "[yellow]No repositories registered yet.[/yellow] "
1398
+ "Run [bold]codegraph workspace add <path>[/bold]."
1399
+ )
1400
+ return
1401
+
1402
+ state = workspace_state(cfg)
1403
+ table = Table(title=f"workspace status ({state['workspace_size']} repos)")
1404
+ table.add_column("Name", style="cyan", no_wrap=True)
1405
+ table.add_column("Branch")
1406
+ table.add_column("Dirty", justify="right")
1407
+ table.add_column("Last commit", overflow="fold")
1408
+ table.add_column("Graph", justify="center")
1409
+ table.add_column("Note", style="yellow")
1410
+
1411
+ for r in state["repos"]:
1412
+ graph_cell = "[green]✓[/green]" if r["has_graph"] else "[red]—[/red]"
1413
+ dirty = str(r["dirty_files"]) if r["dirty_files"] else "0"
1414
+ branch = r["branch"] or "—"
1415
+ last = r["last_commit"] or "—"
1416
+ if last and len(last) > 60:
1417
+ last = last[:57] + "..."
1418
+ note = r["error"] or ""
1419
+ table.add_row(
1420
+ r["name"],
1421
+ branch,
1422
+ dirty,
1423
+ last,
1424
+ graph_cell,
1425
+ note,
1426
+ )
1427
+ console.print(table)
1428
+
1429
+
1430
+ @workspace_app.command("sync")
1431
+ def workspace_sync(
1432
+ incremental: bool = typer.Option(
1433
+ True, help="Incremental rebuild when possible."
1434
+ ),
1435
+ only: str | None = typer.Option(
1436
+ None, "--only", help="Sync just this repo (path or name)."
1437
+ ),
1438
+ ) -> None:
1439
+ """Rebuild the graph for every registered repo (or just --only one)."""
1440
+ from codegraph.config import load_config
1441
+ from codegraph.graph.builder import GraphBuilder
1442
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
1443
+ from codegraph.workspace.config import load_workspace, resolve_workspace_path
1444
+
1445
+ cfg = load_workspace(resolve_workspace_path())
1446
+ if not cfg.repos:
1447
+ console.print(
1448
+ "[yellow]No repositories registered yet.[/yellow] "
1449
+ "Run [bold]codegraph workspace add <path>[/bold]."
1450
+ )
1451
+ return
1452
+
1453
+ targets = list(cfg.repos)
1454
+ if only:
1455
+ only_resolved = str(Path(only).expanduser().resolve()) if "/" in only else None
1456
+ targets = [
1457
+ r
1458
+ for r in cfg.repos
1459
+ if r.display_name == only
1460
+ or (only_resolved and str(Path(r.path).resolve()) == only_resolved)
1461
+ ]
1462
+ if not targets:
1463
+ console.print(f"[red]error:[/red] no registered repo matches '{only}'")
1464
+ raise typer.Exit(1)
1465
+
1466
+ summary: list[tuple[str, str]] = []
1467
+ for r in targets:
1468
+ repo_path = Path(r.path).expanduser()
1469
+ if not repo_path.exists():
1470
+ summary.append((r.display_name, "skipped (missing dir)"))
1471
+ continue
1472
+ try:
1473
+ repo_cfg = load_config(repo_path)
1474
+ except Exception as exc:
1475
+ summary.append((r.display_name, f"config error: {exc}"))
1476
+ continue
1477
+ data_dir = repo_path / ".codegraph"
1478
+ data_dir.mkdir(parents=True, exist_ok=True)
1479
+ db_path = data_dir / "graph.db"
1480
+ store = SQLiteGraphStore(db_path)
1481
+ try:
1482
+ builder = GraphBuilder(repo_path, store, ignore=repo_cfg.ignore)
1483
+ stats = builder.build(incremental=incremental)
1484
+ except Exception as exc:
1485
+ store.close()
1486
+ summary.append((r.display_name, f"build failed: {exc}"))
1487
+ continue
1488
+ store.close()
1489
+ summary.append(
1490
+ (
1491
+ r.display_name,
1492
+ f"ok ({stats.files_parsed} files, {stats.nodes_added} nodes)",
1493
+ )
1494
+ )
1495
+
1496
+ table = Table(title=f"workspace sync ({len(targets)} repos)")
1497
+ table.add_column("Repo", style="cyan")
1498
+ table.add_column("Result")
1499
+ for name, result in summary:
1500
+ style = "green" if result.startswith("ok") else "yellow"
1501
+ table.add_row(name, f"[{style}]{result}[/{style}]")
1502
+ console.print(table)
1503
+
1504
+
1505
+ if __name__ == "__main__":
1506
+ app()