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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|