pycode-kg 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
@@ -0,0 +1,96 @@
1
+ """
2
+ cmd_analyze.py
3
+
4
+ Click subcommand for running a thorough codebase analysis:
5
+
6
+ analyze — run PyCodeKGAnalyzer, emit a Markdown report (JSON optional via --json)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from pycode_kg.cli.main import cli
16
+ from pycode_kg.cli.options import exclude_option, include_option
17
+ from pycode_kg.config import load_exclude_dirs, load_include_dirs
18
+ from pycode_kg.pycodekg_thorough_analysis import main as run_analysis
19
+
20
+
21
+ @cli.command("analyze")
22
+ @click.argument("repo_root", default=".", required=False)
23
+ @click.option(
24
+ "--db",
25
+ default=None,
26
+ type=click.Path(),
27
+ help="SQLite knowledge graph path (default: <repo>/.pycodekg/graph.sqlite).",
28
+ )
29
+ @click.option(
30
+ "--lancedb",
31
+ default=None,
32
+ type=click.Path(),
33
+ help="LanceDB vector index directory (default: <repo>/.pycodekg/lancedb).",
34
+ )
35
+ @click.option(
36
+ "--output",
37
+ "-o",
38
+ default=None,
39
+ type=click.Path(),
40
+ help="Markdown report output path (default: <repo>_analysis_<YYYYMMDD>.md).",
41
+ )
42
+ @click.option(
43
+ "--json",
44
+ "-j",
45
+ "json_path",
46
+ default=None,
47
+ type=click.Path(),
48
+ help="Write JSON snapshot to this path (omit to skip JSON output).",
49
+ )
50
+ @click.option(
51
+ "--quiet",
52
+ "-q",
53
+ is_flag=True,
54
+ help="Suppress the Rich console summary table.",
55
+ )
56
+ @click.option(
57
+ "--write-centrality",
58
+ is_flag=True,
59
+ help="Persist SIR centrality scores to the centrality_scores table in the SQLite graph.",
60
+ )
61
+ @include_option
62
+ @exclude_option
63
+ def analyze(
64
+ repo_root: str,
65
+ db: str | None,
66
+ lancedb: str | None,
67
+ output: str | None,
68
+ json_path: str | None,
69
+ quiet: bool,
70
+ write_centrality: bool,
71
+ include_dir: tuple[str, ...],
72
+ exclude_dir: tuple[str, ...],
73
+ ) -> None:
74
+ """Run a thorough analysis of a Python repository.
75
+
76
+ Analyzes complexity, hotspots, docstring coverage, circular dependencies,
77
+ and other health signals. Outputs a Markdown report. Pass --json PATH to
78
+ also write a JSON snapshot.
79
+ """
80
+ # Merge pyproject.toml config with CLI flags
81
+ repo_path = Path(repo_root).resolve()
82
+ include = load_include_dirs(repo_path) | set(include_dir)
83
+ exclude = load_exclude_dirs(repo_path) | set(exclude_dir)
84
+
85
+ # Run thorough analysis
86
+ run_analysis(
87
+ repo_root=repo_root,
88
+ db_path=db,
89
+ lancedb_path=lancedb,
90
+ report_path=output,
91
+ json_path=json_path,
92
+ quiet=quiet,
93
+ include=include if include else None,
94
+ exclude=exclude if exclude else None,
95
+ persist_centrality=write_centrality,
96
+ )
@@ -0,0 +1,150 @@
1
+ """
2
+ cmd_architecture.py
3
+
4
+ Click subcommand for generating coherent architecture descriptions:
5
+
6
+ architecture — analyze codebase architecture, emit Markdown or JSON descriptions
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.metadata
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from pycode_kg.architecture import ArchitectureAnalyzer
18
+ from pycode_kg.cli.main import cli
19
+ from pycode_kg.store import GraphStore
20
+
21
+
22
+ @cli.command("architecture")
23
+ @click.argument("repo_root", default=".", required=False)
24
+ @click.option(
25
+ "--db",
26
+ default=None,
27
+ type=click.Path(),
28
+ help="SQLite knowledge graph path (default: <repo>/.pycodekg/graph.sqlite).",
29
+ )
30
+ @click.option(
31
+ "--markdown",
32
+ "-m",
33
+ "markdown_path",
34
+ default=None,
35
+ type=click.Path(),
36
+ help="Save architecture description as Markdown.",
37
+ )
38
+ @click.option(
39
+ "--json",
40
+ "-j",
41
+ "json_path",
42
+ default=None,
43
+ type=click.Path(),
44
+ help="Save architecture description as JSON (infographic-ready).",
45
+ )
46
+ @click.option(
47
+ "--analysis",
48
+ type=click.Path(),
49
+ default=None,
50
+ help="Path to thorough analysis JSON (from 'pycodekg analyze') to incorporate.",
51
+ )
52
+ @click.option(
53
+ "--load-latest",
54
+ is_flag=True,
55
+ help="Auto-load latest thorough analysis from ~/.claude/pycodekg_analysis_latest.json.",
56
+ )
57
+ def architecture(
58
+ repo_root: str,
59
+ db: str | None,
60
+ markdown_path: str | None,
61
+ json_path: str | None,
62
+ analysis: str | None,
63
+ load_latest: bool,
64
+ ) -> None:
65
+ """Generate a coherent architecture description of a Python repository.
66
+
67
+ Produces human-readable Markdown and machine-friendly JSON suitable for
68
+ infographics. Can incorporate insights from 'pycodekg analyze' output.
69
+
70
+ If neither --markdown nor --json is specified, prints Markdown to console.
71
+ """
72
+ repo_path = Path(repo_root).resolve()
73
+ db_path = Path(db or repo_path / ".pycodekg" / "graph.sqlite")
74
+
75
+ if not db_path.exists():
76
+ raise click.UsageError(
77
+ f"SQLite graph not found: {db_path}\nRun 'pycodekg build' first to index your repository."
78
+ )
79
+
80
+ store = GraphStore(db_path)
81
+ try:
82
+ # Get version and commit for provenance
83
+ try:
84
+ commit = subprocess.check_output(
85
+ ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
86
+ ).strip()[:10]
87
+ except (subprocess.CalledProcessError, FileNotFoundError):
88
+ commit = "unknown"
89
+
90
+ # Try to get version from package
91
+ version = "unknown"
92
+ try:
93
+ version = importlib.metadata.version("pycode-kg")
94
+ except importlib.metadata.PackageNotFoundError:
95
+ pass
96
+
97
+ analyzer = ArchitectureAnalyzer(store, repo_path, version=version, commit=commit)
98
+
99
+ # Load thorough analysis - try in order: explicit path, auto-load latest, skip
100
+ import json # pylint: disable=import-outside-toplevel
101
+
102
+ analysis_loaded = False
103
+ if analysis:
104
+ try:
105
+ with open(analysis) as f:
106
+ analysis_data = json.load(f)
107
+ analyzer.incorporate_thorough_analysis(analysis_data)
108
+ click.echo(f"OK Loaded analysis: {analysis}")
109
+ analysis_loaded = True
110
+ except (FileNotFoundError, json.JSONDecodeError) as e:
111
+ click.echo(f"Warning: Could not load analysis file: {e}", err=True)
112
+
113
+ elif load_latest:
114
+ latest_path = Path.home() / ".claude" / "pycodekg_analysis_latest.json"
115
+ if latest_path.exists():
116
+ try:
117
+ with open(latest_path) as f:
118
+ analysis_data = json.load(f)
119
+ analyzer.incorporate_thorough_analysis(analysis_data)
120
+ click.echo("OK Auto-loaded latest analysis")
121
+ analysis_loaded = True
122
+ except json.JSONDecodeError as e:
123
+ click.echo(f"Warning: Could not parse analysis file: {e}", err=True)
124
+
125
+ if not analysis_loaded:
126
+ click.echo(
127
+ "Tip: Run 'pycodekg analyze --json' then 'pycodekg architecture --load-latest'"
128
+ )
129
+ click.echo(" for richer architectural insights.")
130
+
131
+ # Generate outputs
132
+ if markdown_path:
133
+ arch_md = analyzer.analyze_to_markdown()
134
+ with open(markdown_path, "w") as f:
135
+ f.write(arch_md)
136
+ click.echo(f"OK Architecture Markdown: {markdown_path}")
137
+
138
+ if json_path:
139
+ arch_json = analyzer.analyze_to_json()
140
+ with open(json_path, "w") as f:
141
+ f.write(arch_json)
142
+ click.echo(f"OK Architecture JSON: {json_path}")
143
+
144
+ if not markdown_path and not json_path:
145
+ # Print to console
146
+ arch_md = analyzer.analyze_to_markdown()
147
+ click.echo(arch_md)
148
+
149
+ finally:
150
+ store.close()
@@ -0,0 +1,26 @@
1
+ """
2
+ CLI command for bridge centrality in PyCodeKG.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from pycode_kg.analysis.bridge import compute_bridge_centrality
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(
12
+ description="Show top bridge modules by betweenness centrality."
13
+ )
14
+ parser.add_argument("--db", default="pycodekg.sqlite", help="Path to PyCodeKG SQLite DB")
15
+ parser.add_argument("--top", type=int, default=25, help="Number of top bridges to show")
16
+ parser.add_argument("--no-imports", action="store_true", help="Ignore IMPORTS edges")
17
+ args = parser.parse_args()
18
+ bridges = compute_bridge_centrality(
19
+ kind="module",
20
+ include_imports=not args.no_imports,
21
+ top=args.top,
22
+ db_path=args.db,
23
+ )
24
+ print(f"Top {args.top} bridge modules:")
25
+ for mod, score in bridges:
26
+ print(f"{mod:50s} {score:.5f}")
@@ -0,0 +1,154 @@
1
+ """
2
+ cmd_build.py
3
+
4
+ Click subcommands for building the PyCodeKG knowledge graph:
5
+
6
+ build-sqlite - repo -> AST -> graph store
7
+ build-lancedb - graph store -> vector index
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from pycode_kg.cli.main import cli
17
+ from pycode_kg.cli.options import exclude_option, include_option, repo_option
18
+ from pycode_kg.config import load_exclude_dirs, load_include_dirs
19
+ from pycode_kg.graph import CodeGraph
20
+ from pycode_kg.index import (
21
+ SemanticIndex,
22
+ SentenceTransformerEmbedder,
23
+ suppress_ingestion_logging,
24
+ )
25
+ from pycode_kg.pycodekg import DEFAULT_MODEL
26
+ from pycode_kg.store import GraphStore
27
+
28
+
29
+ @cli.command("build-sqlite")
30
+ @repo_option
31
+ @click.option(
32
+ "--db",
33
+ default=None,
34
+ type=click.Path(),
35
+ show_default=False,
36
+ help="SQLite database path (default: <repo>/.pycodekg/graph.sqlite).",
37
+ )
38
+ @click.option("--wipe", is_flag=True, help="Delete existing graph first.")
39
+ @include_option
40
+ @exclude_option
41
+ def build_sqlite(
42
+ repo: str,
43
+ db: str | None,
44
+ wipe: bool,
45
+ include_dir: tuple[str, ...],
46
+ exclude_dir: tuple[str, ...],
47
+ ) -> None:
48
+ """Extract a code knowledge graph from a Python repo and store it in SQLite."""
49
+ repo_root = Path(repo).resolve()
50
+ db_path = Path(db) if db else repo_root / ".pycodekg" / "graph.sqlite"
51
+
52
+ # Merge pyproject.toml config with CLI flags
53
+ include = load_include_dirs(repo_root) | set(include_dir)
54
+ exclude = load_exclude_dirs(repo_root) | set(exclude_dir)
55
+
56
+ graph = CodeGraph(
57
+ repo_root,
58
+ include=include if include else None,
59
+ exclude=exclude if exclude else None,
60
+ )
61
+ nodes, edges = graph.extract().result()
62
+
63
+ store = GraphStore(db_path)
64
+ store.write(nodes, edges, wipe=wipe)
65
+ resolved = store.resolve_symbols()
66
+ store.close()
67
+
68
+ print(f"OK: nodes={len(nodes)} edges={len(edges)} resolved={resolved} db={db_path}")
69
+
70
+
71
+ @cli.command("build-lancedb")
72
+ @repo_option
73
+ @click.option(
74
+ "--sqlite",
75
+ default=None,
76
+ type=click.Path(),
77
+ show_default=False,
78
+ help="Path to graph.sqlite (default: <repo>/.pycodekg/graph.sqlite).",
79
+ )
80
+ @click.option(
81
+ "--lancedb",
82
+ default=None,
83
+ type=click.Path(),
84
+ show_default=False,
85
+ help="Directory for LanceDB (default: <repo>/.pycodekg/lancedb).",
86
+ )
87
+ @click.option(
88
+ "--table",
89
+ default="pycodekg_nodes",
90
+ show_default=True,
91
+ help="LanceDB table name.",
92
+ )
93
+ @click.option(
94
+ "--model",
95
+ default=DEFAULT_MODEL,
96
+ show_default=True,
97
+ help="SentenceTransformer model name.",
98
+ )
99
+ @click.option("--wipe", is_flag=True, help="Delete existing vectors first.")
100
+ @click.option("-v", "--verbose", is_flag=True, help="Show LanceDB and embedder progress output.")
101
+ @click.option(
102
+ "--kinds",
103
+ default="module,class,function,method",
104
+ show_default=True,
105
+ help="Comma-separated node kinds to index.",
106
+ )
107
+ @click.option(
108
+ "--batch",
109
+ type=int,
110
+ default=256,
111
+ show_default=True,
112
+ help="Embedding batch size.",
113
+ )
114
+ def build_lancedb(
115
+ repo: str,
116
+ sqlite: str | None,
117
+ lancedb: str | None,
118
+ table: str,
119
+ model: str,
120
+ wipe: bool,
121
+ verbose: bool,
122
+ kinds: str,
123
+ batch: int,
124
+ ) -> None:
125
+ """Build a LanceDB semantic index from an existing pycodekg SQLite database."""
126
+ repo_root = Path(repo).resolve()
127
+ sqlite_path = Path(sqlite) if sqlite else repo_root / ".pycodekg" / "graph.sqlite"
128
+ lancedb_dir = Path(lancedb) if lancedb else repo_root / ".pycodekg" / "lancedb"
129
+
130
+ if not verbose:
131
+ suppress_ingestion_logging()
132
+
133
+ kinds_tuple = tuple(k.strip() for k in kinds.split(",") if k.strip())
134
+ embedder = SentenceTransformerEmbedder(model)
135
+
136
+ store = GraphStore(sqlite_path)
137
+ idx = SemanticIndex(
138
+ lancedb_dir,
139
+ embedder=embedder,
140
+ table=table,
141
+ index_kinds=kinds_tuple,
142
+ )
143
+ stats = idx.build(store, wipe=wipe, batch_size=batch, quiet=not verbose)
144
+ store.close()
145
+
146
+ print(
147
+ "OK:",
148
+ f"indexed_rows={stats['indexed_rows']}",
149
+ f"embedder={model}",
150
+ f"dim={stats['dim']}",
151
+ f"table={stats['table']}",
152
+ f"lancedb_dir={stats['lancedb_dir']}",
153
+ f"kinds={','.join(stats['kinds'])}",
154
+ )
@@ -0,0 +1,242 @@
1
+ """
2
+ cmd_build_full.py
3
+
4
+ Click subcommand for building the full PyCodeKG pipeline in one step:
5
+
6
+ build - repo -> AST -> graph store -> vector index (full pipeline, always wipes)
7
+ update - repo -> AST -> graph store -> vector index (incremental upsert, no wipe)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from pycode_kg.cli.main import cli
18
+ from pycode_kg.cli.options import (
19
+ exclude_option,
20
+ include_option,
21
+ model_option,
22
+ repo_option,
23
+ )
24
+ from pycode_kg.config import load_exclude_dirs, load_include_dirs
25
+ from pycode_kg.graph import CodeGraph
26
+ from pycode_kg.index import (
27
+ SemanticIndex,
28
+ SentenceTransformerEmbedder,
29
+ suppress_ingestion_logging,
30
+ )
31
+ from pycode_kg.store import GraphStore
32
+
33
+
34
+ def _banner(repo_root: Path, label: str) -> None:
35
+ """Print the build header banner."""
36
+ click.echo()
37
+ click.echo(f" PyCodeKG {label}")
38
+ click.echo(" repo - AST - graph store - vector index")
39
+ click.echo(f" repo {repo_root}")
40
+ click.echo()
41
+
42
+
43
+ def _step_result(n: int, label: str, pairs: list[tuple[str, str]], elapsed: float) -> None:
44
+ """Print a single-line step result after the step completes."""
45
+ kv = " ".join(f"{k}={v}" for k, v in pairs)
46
+ click.echo(f" Step {n} {label} {kv} ({elapsed:.1f}s)")
47
+
48
+
49
+ def _done(elapsed: float) -> None:
50
+ """Print a compact build-complete summary."""
51
+ click.echo()
52
+ click.echo(f" done ({elapsed:.1f}s)")
53
+ click.echo()
54
+
55
+
56
+ def _common_options(fn):
57
+ """Shared Click options for build and update commands."""
58
+ fn = repo_option(fn)
59
+ fn = click.option(
60
+ "--db",
61
+ default=None,
62
+ type=click.Path(),
63
+ show_default=False,
64
+ help="SQLite database path (default: <repo>/.pycodekg/graph.sqlite).",
65
+ )(fn)
66
+ fn = click.option(
67
+ "--lancedb",
68
+ default=None,
69
+ type=click.Path(),
70
+ show_default=False,
71
+ help="LanceDB directory path (default: <repo>/.pycodekg/lancedb).",
72
+ )(fn)
73
+ fn = click.option(
74
+ "--table",
75
+ default="pycodekg_nodes",
76
+ show_default=True,
77
+ help="LanceDB table name.",
78
+ )(fn)
79
+ fn = model_option(fn)
80
+ fn = click.option(
81
+ "-v", "--verbose", is_flag=True, help="Show LanceDB and embedder progress output."
82
+ )(fn)
83
+ fn = click.option(
84
+ "--kinds",
85
+ default="module,class,function,method",
86
+ show_default=True,
87
+ help="Comma-separated node kinds to index.",
88
+ )(fn)
89
+ fn = click.option(
90
+ "--batch",
91
+ type=int,
92
+ default=256,
93
+ show_default=True,
94
+ help="Embedding batch size.",
95
+ )(fn)
96
+ fn = include_option(fn)
97
+ fn = exclude_option(fn)
98
+ return fn
99
+
100
+
101
+ def _run_pipeline(
102
+ repo: str,
103
+ db: str | None,
104
+ lancedb: str | None,
105
+ table: str,
106
+ model: str,
107
+ verbose: bool,
108
+ kinds: str,
109
+ batch: int,
110
+ include_dir: tuple[str, ...],
111
+ exclude_dir: tuple[str, ...],
112
+ *,
113
+ wipe: bool,
114
+ label: str,
115
+ ) -> None:
116
+ """Execute the full build pipeline (shared by build and update)."""
117
+ repo_root = Path(repo).resolve()
118
+ db_path = Path(db) if db else repo_root / ".pycodekg" / "graph.sqlite"
119
+ lancedb_dir = Path(lancedb) if lancedb else repo_root / ".pycodekg" / "lancedb"
120
+
121
+ include = load_include_dirs(repo_root) | set(include_dir)
122
+ exclude = load_exclude_dirs(repo_root) | set(exclude_dir)
123
+
124
+ _banner(repo_root, label)
125
+ t_total = time.monotonic()
126
+
127
+ # Step 1: Build graph store
128
+ t1 = time.monotonic()
129
+ graph = CodeGraph(
130
+ repo_root,
131
+ include=include if include else None,
132
+ exclude=exclude if exclude else None,
133
+ )
134
+ nodes, edges = graph.extract().result()
135
+
136
+ store = GraphStore(db_path)
137
+ store.write(nodes, edges, wipe=wipe)
138
+ resolved = store.resolve_symbols()
139
+ store.close()
140
+
141
+ _step_result(
142
+ 1,
143
+ "graph store",
144
+ [
145
+ ("nodes", str(len(nodes))),
146
+ ("edges", str(len(edges))),
147
+ ("resolved", str(resolved)),
148
+ ],
149
+ elapsed=time.monotonic() - t1,
150
+ )
151
+
152
+ # Step 2: Build vector index
153
+ if not verbose:
154
+ suppress_ingestion_logging()
155
+
156
+ t2 = time.monotonic()
157
+ kinds_tuple = tuple(k.strip() for k in kinds.split(",") if k.strip())
158
+ embedder = SentenceTransformerEmbedder(model)
159
+
160
+ store = GraphStore(db_path)
161
+ idx = SemanticIndex(
162
+ lancedb_dir,
163
+ embedder=embedder,
164
+ table=table,
165
+ index_kinds=kinds_tuple,
166
+ )
167
+ stats = idx.build(store, wipe=wipe, batch_size=batch, quiet=not verbose)
168
+ store.close()
169
+
170
+ _step_result(
171
+ 2,
172
+ "vector index",
173
+ [
174
+ ("indexed", str(stats["indexed_rows"])),
175
+ ("model", Path(model).name),
176
+ ],
177
+ elapsed=time.monotonic() - t2,
178
+ )
179
+
180
+ _done(elapsed=time.monotonic() - t_total)
181
+
182
+
183
+ @cli.command("build")
184
+ @_common_options
185
+ def build(
186
+ repo: str,
187
+ db: str | None,
188
+ lancedb: str | None,
189
+ table: str,
190
+ model: str,
191
+ verbose: bool,
192
+ kinds: str,
193
+ batch: int,
194
+ include_dir: tuple[str, ...],
195
+ exclude_dir: tuple[str, ...],
196
+ ) -> None:
197
+ """Build knowledge graph from scratch: wipes existing data, then extracts AST -> graph store -> vector index."""
198
+ _run_pipeline(
199
+ repo,
200
+ db,
201
+ lancedb,
202
+ table,
203
+ model,
204
+ verbose,
205
+ kinds,
206
+ batch,
207
+ include_dir,
208
+ exclude_dir,
209
+ wipe=True,
210
+ label="Full Build",
211
+ )
212
+
213
+
214
+ @cli.command("update")
215
+ @_common_options
216
+ def update(
217
+ repo: str,
218
+ db: str | None,
219
+ lancedb: str | None,
220
+ table: str,
221
+ model: str,
222
+ verbose: bool,
223
+ kinds: str,
224
+ batch: int,
225
+ include_dir: tuple[str, ...],
226
+ exclude_dir: tuple[str, ...],
227
+ ) -> None:
228
+ """Update knowledge graph incrementally: upserts changes without wiping existing data."""
229
+ _run_pipeline(
230
+ repo,
231
+ db,
232
+ lancedb,
233
+ table,
234
+ model,
235
+ verbose,
236
+ kinds,
237
+ batch,
238
+ include_dir,
239
+ exclude_dir,
240
+ wipe=False,
241
+ label="Update",
242
+ )