ctxgraph-code 0.2.0__py3-none-any.whl → 0.3.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.
- ctxgraph_code/cli.py +227 -45
- ctxgraph_code/config/build_status.py +93 -0
- ctxgraph_code/graph/builder.py +161 -32
- ctxgraph_code/graph/storage.py +44 -30
- {ctxgraph_code-0.2.0.dist-info → ctxgraph_code-0.3.0.dist-info}/METADATA +77 -29
- {ctxgraph_code-0.2.0.dist-info → ctxgraph_code-0.3.0.dist-info}/RECORD +9 -8
- {ctxgraph_code-0.2.0.dist-info → ctxgraph_code-0.3.0.dist-info}/WHEEL +0 -0
- {ctxgraph_code-0.2.0.dist-info → ctxgraph_code-0.3.0.dist-info}/entry_points.txt +0 -0
- {ctxgraph_code-0.2.0.dist-info → ctxgraph_code-0.3.0.dist-info}/top_level.txt +0 -0
ctxgraph_code/cli.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import time
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
6
7
|
|
|
@@ -8,6 +9,12 @@ import typer
|
|
|
8
9
|
from rich.console import Console
|
|
9
10
|
from rich.table import Table
|
|
10
11
|
|
|
12
|
+
from ctxgraph_code.config.build_status import (
|
|
13
|
+
get_build_status,
|
|
14
|
+
get_status_message,
|
|
15
|
+
mark_build_complete,
|
|
16
|
+
mark_build_started,
|
|
17
|
+
)
|
|
11
18
|
from ctxgraph_code.config.global_paths import get_global_claude_commands_dir
|
|
12
19
|
from ctxgraph_code.config.init import init_project
|
|
13
20
|
from ctxgraph_code.config.settings import Settings
|
|
@@ -66,19 +73,30 @@ def _resolve_storage(
|
|
|
66
73
|
if dir_name:
|
|
67
74
|
s = get_storage(path, dir_name=dir_name)
|
|
68
75
|
if s:
|
|
76
|
+
msg = get_status_message(path)
|
|
77
|
+
if msg:
|
|
78
|
+
console.print(msg)
|
|
69
79
|
return s
|
|
70
|
-
console.print(
|
|
80
|
+
console.print("[red]Error: No graph found for directory '[bold]{dir_name}[/bold]'.[/red]")
|
|
71
81
|
_print_avail(avail)
|
|
72
82
|
raise typer.Exit(1)
|
|
73
83
|
|
|
74
84
|
if avail["_combined"]:
|
|
75
|
-
|
|
85
|
+
s = get_storage(path)
|
|
86
|
+
if s:
|
|
87
|
+
msg = get_status_message(path)
|
|
88
|
+
if msg:
|
|
89
|
+
console.print(msg)
|
|
90
|
+
return s
|
|
76
91
|
|
|
77
92
|
if file_arg:
|
|
78
93
|
d = _resolve_dir(path, file_arg)
|
|
79
94
|
if d and d in avail["dirs"]:
|
|
80
95
|
s = get_storage(path, dir_name=d)
|
|
81
96
|
if s:
|
|
97
|
+
msg = get_status_message(path)
|
|
98
|
+
if msg:
|
|
99
|
+
console.print(msg)
|
|
82
100
|
return s
|
|
83
101
|
|
|
84
102
|
if avail["dirs"]:
|
|
@@ -237,17 +255,28 @@ def init(
|
|
|
237
255
|
|
|
238
256
|
|
|
239
257
|
def _print_build_hint(path: Path):
|
|
240
|
-
console.print(
|
|
258
|
+
console.print(
|
|
259
|
+
"\n[yellow]Run [bold]ctxgraph-code build[/bold]"
|
|
260
|
+
" to scan files and build the graph.[/yellow]"
|
|
261
|
+
)
|
|
241
262
|
|
|
242
263
|
|
|
243
|
-
def _build_single_graph(path, exts, user_patterns, db_path, label
|
|
264
|
+
def _build_single_graph(path, exts, user_patterns, db_path, label,
|
|
265
|
+
incremental=False, verbose=False, no_summary=False):
|
|
266
|
+
ctx_dir = path / ".ctxgraph"
|
|
267
|
+
mark_build_started(ctx_dir, os.getpid())
|
|
268
|
+
start = time.time()
|
|
244
269
|
with console.status(f"Scanning {', '.join(exts)} files for '{label}'..."):
|
|
245
270
|
stats = build_graph(
|
|
246
271
|
path,
|
|
247
272
|
db_path=db_path,
|
|
248
273
|
exclude_patterns=user_patterns,
|
|
249
274
|
extensions=exts,
|
|
275
|
+
incremental=incremental,
|
|
276
|
+
verbose=verbose,
|
|
277
|
+
no_summary=no_summary,
|
|
250
278
|
)
|
|
279
|
+
mark_build_complete(ctx_dir, time.time() - start)
|
|
251
280
|
|
|
252
281
|
table = Table(title=f"Graph Build: {label}")
|
|
253
282
|
table.add_column("Metric", style="cyan")
|
|
@@ -261,6 +290,88 @@ def _build_single_graph(path, exts, user_patterns, db_path, label):
|
|
|
261
290
|
return stats
|
|
262
291
|
|
|
263
292
|
|
|
293
|
+
def _build_dir_worker(path, exts, user_patterns, db_path, label,
|
|
294
|
+
incremental=False, verbose=False, no_summary=False):
|
|
295
|
+
"""Silent build worker for parallel execution. No console output."""
|
|
296
|
+
ctx_dir = path / ".ctxgraph"
|
|
297
|
+
mark_build_started(ctx_dir, os.getpid())
|
|
298
|
+
start = time.time()
|
|
299
|
+
stats = build_graph(
|
|
300
|
+
path,
|
|
301
|
+
db_path=db_path,
|
|
302
|
+
exclude_patterns=user_patterns,
|
|
303
|
+
extensions=exts,
|
|
304
|
+
incremental=incremental,
|
|
305
|
+
verbose=verbose,
|
|
306
|
+
no_summary=no_summary,
|
|
307
|
+
)
|
|
308
|
+
mark_build_complete(ctx_dir, time.time() - start)
|
|
309
|
+
return label, stats
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _build_dirs_parallel(path, exts, user_patterns, top_dirs, graphs_dir, jobs,
|
|
313
|
+
incremental=False, verbose=False, no_summary=False):
|
|
314
|
+
"""Build per-directory graphs in parallel using a thread pool."""
|
|
315
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
316
|
+
|
|
317
|
+
n_workers = max(1, jobs) if jobs > 0 else os.cpu_count() or 1
|
|
318
|
+
console.print(f"[dim]Building {len(top_dirs)} graphs with {n_workers} workers...[/dim]")
|
|
319
|
+
|
|
320
|
+
futures = []
|
|
321
|
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
322
|
+
for d in top_dirs:
|
|
323
|
+
db_path = graphs_dir / f"{d.name}.db"
|
|
324
|
+
fut = pool.submit(
|
|
325
|
+
_build_dir_worker, path, exts, user_patterns, db_path, d.name,
|
|
326
|
+
incremental, verbose, no_summary,
|
|
327
|
+
)
|
|
328
|
+
futures.append(fut)
|
|
329
|
+
|
|
330
|
+
results = []
|
|
331
|
+
for f in as_completed(futures):
|
|
332
|
+
try:
|
|
333
|
+
label, stats = f.result()
|
|
334
|
+
results.append((label, stats))
|
|
335
|
+
except Exception as e:
|
|
336
|
+
results.append(("error", str(e)))
|
|
337
|
+
|
|
338
|
+
results.sort(key=lambda x: x[0] if isinstance(x[0], str) else "")
|
|
339
|
+
|
|
340
|
+
summary = Table(title="Build Summary")
|
|
341
|
+
summary.add_column("Directory", style="cyan")
|
|
342
|
+
summary.add_column("Files", style="green")
|
|
343
|
+
summary.add_column("Nodes", style="blue")
|
|
344
|
+
summary.add_column("Edges", style="yellow")
|
|
345
|
+
summary.add_column("Time", style="magenta")
|
|
346
|
+
|
|
347
|
+
total_files = 0
|
|
348
|
+
total_nodes = 0
|
|
349
|
+
total_edges = 0
|
|
350
|
+
total_time = 0.0
|
|
351
|
+
|
|
352
|
+
for label, stats in results:
|
|
353
|
+
if isinstance(stats, str):
|
|
354
|
+
summary.add_row(f"{label}/", "[red]ERROR[/red]", stats, "", "")
|
|
355
|
+
else:
|
|
356
|
+
files = stats.get("files_analyzed", 0)
|
|
357
|
+
nodes = stats.get("total_nodes", 0)
|
|
358
|
+
edges = stats.get("total_edges", 0)
|
|
359
|
+
t = stats.get("elapsed_seconds", 0)
|
|
360
|
+
summary.add_row(f"{label}/", str(files), str(nodes), str(edges), f"{t}s")
|
|
361
|
+
total_files += files
|
|
362
|
+
total_nodes += nodes
|
|
363
|
+
total_edges += edges
|
|
364
|
+
total_time = max(total_time, t)
|
|
365
|
+
|
|
366
|
+
console.print(summary)
|
|
367
|
+
console.print(
|
|
368
|
+
f"\n[green]Built {len(top_dirs)} graphs: "
|
|
369
|
+
f"{total_files} files, {total_nodes} nodes, {total_edges} edges "
|
|
370
|
+
f"in {total_time}s[/green]"
|
|
371
|
+
)
|
|
372
|
+
return results
|
|
373
|
+
|
|
374
|
+
|
|
264
375
|
@app.command()
|
|
265
376
|
def build(
|
|
266
377
|
repo_path: Optional[str] = typer.Argument(
|
|
@@ -275,10 +386,22 @@ def build(
|
|
|
275
386
|
all_graph: bool = typer.Option(
|
|
276
387
|
False, "--all", "-a", help="Build a single combined graph instead of per-directory"
|
|
277
388
|
),
|
|
389
|
+
jobs: int = typer.Option(
|
|
390
|
+
0, "--jobs", "-j", help="Number of parallel workers (0 = auto, default: CPU count)"
|
|
391
|
+
),
|
|
392
|
+
incremental: bool = typer.Option(
|
|
393
|
+
False, "--incremental", "-i", help="Only rebuild files that changed since last build"
|
|
394
|
+
),
|
|
395
|
+
verbose: bool = typer.Option(
|
|
396
|
+
False, "--verbose", "-v", help="Show per-file progress"
|
|
397
|
+
),
|
|
398
|
+
no_summary: bool = typer.Option(
|
|
399
|
+
False, "--no-summary", help="Skip docstring extraction for faster builds"
|
|
400
|
+
),
|
|
278
401
|
):
|
|
279
402
|
"""Build the knowledge graph from source files.
|
|
280
403
|
|
|
281
|
-
Default: builds a separate graph per top-level directory.
|
|
404
|
+
Default: builds a separate graph per top-level directory in parallel.
|
|
282
405
|
Use --all to build one combined graph instead.
|
|
283
406
|
"""
|
|
284
407
|
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
@@ -297,9 +420,9 @@ def build(
|
|
|
297
420
|
|
|
298
421
|
if all_graph:
|
|
299
422
|
db_path = ctx_dir / "graph.db"
|
|
300
|
-
_build_single_graph(path, exts, user_patterns, db_path, "combined"
|
|
423
|
+
_build_single_graph(path, exts, user_patterns, db_path, "combined",
|
|
424
|
+
incremental, verbose, no_summary)
|
|
301
425
|
else:
|
|
302
|
-
# Per-directory: find top-level dirs (skip excluded ones)
|
|
303
426
|
from ctxgraph_code.exclude.patterns import should_exclude
|
|
304
427
|
top_dirs = sorted([
|
|
305
428
|
d for d in path.iterdir()
|
|
@@ -308,34 +431,18 @@ def build(
|
|
|
308
431
|
])
|
|
309
432
|
|
|
310
433
|
if not top_dirs:
|
|
311
|
-
console.print(
|
|
434
|
+
console.print(
|
|
435
|
+
"[yellow]No top-level source directories found."
|
|
436
|
+
" Building combined graph.[/yellow]"
|
|
437
|
+
)
|
|
312
438
|
db_path = ctx_dir / "graph.db"
|
|
313
|
-
_build_single_graph(path, exts, user_patterns, db_path, "combined"
|
|
439
|
+
_build_single_graph(path, exts, user_patterns, db_path, "combined",
|
|
440
|
+
incremental, verbose, no_summary)
|
|
314
441
|
else:
|
|
315
442
|
graphs_dir = ctx_dir / "graphs"
|
|
316
443
|
graphs_dir.mkdir(parents=True, exist_ok=True)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
total_edges = 0
|
|
320
|
-
for d in top_dirs:
|
|
321
|
-
db_path = graphs_dir / f"{d.name}.db"
|
|
322
|
-
s = _build_single_graph(path, exts, user_patterns, db_path, d.name)
|
|
323
|
-
total_nodes += s.get("total_nodes", 0)
|
|
324
|
-
total_edges += s.get("total_edges", 0)
|
|
325
|
-
|
|
326
|
-
summary = Table(title="Build Summary")
|
|
327
|
-
summary.add_column("Directory", style="cyan")
|
|
328
|
-
for d in top_dirs:
|
|
329
|
-
db = graphs_dir / f"{d.name}.db"
|
|
330
|
-
if db.exists():
|
|
331
|
-
from ctxgraph_code.graph.storage import Storage
|
|
332
|
-
st = Storage(db)
|
|
333
|
-
st.connect()
|
|
334
|
-
nd = st.stats()["nodes"]
|
|
335
|
-
ed = st.stats()["edges"]
|
|
336
|
-
summary.add_row(f"{d.name}/: {nd} nodes, {ed} edges")
|
|
337
|
-
console.print(summary)
|
|
338
|
-
console.print(f"\n[green]Built {len(top_dirs)} graphs in {graphs_dir}[/green]")
|
|
444
|
+
_build_dirs_parallel(path, exts, user_patterns, top_dirs, graphs_dir, jobs,
|
|
445
|
+
incremental, verbose, no_summary)
|
|
339
446
|
|
|
340
447
|
|
|
341
448
|
@app.command()
|
|
@@ -490,6 +597,21 @@ def setup(
|
|
|
490
597
|
False, "--project-slash",
|
|
491
598
|
help="Install slash command in project .claude/ instead of globally",
|
|
492
599
|
),
|
|
600
|
+
jobs: int = typer.Option(
|
|
601
|
+
0, "--jobs", "-j", help="Number of parallel workers (0 = auto, default: CPU count)"
|
|
602
|
+
),
|
|
603
|
+
incremental: bool = typer.Option(
|
|
604
|
+
False, "--incremental", "-i", help="Only rebuild files that changed since last build"
|
|
605
|
+
),
|
|
606
|
+
verbose: bool = typer.Option(
|
|
607
|
+
False, "--verbose", "-v", help="Show per-file progress"
|
|
608
|
+
),
|
|
609
|
+
no_summary: bool = typer.Option(
|
|
610
|
+
False, "--no-summary", help="Skip docstring extraction for faster builds"
|
|
611
|
+
),
|
|
612
|
+
background: bool = typer.Option(
|
|
613
|
+
False, "--background", "-b", help="Launch build in background and exit immediately"
|
|
614
|
+
),
|
|
493
615
|
):
|
|
494
616
|
"""Initialize config, build the graph, and install the Claude Code slash command.
|
|
495
617
|
|
|
@@ -529,7 +651,51 @@ def setup(
|
|
|
529
651
|
init_project(path, extensions=exts, exclude_patterns=excl)
|
|
530
652
|
console.print("[green][OK] Initialized .ctxgraph/[/green]")
|
|
531
653
|
|
|
532
|
-
#
|
|
654
|
+
# Install slash command FIRST (immediate)
|
|
655
|
+
if project_slash:
|
|
656
|
+
claude_dir = path / ".claude" / "commands"
|
|
657
|
+
else:
|
|
658
|
+
claude_dir = get_global_claude_commands_dir()
|
|
659
|
+
slash_path = claude_dir / "ctxgraph-code.md"
|
|
660
|
+
_write_slash_command(slash_path, path)
|
|
661
|
+
|
|
662
|
+
# Build in background via detached subprocess
|
|
663
|
+
if background:
|
|
664
|
+
import subprocess
|
|
665
|
+
import sys
|
|
666
|
+
|
|
667
|
+
build_args = [sys.executable, "-m", "ctxgraph_code", "build"]
|
|
668
|
+
if repo_path:
|
|
669
|
+
build_args.append(repo_path)
|
|
670
|
+
if extensions:
|
|
671
|
+
build_args.extend(["--extensions", extensions])
|
|
672
|
+
if exclude:
|
|
673
|
+
build_args.extend(["--exclude", exclude])
|
|
674
|
+
if jobs:
|
|
675
|
+
build_args.extend(["--jobs", str(jobs)])
|
|
676
|
+
if incremental:
|
|
677
|
+
build_args.append("--incremental")
|
|
678
|
+
if verbose:
|
|
679
|
+
build_args.append("--verbose")
|
|
680
|
+
if no_summary:
|
|
681
|
+
build_args.append("--no-summary")
|
|
682
|
+
|
|
683
|
+
subprocess.Popen(
|
|
684
|
+
build_args,
|
|
685
|
+
creationflags=subprocess.DETACHED_PROCESS,
|
|
686
|
+
stdout=subprocess.DEVNULL,
|
|
687
|
+
stderr=subprocess.DEVNULL,
|
|
688
|
+
)
|
|
689
|
+
console.print("[yellow]Build launched in background.[/yellow]")
|
|
690
|
+
console.print("Check status with: [bold]ctxgraph-code build-status[/bold]")
|
|
691
|
+
console.print()
|
|
692
|
+
console.print("[bold green]Setup complete! Slash command installed.[/bold green]")
|
|
693
|
+
console.print(
|
|
694
|
+
"Open any Claude Code project and type [bold]/ctxgraph-code[/bold] to get started."
|
|
695
|
+
)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
# Build (synchronous)
|
|
533
699
|
ctx_dir = path / ".ctxgraph"
|
|
534
700
|
graphs_dir = ctx_dir / "graphs"
|
|
535
701
|
graphs_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -539,22 +705,14 @@ def setup(
|
|
|
539
705
|
])
|
|
540
706
|
|
|
541
707
|
if top_dirs:
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
_build_single_graph(path, exts, excl, db_path, d.name)
|
|
545
|
-
console.print(f"[green][OK] Built graphs for {len(top_dirs)} directories[/green]")
|
|
708
|
+
_build_dirs_parallel(path, exts, excl, top_dirs, graphs_dir, jobs,
|
|
709
|
+
incremental, verbose, no_summary)
|
|
546
710
|
else:
|
|
547
711
|
db_path = ctx_dir / "graph.db"
|
|
548
|
-
_build_single_graph(path, exts, excl, db_path, "combined"
|
|
712
|
+
_build_single_graph(path, exts, excl, db_path, "combined",
|
|
713
|
+
incremental, verbose, no_summary)
|
|
549
714
|
console.print("[green][OK] Built combined graph[/green]")
|
|
550
715
|
|
|
551
|
-
if project_slash:
|
|
552
|
-
claude_dir = path / ".claude" / "commands"
|
|
553
|
-
else:
|
|
554
|
-
claude_dir = get_global_claude_commands_dir()
|
|
555
|
-
slash_path = claude_dir / "ctxgraph-code.md"
|
|
556
|
-
_write_slash_command(slash_path, path)
|
|
557
|
-
|
|
558
716
|
console.print()
|
|
559
717
|
console.print("[bold green]Setup complete![/bold green]")
|
|
560
718
|
console.print(
|
|
@@ -586,6 +744,30 @@ def install_slash(
|
|
|
586
744
|
_write_slash_command(slash_path, path)
|
|
587
745
|
|
|
588
746
|
|
|
747
|
+
@app.command(name="build-status")
|
|
748
|
+
def build_status(
|
|
749
|
+
repo_path: Optional[str] = typer.Option(
|
|
750
|
+
None, "--repo", "-r", help="Repository path"
|
|
751
|
+
),
|
|
752
|
+
):
|
|
753
|
+
"""Show the status of the last or current graph build."""
|
|
754
|
+
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
755
|
+
msg = get_status_message(path)
|
|
756
|
+
if msg:
|
|
757
|
+
console.print(msg)
|
|
758
|
+
else:
|
|
759
|
+
status = get_build_status(path)
|
|
760
|
+
if not status:
|
|
761
|
+
console.print("[yellow]No build history found.[/yellow]")
|
|
762
|
+
console.print("Run [bold]ctxgraph-code build[/bold] to start one.")
|
|
763
|
+
elif status["status"] == "complete":
|
|
764
|
+
dur = status.get("duration_s", "?")
|
|
765
|
+
console.print(f"[green]Last build completed[/green] (duration: {dur}s)")
|
|
766
|
+
elif status["status"] == "failed":
|
|
767
|
+
err = status.get("error", "unknown")
|
|
768
|
+
console.print(f"[red]Last build failed: {err}[/red]")
|
|
769
|
+
|
|
770
|
+
|
|
589
771
|
@app.command()
|
|
590
772
|
def view(
|
|
591
773
|
repo_path: Optional[str] = typer.Option(
|
|
@@ -684,7 +866,7 @@ def version():
|
|
|
684
866
|
try:
|
|
685
867
|
ver = _v("ctxgraph-code")
|
|
686
868
|
except Exception:
|
|
687
|
-
ver = "0.
|
|
869
|
+
ver = "0.3.0"
|
|
688
870
|
console.print(f"ctxgraph-code version [bold]{ver}[/bold]")
|
|
689
871
|
|
|
690
872
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
BUILD_STATUS_FILE = "build_status.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _status_path(repo_path: Path) -> Path:
|
|
14
|
+
return repo_path / ".ctxgraph" / BUILD_STATUS_FILE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mark_build_started(repo_path: Path, pid: int) -> dict:
|
|
18
|
+
data = {
|
|
19
|
+
"status": "in_progress",
|
|
20
|
+
"pid": pid,
|
|
21
|
+
"started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
22
|
+
}
|
|
23
|
+
p = _status_path(repo_path)
|
|
24
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
p.write_text(json.dumps(data), encoding="utf-8")
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def mark_build_complete(repo_path: Path, duration_s: float):
|
|
30
|
+
p = _status_path(repo_path)
|
|
31
|
+
data = {"status": "complete", "duration_s": round(duration_s, 1)}
|
|
32
|
+
p.write_text(json.dumps(data), encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mark_build_failed(repo_path: Path, error: str):
|
|
36
|
+
p = _status_path(repo_path)
|
|
37
|
+
data = {"status": "failed", "error": error}
|
|
38
|
+
p.write_text(json.dumps(data), encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_build_status(repo_path: Path) -> Optional[dict]:
|
|
42
|
+
p = _status_path(repo_path)
|
|
43
|
+
if not p.exists():
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
47
|
+
return data
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_pid_running(pid: int) -> bool:
|
|
53
|
+
"""Check if a process with the given PID is still running (Windows)."""
|
|
54
|
+
try:
|
|
55
|
+
import ctypes
|
|
56
|
+
kernel32 = ctypes.windll.kernel32
|
|
57
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
58
|
+
if not handle:
|
|
59
|
+
return False
|
|
60
|
+
kernel32.CloseHandle(handle)
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_status_message(repo_path: Path) -> Optional[str]:
|
|
67
|
+
status = get_build_status(repo_path)
|
|
68
|
+
if not status:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if status["status"] == "in_progress":
|
|
72
|
+
pid = status.get("pid", 0)
|
|
73
|
+
if pid and not check_pid_running(pid):
|
|
74
|
+
mark_build_failed(repo_path, "process exited unexpectedly")
|
|
75
|
+
return (
|
|
76
|
+
"[red]Previous build appears to have crashed. "
|
|
77
|
+
"Run [bold]ctxgraph-code build[/bold] to retry.[/red]"
|
|
78
|
+
)
|
|
79
|
+
started = status.get("started_at", "unknown")
|
|
80
|
+
return (
|
|
81
|
+
f"[yellow]Graph build in progress (PID {pid}, started {started})."
|
|
82
|
+
f" Results may be partial. Check [bold]ctxgraph-code info[/bold]"
|
|
83
|
+
f" for status.[/yellow]"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if status["status"] == "failed":
|
|
87
|
+
err = status.get("error", "unknown error")
|
|
88
|
+
return (
|
|
89
|
+
f"[red]Previous build failed: {err}."
|
|
90
|
+
f" Run [bold]ctxgraph-code build[/bold] to retry.[/red]"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return None
|
ctxgraph_code/graph/builder.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import time
|
|
7
|
+
from functools import lru_cache
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Optional
|
|
8
10
|
|
|
@@ -13,6 +15,28 @@ from ctxgraph_code.exclude.patterns import should_exclude
|
|
|
13
15
|
from ctxgraph_code.graph.models import Graph
|
|
14
16
|
from ctxgraph_code.graph.storage import Storage
|
|
15
17
|
|
|
18
|
+
MTIMES_FILE = "file_mtimes.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _mtimes_path(repo_path: Path) -> Path:
|
|
22
|
+
return repo_path / ".ctxgraph" / MTIMES_FILE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_mtimes(repo_path: Path) -> dict[str, float]:
|
|
26
|
+
p = _mtimes_path(repo_path)
|
|
27
|
+
if not p.exists():
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _save_mtimes(repo_path: Path, mtimes: dict[str, float]):
|
|
36
|
+
p = _mtimes_path(repo_path)
|
|
37
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
p.write_text(json.dumps(mtimes, indent=1), encoding="utf-8")
|
|
39
|
+
|
|
16
40
|
|
|
17
41
|
def build_graph(
|
|
18
42
|
repo_path: str | Path,
|
|
@@ -20,6 +44,9 @@ def build_graph(
|
|
|
20
44
|
exclude_patterns: Optional[list[str]] = None,
|
|
21
45
|
extensions: Optional[list[str]] = None,
|
|
22
46
|
jobs: int = 0,
|
|
47
|
+
incremental: bool = False,
|
|
48
|
+
verbose: bool = False,
|
|
49
|
+
no_summary: bool = False,
|
|
23
50
|
) -> dict:
|
|
24
51
|
repo_path = Path(repo_path).resolve()
|
|
25
52
|
if db_path is None:
|
|
@@ -31,23 +58,32 @@ def build_graph(
|
|
|
31
58
|
if jobs <= 0:
|
|
32
59
|
jobs = (os.cpu_count() or 1)
|
|
33
60
|
|
|
34
|
-
|
|
61
|
+
@lru_cache(maxsize=None)
|
|
62
|
+
def _should_exclude(path: Path) -> bool:
|
|
63
|
+
return should_exclude(path, repo_path, exclude_patterns)
|
|
64
|
+
|
|
65
|
+
# ── walk files + collect mtimes ────────────────────────────────────────
|
|
35
66
|
path_index: set[str] = set()
|
|
36
67
|
scan_files: list[Path] = []
|
|
68
|
+
current_mtimes: dict[str, float] = {}
|
|
37
69
|
|
|
38
70
|
for dirpath, dirnames, filenames in os.walk(repo_path):
|
|
39
|
-
rel = os.path.relpath(dirpath, repo_path).replace("\\", "/")
|
|
40
71
|
dirnames[:] = [
|
|
41
72
|
d for d in dirnames
|
|
42
|
-
if not
|
|
73
|
+
if not _should_exclude(repo_path / d)
|
|
43
74
|
]
|
|
44
75
|
for fn in filenames:
|
|
76
|
+
if Path(fn).suffix not in exts:
|
|
77
|
+
continue
|
|
45
78
|
fp = Path(dirpath) / fn
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
rp = str(fp.relative_to(repo_path)).replace("\\", "/")
|
|
80
|
+
if not _should_exclude(fp):
|
|
81
|
+
path_index.add(rp)
|
|
82
|
+
scan_files.append(fp)
|
|
83
|
+
try:
|
|
84
|
+
current_mtimes[rp] = os.path.getmtime(fp)
|
|
85
|
+
except OSError:
|
|
86
|
+
current_mtimes[rp] = 0.0
|
|
51
87
|
|
|
52
88
|
if not scan_files:
|
|
53
89
|
elapsed = time.time() - start
|
|
@@ -57,69 +93,160 @@ def build_graph(
|
|
|
57
93
|
"total_nodes": 0, "total_edges": 0,
|
|
58
94
|
}
|
|
59
95
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
# ── incremental: filter to changed files only ─────────────────────────
|
|
97
|
+
changed_files: list[Path] = list(scan_files)
|
|
98
|
+
old_mtimes: dict[str, float] = {}
|
|
99
|
+
|
|
100
|
+
if incremental:
|
|
101
|
+
old_mtimes = _load_mtimes(repo_path)
|
|
102
|
+
filtered: list[Path] = []
|
|
103
|
+
for fp in scan_files:
|
|
104
|
+
rp = str(fp.relative_to(repo_path)).replace("\\", "/")
|
|
105
|
+
old_mtime = old_mtimes.get(rp)
|
|
106
|
+
current_mtime = current_mtimes.get(rp, 0)
|
|
107
|
+
if old_mtime is None or old_mtime != current_mtime:
|
|
108
|
+
filtered.append(fp)
|
|
109
|
+
changed_files = filtered
|
|
110
|
+
|
|
111
|
+
removed_paths = old_mtimes.keys() - current_mtimes.keys()
|
|
112
|
+
|
|
113
|
+
if not changed_files:
|
|
114
|
+
elapsed = time.time() - start
|
|
115
|
+
return {
|
|
116
|
+
"files_analyzed": 0, "files_skipped": len(scan_files), "errors": 0,
|
|
117
|
+
"elapsed_seconds": round(elapsed, 2),
|
|
118
|
+
"total_nodes": 0, "total_edges": 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
stats: dict = {"files_analyzed": 0, "errors": 0}
|
|
63
122
|
all_nodes: list[dict] = []
|
|
64
123
|
all_edges: list[dict] = []
|
|
65
124
|
|
|
125
|
+
n_total = len(changed_files)
|
|
126
|
+
use_mp = jobs > 1 and n_total > 10
|
|
127
|
+
|
|
128
|
+
if verbose:
|
|
129
|
+
from rich.console import Console
|
|
130
|
+
vconsole = Console()
|
|
131
|
+
vconsole.log(f"Processing {n_total} files ({len(scan_files)} total) with {jobs} workers...")
|
|
132
|
+
|
|
133
|
+
# ── process files ─────────────────────────────────────────────────────
|
|
66
134
|
if use_mp:
|
|
67
135
|
import multiprocessing as mp
|
|
68
|
-
chunk_size = max(1,
|
|
69
|
-
chunks = [
|
|
136
|
+
chunk_size = max(1, n_total // jobs)
|
|
137
|
+
chunks = [changed_files[i:i + chunk_size] for i in range(0, n_total, chunk_size)]
|
|
70
138
|
|
|
71
139
|
with mp.Pool(jobs) as pool:
|
|
72
140
|
results = [
|
|
73
|
-
pool.apply_async(_worker_batch, (chunk, repo_path, path_index))
|
|
141
|
+
pool.apply_async(_worker_batch, (chunk, repo_path, path_index, no_summary))
|
|
74
142
|
for chunk in chunks
|
|
75
143
|
]
|
|
76
|
-
for r in results:
|
|
144
|
+
for i, r in enumerate(results):
|
|
77
145
|
try:
|
|
78
146
|
nds, eds, ok, err = r.get(timeout=600)
|
|
79
147
|
all_nodes.extend(nds)
|
|
80
148
|
all_edges.extend(eds)
|
|
81
149
|
stats["files_analyzed"] += ok
|
|
82
150
|
stats["errors"] += err
|
|
151
|
+
if verbose:
|
|
152
|
+
done = min((i + 1) * chunk_size, n_total)
|
|
153
|
+
vconsole.log(f" [{done}/{n_total}] files done...")
|
|
83
154
|
except Exception:
|
|
84
|
-
|
|
155
|
+
stats["errors"] += chunk_size
|
|
85
156
|
else:
|
|
86
|
-
for file_path in
|
|
157
|
+
for i, file_path in enumerate(changed_files):
|
|
87
158
|
try:
|
|
88
|
-
nds, eds = _process_file(file_path, repo_path, path_index)
|
|
159
|
+
nds, eds = _process_file(file_path, repo_path, path_index, no_summary)
|
|
89
160
|
all_nodes.extend(nds)
|
|
90
161
|
all_edges.extend(eds)
|
|
91
162
|
stats["files_analyzed"] += 1
|
|
92
163
|
except Exception:
|
|
93
164
|
stats["errors"] += 1
|
|
165
|
+
if verbose and (i + 1) % 50 == 0:
|
|
166
|
+
from rich.console import Console
|
|
167
|
+
vconsole = Console()
|
|
168
|
+
vconsole.log(f" [{i + 1}/{n_total}] files done...")
|
|
94
169
|
|
|
170
|
+
# ── save to DB ────────────────────────────────────────────────────────
|
|
95
171
|
graph = Graph.from_batch(all_nodes, all_edges)
|
|
96
|
-
|
|
97
172
|
storage = Storage(db_path)
|
|
98
173
|
storage.connect()
|
|
174
|
+
|
|
175
|
+
if incremental:
|
|
176
|
+
for fp in changed_files:
|
|
177
|
+
rp = str(fp.relative_to(repo_path)).replace("\\", "/")
|
|
178
|
+
storage.delete_nodes_for_file(rp)
|
|
179
|
+
for rp in removed_paths:
|
|
180
|
+
storage.delete_nodes_for_file(rp)
|
|
181
|
+
|
|
99
182
|
storage.save_graph(graph)
|
|
100
183
|
storage.save_metadata("build_time", str(time.time()))
|
|
101
184
|
storage.save_metadata("repo_path", str(repo_path))
|
|
102
185
|
storage.save_metadata("file_count", str(stats["files_analyzed"]))
|
|
103
186
|
storage.close()
|
|
104
187
|
|
|
188
|
+
# ── save mtimes for incremental ───────────────────────────────────────
|
|
189
|
+
if incremental:
|
|
190
|
+
_save_mtimes(repo_path, current_mtimes)
|
|
191
|
+
|
|
105
192
|
elapsed = time.time() - start
|
|
106
193
|
stats["elapsed_seconds"] = round(elapsed, 2)
|
|
107
194
|
stats["total_nodes"] = len(graph.nodes)
|
|
108
195
|
stats["total_edges"] = len(graph.edges)
|
|
109
196
|
|
|
197
|
+
if verbose:
|
|
198
|
+
from rich.console import Console
|
|
199
|
+
vconsole = Console()
|
|
200
|
+
vconsole.log(
|
|
201
|
+
f"Done: {stats['files_analyzed']} files, {stats['total_nodes']} nodes, "
|
|
202
|
+
f"{stats['total_edges']} edges in {elapsed:.1f}s"
|
|
203
|
+
)
|
|
204
|
+
|
|
110
205
|
return stats
|
|
111
206
|
|
|
112
207
|
|
|
113
208
|
# ── worker helpers ───────────────────────────────────────────────────────────
|
|
114
209
|
|
|
115
210
|
|
|
211
|
+
def _quick_scan(source: str) -> bool:
|
|
212
|
+
"""Quick pre-check: does this file have imports, classes, functions,
|
|
213
|
+
or decorators? Returns True if full AST parsing is needed."""
|
|
214
|
+
for line in source.splitlines():
|
|
215
|
+
line = line.strip()
|
|
216
|
+
if not line or line.startswith("#"):
|
|
217
|
+
continue
|
|
218
|
+
if any(line.startswith(kw) for kw in ("import ", "from ", "class ", "def ", "@")):
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
116
223
|
def _process_file(
|
|
117
224
|
file_path: Path,
|
|
118
225
|
root_path: Path,
|
|
119
226
|
path_index: set[str],
|
|
227
|
+
no_summary: bool = False,
|
|
120
228
|
) -> tuple[list[dict], list[dict]]:
|
|
229
|
+
rel = str(file_path.relative_to(root_path)).replace("\\", "/")
|
|
230
|
+
|
|
121
231
|
try:
|
|
122
232
|
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
233
|
+
except OSError:
|
|
234
|
+
return [], []
|
|
235
|
+
|
|
236
|
+
if not _quick_scan(source):
|
|
237
|
+
return [{
|
|
238
|
+
"id": f"{root_path}:{rel}",
|
|
239
|
+
"type": "file",
|
|
240
|
+
"name": file_path.name,
|
|
241
|
+
"path": rel,
|
|
242
|
+
"parent_id": None,
|
|
243
|
+
"summary": None,
|
|
244
|
+
"importance": 0.5,
|
|
245
|
+
"size_bytes": len(source),
|
|
246
|
+
"lineno": 0,
|
|
247
|
+
}], []
|
|
248
|
+
|
|
249
|
+
try:
|
|
123
250
|
tree = ast.parse(source)
|
|
124
251
|
except SyntaxError:
|
|
125
252
|
return [], []
|
|
@@ -139,19 +266,20 @@ def _process_file(
|
|
|
139
266
|
for ed in sg.edges:
|
|
140
267
|
edges.append(ed.to_dict())
|
|
141
268
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
269
|
+
if not no_summary:
|
|
270
|
+
for nd in sg.nodes.values():
|
|
271
|
+
if nd.summary is None:
|
|
272
|
+
enriched = enrich_node_summary(nd, file_path, source=source, tree=tree)
|
|
273
|
+
if enriched:
|
|
274
|
+
nd.summary = enriched
|
|
275
|
+
nodes.append(nd.to_dict())
|
|
276
|
+
|
|
277
|
+
file_node = next((nd for nd in ig.nodes.values() if nd.type == "file"), None)
|
|
278
|
+
if file_node and file_node.summary is None:
|
|
279
|
+
enriched = enrich_node_summary(file_node, file_path, source=source, tree=tree)
|
|
145
280
|
if enriched:
|
|
146
|
-
|
|
147
|
-
nodes.append(
|
|
148
|
-
|
|
149
|
-
file_node = next((nd for nd in ig.nodes.values() if nd.type == "file"), None)
|
|
150
|
-
if file_node and file_node.summary is None:
|
|
151
|
-
enriched = enrich_node_summary(file_node, file_path, source=source, tree=tree)
|
|
152
|
-
if enriched:
|
|
153
|
-
file_node.summary = enriched
|
|
154
|
-
nodes.append(file_node.to_dict())
|
|
281
|
+
file_node.summary = enriched
|
|
282
|
+
nodes.append(file_node.to_dict())
|
|
155
283
|
|
|
156
284
|
return nodes, edges
|
|
157
285
|
|
|
@@ -160,6 +288,7 @@ def _worker_batch(
|
|
|
160
288
|
file_paths: list[Path],
|
|
161
289
|
root_path: Path,
|
|
162
290
|
path_index: set[str],
|
|
291
|
+
no_summary: bool = False,
|
|
163
292
|
) -> tuple[list[dict], list[dict], int, int]:
|
|
164
293
|
nodes: list[dict] = []
|
|
165
294
|
edges: list[dict] = []
|
|
@@ -167,7 +296,7 @@ def _worker_batch(
|
|
|
167
296
|
err = 0
|
|
168
297
|
for fp in file_paths:
|
|
169
298
|
try:
|
|
170
|
-
nds, eds = _process_file(fp, root_path, path_index)
|
|
299
|
+
nds, eds = _process_file(fp, root_path, path_index, no_summary)
|
|
171
300
|
nodes.extend(nds)
|
|
172
301
|
edges.extend(eds)
|
|
173
302
|
ok += 1
|
ctxgraph_code/graph/storage.py
CHANGED
|
@@ -75,37 +75,31 @@ class Storage:
|
|
|
75
75
|
)
|
|
76
76
|
self.conn.commit()
|
|
77
77
|
|
|
78
|
-
def save_node(self, node: Node):
|
|
79
|
-
self.conn.execute(
|
|
80
|
-
"""INSERT OR REPLACE INTO nodes
|
|
81
|
-
(id, type, name, path, parent_id, summary, importance, size_bytes, lineno)
|
|
82
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
83
|
-
(
|
|
84
|
-
node.id,
|
|
85
|
-
node.type,
|
|
86
|
-
node.name,
|
|
87
|
-
node.path,
|
|
88
|
-
node.parent_id,
|
|
89
|
-
node.summary,
|
|
90
|
-
node.importance,
|
|
91
|
-
node.size_bytes,
|
|
92
|
-
node.lineno,
|
|
93
|
-
),
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
def save_edge(self, edge: Edge):
|
|
97
|
-
self.conn.execute(
|
|
98
|
-
"""INSERT OR REPLACE INTO edges
|
|
99
|
-
(source_id, target_id, relation, weight)
|
|
100
|
-
VALUES (?, ?, ?, ?)""",
|
|
101
|
-
(edge.source_id, edge.target_id, edge.relation, edge.weight),
|
|
102
|
-
)
|
|
103
|
-
|
|
104
78
|
def save_graph(self, graph: Graph):
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
79
|
+
nodes_data = [
|
|
80
|
+
(n.id, n.type, n.name, n.path, n.parent_id, n.summary,
|
|
81
|
+
n.importance, n.size_bytes, n.lineno)
|
|
82
|
+
for n in graph.nodes.values()
|
|
83
|
+
]
|
|
84
|
+
if nodes_data:
|
|
85
|
+
self.conn.executemany(
|
|
86
|
+
"""INSERT OR REPLACE INTO nodes
|
|
87
|
+
(id, type, name, path, parent_id, summary, importance, size_bytes, lineno)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
89
|
+
nodes_data,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
edges_data = [
|
|
93
|
+
(e.source_id, e.target_id, e.relation, e.weight)
|
|
94
|
+
for e in graph.edges
|
|
95
|
+
]
|
|
96
|
+
if edges_data:
|
|
97
|
+
self.conn.executemany(
|
|
98
|
+
"""INSERT OR REPLACE INTO edges
|
|
99
|
+
(source_id, target_id, relation, weight)
|
|
100
|
+
VALUES (?, ?, ?, ?)""",
|
|
101
|
+
edges_data,
|
|
102
|
+
)
|
|
109
103
|
self.conn.commit()
|
|
110
104
|
|
|
111
105
|
def get_node(self, node_id: str) -> Optional[Node]:
|
|
@@ -210,6 +204,26 @@ class Storage:
|
|
|
210
204
|
"types": {r["type"]: r["cnt"] for r in type_counts},
|
|
211
205
|
}
|
|
212
206
|
|
|
207
|
+
def delete_nodes_for_file(self, file_path: str):
|
|
208
|
+
"""Remove all nodes and edges belonging to a file (for incremental rebuild)."""
|
|
209
|
+
cursor = self.conn.execute(
|
|
210
|
+
"SELECT id FROM nodes WHERE path = ?", (file_path,)
|
|
211
|
+
)
|
|
212
|
+
node_ids = [r[0] for r in cursor.fetchall()]
|
|
213
|
+
if not node_ids:
|
|
214
|
+
return
|
|
215
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
216
|
+
self.conn.execute(
|
|
217
|
+
"DELETE FROM edges WHERE source_id IN ({}) OR target_id IN ({})".format(
|
|
218
|
+
placeholders, placeholders
|
|
219
|
+
),
|
|
220
|
+
node_ids + node_ids,
|
|
221
|
+
)
|
|
222
|
+
self.conn.execute(
|
|
223
|
+
f"DELETE FROM nodes WHERE id IN ({placeholders})", node_ids
|
|
224
|
+
)
|
|
225
|
+
self.conn.commit()
|
|
226
|
+
|
|
213
227
|
def save_metadata(self, key: str, value: str):
|
|
214
228
|
self.conn.execute(
|
|
215
229
|
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctxgraph-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions.
|
|
5
5
|
Author: ctxgraph-code contributors
|
|
6
6
|
License: MIT
|
|
@@ -76,15 +76,23 @@ Interactive walkthrough — prompts for:
|
|
|
76
76
|
|
|
77
77
|
Does everything in one step:
|
|
78
78
|
1. Creates `.ctxgraph/config.toml` with your chosen extensions and excludes
|
|
79
|
-
2.
|
|
80
|
-
3.
|
|
79
|
+
2. Installs the `/ctxgraph-code` slash command globally (works in every Claude Code session)
|
|
80
|
+
3. Builds the knowledge graph from all matching files
|
|
81
81
|
|
|
82
|
-
Non-interactive mode
|
|
82
|
+
Non-interactive mode:
|
|
83
83
|
```bash
|
|
84
84
|
ctxgraph-code setup --extensions .py,.js,.ts --exclude tests/,examples/
|
|
85
85
|
ctxgraph-code setup -y # all defaults
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
Options:
|
|
89
|
+
- `--project-slash` — install slash command in project's `.claude/` instead of globally
|
|
90
|
+
- `--background` / `-b` — launch build in background and exit immediately (check with `build-status`)
|
|
91
|
+
- `--jobs` / `-j` — number of parallel workers (default: CPU count)
|
|
92
|
+
- `--incremental` / `-i` — only rebuild files that changed since last build
|
|
93
|
+
- `--verbose` / `-v` — show per-file progress
|
|
94
|
+
- `--no-summary` — skip docstring extraction for faster builds
|
|
95
|
+
|
|
88
96
|
### `init`
|
|
89
97
|
|
|
90
98
|
```bash
|
|
@@ -103,24 +111,27 @@ ctxgraph-code build --exclude tests/ --exclude *.generated.py
|
|
|
103
111
|
|
|
104
112
|
Scans all matching files in the project, runs AST analysis. Extensions are read from config (`.py` by default, or whatever was set in `setup`).
|
|
105
113
|
|
|
106
|
-
- **
|
|
107
|
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
114
|
+
**By default, builds a separate graph per top-level directory** (e.g., `src/`, `api/`, `tests/`) in parallel. This keeps each graph small and fast to query. Use `--dir <name>` on query commands to select one, or let it auto-detect from file paths.
|
|
115
|
+
|
|
116
|
+
- `--all` / `-a` — build a single combined graph instead of per-directory
|
|
117
|
+
- `--jobs` / `-j` — number of parallel workers (default: CPU count)
|
|
118
|
+
- `--incremental` / `-i` — only rebuild files that changed since last build
|
|
119
|
+
- `--verbose` / `-v` — show per-file progress
|
|
120
|
+
- `--no-summary` — skip docstring extraction for faster builds
|
|
111
121
|
|
|
112
|
-
Stores
|
|
122
|
+
Stores graphs in `.ctxgraph/graphs/<dir>.db` (per-directory) or `.ctxgraph/graph.db` (combined).
|
|
113
123
|
|
|
114
|
-
> The graph is a **static snapshot**. If code changes, run `ctxgraph-code build` again to refresh.
|
|
124
|
+
> The graph is a **static snapshot**. If code changes, run `ctxgraph-code build` again to refresh. Use `--incremental` to only reprocess changed files.
|
|
115
125
|
|
|
116
126
|
### `query`
|
|
117
127
|
|
|
118
128
|
```bash
|
|
119
129
|
ctxgraph-code query "user authentication"
|
|
120
130
|
ctxgraph-code query "database connection" --max 20
|
|
131
|
+
ctxgraph-code query "api routes" --dir src
|
|
121
132
|
```
|
|
122
133
|
|
|
123
|
-
Searches the graph by relevance scoring (name matches > summary matches > path matches) and expands to neighboring nodes via BFS up to depth 2.
|
|
134
|
+
Searches the graph by relevance scoring (name matches > summary matches > path matches) and expands to neighboring nodes via BFS up to depth 2. Use `--dir` to scope to a specific per-directory graph.
|
|
124
135
|
|
|
125
136
|
### `deps`
|
|
126
137
|
|
|
@@ -128,7 +139,7 @@ Searches the graph by relevance scoring (name matches > summary matches > path m
|
|
|
128
139
|
ctxgraph-code deps src/api/routes.py
|
|
129
140
|
```
|
|
130
141
|
|
|
131
|
-
Shows all relationships for a file: imports, imported-by, function calls, class definitions.
|
|
142
|
+
Shows all relationships for a file: imports, imported-by, function calls, class definitions. Auto-detects the per-directory graph from the file path.
|
|
132
143
|
|
|
133
144
|
### `usedby`
|
|
134
145
|
|
|
@@ -142,9 +153,10 @@ Shows every file that imports or calls something in the given file. Useful to un
|
|
|
142
153
|
|
|
143
154
|
```bash
|
|
144
155
|
ctxgraph-code overview
|
|
156
|
+
ctxgraph-code overview --dir src
|
|
145
157
|
```
|
|
146
158
|
|
|
147
|
-
Prints the project structure: every file with its summary and top-level symbols.
|
|
159
|
+
Prints the project structure: every file with its summary and top-level symbols. Use `--dir` to scope to a per-directory graph.
|
|
148
160
|
|
|
149
161
|
### `symbols`
|
|
150
162
|
|
|
@@ -181,10 +193,28 @@ Use `--tree` for a terminal-friendly text view of the directory hierarchy with s
|
|
|
181
193
|
|
|
182
194
|
```bash
|
|
183
195
|
ctxgraph-code info
|
|
196
|
+
ctxgraph-code info --dir src
|
|
184
197
|
```
|
|
185
198
|
|
|
186
199
|
Shows graph statistics: node/edge counts, type distribution, build time.
|
|
187
200
|
|
|
201
|
+
### `install-slash`
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
ctxgraph-code install-slash
|
|
205
|
+
ctxgraph-code install-slash --project-slash # project-local instead of global
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Install or update the `/ctxgraph-code` slash command for Claude Code. By default installs globally so it works in every Claude Code session. Use `--project-slash` to install in the project's `.claude/commands/` directory instead.
|
|
209
|
+
|
|
210
|
+
### `build-status`
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
ctxgraph-code build-status
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Check whether a background build (`ctxgraph-code setup --background` or `ctxgraph-code build`) completed, failed, or is still running. Shows PID and start time for in-progress builds.
|
|
217
|
+
|
|
188
218
|
---
|
|
189
219
|
|
|
190
220
|
## How It Works
|
|
@@ -192,7 +222,7 @@ Shows graph statistics: node/edge counts, type distribution, build time.
|
|
|
192
222
|
```
|
|
193
223
|
Python files ──AST──> Import/Symbol/Call analysis ──> SQLite graph.db
|
|
194
224
|
│
|
|
195
|
-
Claude Code ──/ctxgraph-code──> CLI query/deps/overview
|
|
225
|
+
Claude Code ──/ctxgraph-code──> CLI query/deps/overview <───┘
|
|
196
226
|
```
|
|
197
227
|
|
|
198
228
|
1. **Build phase**: `ctxgraph-code build` parses every `.py` file with Python's `ast` module. It extracts imports, class/function definitions, function calls, and docstrings. The result is a graph of **nodes** (files, classes, functions) and **edges** (imports, defines, extends, calls) stored in SQLite.
|
|
@@ -226,25 +256,43 @@ Edge weights: `imports=1.0`, `defines=1.0`, `extends=0.8`, `calls=0.7`
|
|
|
226
256
|
|
|
227
257
|
---
|
|
228
258
|
|
|
229
|
-
##
|
|
259
|
+
## Performance
|
|
230
260
|
|
|
231
|
-
|
|
261
|
+
`ctxgraph-code` includes several optimizations for large codebases:
|
|
232
262
|
|
|
233
|
-
|
|
263
|
+
| Optimization | Details |
|
|
264
|
+
|---|---|
|
|
265
|
+
| **Parallel builds** | Per-directory graphs build concurrently via `ThreadPoolExecutor` |
|
|
266
|
+
| **Multiprocessing** | Combined graphs split files across CPU cores via `multiprocessing.Pool` |
|
|
267
|
+
| **`--jobs`** | Control parallelism level (default: CPU count) |
|
|
268
|
+
| **Incremental builds** | `--incremental` caches file mtimes, only reprocesses changed files |
|
|
269
|
+
| **Trivial file skip** | `_quick_scan()` pre-checks before AST parse for files with no imports/classes/functions |
|
|
270
|
+
| **Cached excludes** | `lru_cache` on `should_exclude()` during `os.walk` |
|
|
271
|
+
| **Batch SQLite inserts** | `executemany` instead of per-row `INSERT` statements |
|
|
272
|
+
| **`--no-summary`** | Skips docstring extraction (fastest rebuilds) |
|
|
273
|
+
| **`--background`** | Detach build process and continue working immediately |
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Using with Claude Code
|
|
278
|
+
|
|
279
|
+
After `ctxgraph-code setup`, the `/ctxgraph-code` slash command is installed globally by default — it works in every Claude Code session. Claude sees:
|
|
234
280
|
|
|
235
281
|
```
|
|
236
282
|
# ctxgraph-code: Code Relationship Graph
|
|
237
283
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
- ctxgraph-code
|
|
244
|
-
- ctxgraph-code
|
|
245
|
-
- ctxgraph-code
|
|
246
|
-
- ctxgraph-code
|
|
247
|
-
- ctxgraph-code
|
|
284
|
+
**First time in this project?** Tell the user to run `ctxgraph-code setup`.
|
|
285
|
+
**Graph needs refresh?** Tell the user to run `ctxgraph-code build`.
|
|
286
|
+
**Available graphs:** src api tests
|
|
287
|
+
|
|
288
|
+
**Commands:**
|
|
289
|
+
- `ctxgraph-code query "terms"` -- Find relevant files, classes, and functions
|
|
290
|
+
- `ctxgraph-code deps <path>` -- Show what a file imports and what calls it
|
|
291
|
+
- `ctxgraph-code usedby <path>` -- Show what depends on a file
|
|
292
|
+
- `ctxgraph-code overview --dir <name>` -- Show project structure for a specific graph
|
|
293
|
+
- `ctxgraph-code symbols <path>` -- List classes/functions defined in a file
|
|
294
|
+
- `ctxgraph-code context "task"` -- Generate a focused context summary
|
|
295
|
+
- `ctxgraph-code view --dir <name>` -- Visualize a graph interactively
|
|
248
296
|
```
|
|
249
297
|
|
|
250
298
|
Claude then uses these commands as needed during the conversation.
|
|
@@ -287,7 +335,7 @@ Built-in default exclusion patterns (always applied): `__pycache__`, `*.pyc`, `.
|
|
|
287
335
|
|
|
288
336
|
| Feature | ctxgraph | ctxgraph-code |
|
|
289
337
|
|---------|----------|---------------|
|
|
290
|
-
| CLI commands | 9
|
|
338
|
+
| CLI commands | 9+ | 12 (init, build, query, deps, usedby, overview, symbols, context, setup, view, info, install-slash, build-status) |
|
|
291
339
|
| LLM integration | Built-in (Ollama, Claude, OpenAI, Azure) | None (delegates to Claude Code) |
|
|
292
340
|
| Chat sessions | Yes | No |
|
|
293
341
|
| Visualizer | D3.js HTML + SVG | D3.js HTML (`view` opens in browser, `--tree` for text) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
ctxgraph_code/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
ctxgraph_code/__main__.py,sha256=AfV3Onj_9FoL9W8B5jOWOeIyYF2MwX6S4-wyF9U90wM,41
|
|
3
|
-
ctxgraph_code/cli.py,sha256=
|
|
3
|
+
ctxgraph_code/cli.py,sha256=5ShvEYTi5JzN01_bPLhsds0oZfAnbQOOiAlyLz4OgxI,30322
|
|
4
4
|
ctxgraph_code/render.py,sha256=zic5X5gKfuqZSL0sAxsAOCkZcHnac0eta2FXO_DNaHQ,12619
|
|
5
5
|
ctxgraph_code/analyzers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
ctxgraph_code/analyzers/python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -8,20 +8,21 @@ ctxgraph_code/analyzers/python/importer.py,sha256=_5WV-RHK9IzbjCaVi_-1rWCYSuJIu0
|
|
|
8
8
|
ctxgraph_code/analyzers/python/semantic.py,sha256=gS_y2J6uZ5DoFdEz6fF1lp2cBte7hKOIBz2nrrA5db4,2489
|
|
9
9
|
ctxgraph_code/analyzers/python/symbols.py,sha256=xKtpn-78sh8H_xf7TBs9bzIAO42NnuiY1X92d11nNJ4,6330
|
|
10
10
|
ctxgraph_code/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
ctxgraph_code/config/build_status.py,sha256=e2tdNc34oJd-iDAi4aFEi-TkY9lIhkicCjmSj6VpvHY,2754
|
|
11
12
|
ctxgraph_code/config/global_paths.py,sha256=Ot1YIzdTqOValsvN_oEstcg8dFcFGCtHo6OnwOp1IYE,511
|
|
12
13
|
ctxgraph_code/config/init.py,sha256=tXVcDTPAJGVbzPavABRfnCXUsf9Pt-VzDUgV5YX09Tc,501
|
|
13
14
|
ctxgraph_code/config/settings.py,sha256=t0Mu0U9T43ztDxxqj2NXGB3BbaY7Ll7e8UMhTLlY-Xw,4873
|
|
14
15
|
ctxgraph_code/exclude/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
16
|
ctxgraph_code/exclude/patterns.py,sha256=4jBbiVeMgMmK5c0Kucl5g2xiBtAQ1tVtH8dDXA7lz7g,1624
|
|
16
17
|
ctxgraph_code/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
ctxgraph_code/graph/builder.py,sha256=
|
|
18
|
+
ctxgraph_code/graph/builder.py,sha256=XgPfR7ssMaehS3-LwQ4ZGC5MpVJR4N-LPMPfPIyBh8c,11650
|
|
18
19
|
ctxgraph_code/graph/models.py,sha256=0h3qVDHNUvE8zqBBSaTFlY_mBS1A-WrEW66nPU5oouA,2860
|
|
19
20
|
ctxgraph_code/graph/query.py,sha256=qwGIdmTZ7-csL_FMkIdABfwpUCQ1ph9TbDeE3eY_YSU,3414
|
|
20
|
-
ctxgraph_code/graph/storage.py,sha256=
|
|
21
|
+
ctxgraph_code/graph/storage.py,sha256=9qy1OEjLozFZSaxaecrQKHNhyAJxIXYIhWxeagHyWDE,7648
|
|
21
22
|
ctxgraph_code/view/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
23
|
ctxgraph_code/view/visualizer.py,sha256=xvPn9eSDYAk_HICpL6Nt7CQ_5FOKX0EXpZGM6wwyv9s,10555
|
|
23
|
-
ctxgraph_code-0.
|
|
24
|
-
ctxgraph_code-0.
|
|
25
|
-
ctxgraph_code-0.
|
|
26
|
-
ctxgraph_code-0.
|
|
27
|
-
ctxgraph_code-0.
|
|
24
|
+
ctxgraph_code-0.3.0.dist-info/METADATA,sha256=MJ3lilCdCVHMn2aVtjVlkQYIf8okvXegFvE1LX3aRe4,13816
|
|
25
|
+
ctxgraph_code-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
26
|
+
ctxgraph_code-0.3.0.dist-info/entry_points.txt,sha256=Q6poS8LApu1uGl_T9hjoZ7VMC8-iXZFCMslDx6OjlRo,56
|
|
27
|
+
ctxgraph_code-0.3.0.dist-info/top_level.txt,sha256=ZOmow7lPtFHkcRJhLFOP5DVbt_SVaFrzsgXsawzMsq8,14
|
|
28
|
+
ctxgraph_code-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|