codegraph-nav 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_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""GraphBuilder — Build SQLite graph from .codegraph.json code map.
|
|
2
|
+
|
|
3
|
+
Converts the flat JSON symbol index into a queryable graph with nodes,
|
|
4
|
+
edges (CALLS, CONTAINS, INHERITS, IMPORTS_FROM, TESTED_BY), and an
|
|
5
|
+
optional FTS5 full-text index. Supports incremental builds via file hashing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .schema import make_qualified_name
|
|
16
|
+
from .store import GraphStore
|
|
17
|
+
|
|
18
|
+
# Regex patterns for test file detection (same as mcp/server.py)
|
|
19
|
+
_TEST_FILE_PATTERNS = [
|
|
20
|
+
re.compile(r"test_[^/]*\.py$"),
|
|
21
|
+
re.compile(r"[^/]*_test\.py$"),
|
|
22
|
+
re.compile(r"[^/]*\.test\.[jt]sx?$"),
|
|
23
|
+
re.compile(r"[^/]*\.spec\.[jt]sx?$"),
|
|
24
|
+
re.compile(r"(^|/)tests/"),
|
|
25
|
+
re.compile(r"(^|/)__tests__/"),
|
|
26
|
+
re.compile(r"(^|/)test/"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_test_file(file_path: str) -> bool:
|
|
31
|
+
for p in _TEST_FILE_PATTERNS:
|
|
32
|
+
if p.search(file_path):
|
|
33
|
+
return True
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _detect_language(file_path: str) -> str:
|
|
38
|
+
ext = Path(file_path).suffix.lower()
|
|
39
|
+
return {
|
|
40
|
+
".py": "python",
|
|
41
|
+
".js": "javascript",
|
|
42
|
+
".jsx": "javascript",
|
|
43
|
+
".ts": "typescript",
|
|
44
|
+
".tsx": "typescript",
|
|
45
|
+
".mjs": "javascript",
|
|
46
|
+
".java": "java",
|
|
47
|
+
".go": "go",
|
|
48
|
+
".rs": "rust",
|
|
49
|
+
".rb": "ruby",
|
|
50
|
+
".php": "php",
|
|
51
|
+
".c": "c",
|
|
52
|
+
".h": "c",
|
|
53
|
+
".cpp": "cpp",
|
|
54
|
+
".hpp": "cpp",
|
|
55
|
+
}.get(ext, "unknown")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_bases_from_signature(signature: str | None) -> list[str]:
|
|
59
|
+
"""Extract base class names from a class signature like 'class Foo(Base, Mixin)'."""
|
|
60
|
+
if not signature:
|
|
61
|
+
return []
|
|
62
|
+
m = re.search(r"class\s+\w+\((.*?)\)", signature)
|
|
63
|
+
if not m:
|
|
64
|
+
return []
|
|
65
|
+
bases_str = m.group(1)
|
|
66
|
+
return [b.strip() for b in bases_str.split(",") if b.strip()]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GraphBuilder:
|
|
70
|
+
"""Builds a graph DB from a code_map dict."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, store: GraphStore):
|
|
73
|
+
self.store = store
|
|
74
|
+
|
|
75
|
+
def build_from_code_map(
|
|
76
|
+
self,
|
|
77
|
+
code_map: dict,
|
|
78
|
+
root_path: str = "",
|
|
79
|
+
incremental: bool = True,
|
|
80
|
+
) -> dict:
|
|
81
|
+
"""Build graph DB from code_map. Returns build stats.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
code_map: The code map dict (from .codegraph.json).
|
|
85
|
+
root_path: Project root path.
|
|
86
|
+
incremental: If True, skip unchanged files (by hash).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Dict with files_processed, files_skipped, nodes_created, edges_created, etc.
|
|
90
|
+
"""
|
|
91
|
+
start = time.time()
|
|
92
|
+
files = code_map.get("files", {})
|
|
93
|
+
index = code_map.get("index", {})
|
|
94
|
+
|
|
95
|
+
stats: dict[str, float] = {
|
|
96
|
+
"files_processed": 0,
|
|
97
|
+
"files_skipped": 0,
|
|
98
|
+
"nodes_created": 0,
|
|
99
|
+
"edges_created": 0,
|
|
100
|
+
"errors": 0,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Phase 1: Build nodes and intra-file edges
|
|
104
|
+
# Collect all symbol qualified names for resolution
|
|
105
|
+
all_symbols: dict[str, list[str]] = {} # name_lower → [qualified_names]
|
|
106
|
+
|
|
107
|
+
# First pass: determine which files need processing
|
|
108
|
+
files_to_process = {}
|
|
109
|
+
files_to_skip = set()
|
|
110
|
+
|
|
111
|
+
for file_path, file_info in files.items():
|
|
112
|
+
file_hash = file_info.get("hash", "")
|
|
113
|
+
if incremental:
|
|
114
|
+
stored_hash = self.store.get_file_hash(file_path)
|
|
115
|
+
if stored_hash and stored_hash == file_hash:
|
|
116
|
+
files_to_skip.add(file_path)
|
|
117
|
+
stats["files_skipped"] += 1
|
|
118
|
+
continue
|
|
119
|
+
files_to_process[file_path] = file_info
|
|
120
|
+
|
|
121
|
+
# Delete stale files (in DB but not in code_map)
|
|
122
|
+
db_files = {row["file_path"] for row in self.store.get_all_nodes("File")}
|
|
123
|
+
stale_files = db_files - set(files.keys())
|
|
124
|
+
for stale in stale_files:
|
|
125
|
+
self.store.delete_file_nodes(stale)
|
|
126
|
+
|
|
127
|
+
# Load existing symbols from skipped files for resolution
|
|
128
|
+
for file_path in files_to_skip:
|
|
129
|
+
for row in self.store.get_nodes_by_file(file_path):
|
|
130
|
+
name_lower = row["name"].lower()
|
|
131
|
+
all_symbols.setdefault(name_lower, []).append(row["qualified_name"])
|
|
132
|
+
|
|
133
|
+
# Second pass: process files
|
|
134
|
+
for file_path, file_info in files_to_process.items():
|
|
135
|
+
try:
|
|
136
|
+
self._process_file(file_path, file_info, all_symbols, stats)
|
|
137
|
+
except Exception:
|
|
138
|
+
stats["errors"] += 1
|
|
139
|
+
|
|
140
|
+
# Phase 2: Resolve CALLS edges (cross-file)
|
|
141
|
+
self._resolve_calls_edges(files, index, all_symbols, stats)
|
|
142
|
+
|
|
143
|
+
# Phase 3: Create IMPORTS_FROM edges
|
|
144
|
+
self._create_import_edges(files, stats)
|
|
145
|
+
|
|
146
|
+
# Phase 4: Create TESTED_BY edges
|
|
147
|
+
self._create_tested_by_edges(files, all_symbols, stats)
|
|
148
|
+
|
|
149
|
+
# Phase 5: Rebuild FTS5 index
|
|
150
|
+
if self.store.fts_available:
|
|
151
|
+
self._rebuild_fts()
|
|
152
|
+
|
|
153
|
+
stats["build_time"] = round(time.time() - start, 3)
|
|
154
|
+
return stats
|
|
155
|
+
|
|
156
|
+
def _process_file(
|
|
157
|
+
self,
|
|
158
|
+
file_path: str,
|
|
159
|
+
file_info: dict,
|
|
160
|
+
all_symbols: dict[str, list[str]],
|
|
161
|
+
stats: dict,
|
|
162
|
+
):
|
|
163
|
+
"""Process a single file: create nodes and intra-file edges."""
|
|
164
|
+
file_hash = file_info.get("hash", "")
|
|
165
|
+
language = _detect_language(file_path)
|
|
166
|
+
is_test = _is_test_file(file_path)
|
|
167
|
+
symbols = file_info.get("symbols", [])
|
|
168
|
+
|
|
169
|
+
# Delete old data for this file
|
|
170
|
+
self.store.delete_file_nodes(file_path)
|
|
171
|
+
|
|
172
|
+
# Create File node
|
|
173
|
+
file_qn = file_path
|
|
174
|
+
nodes = [
|
|
175
|
+
{
|
|
176
|
+
"kind": "File",
|
|
177
|
+
"name": Path(file_path).name,
|
|
178
|
+
"qualified_name": file_qn,
|
|
179
|
+
"file_path": file_path,
|
|
180
|
+
"language": language,
|
|
181
|
+
"is_test": is_test,
|
|
182
|
+
"file_hash": file_hash,
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
edges: list[dict[str, Any]] = []
|
|
187
|
+
|
|
188
|
+
for sym in symbols:
|
|
189
|
+
name = sym.get("name", "")
|
|
190
|
+
sym_type = sym.get("type", "function")
|
|
191
|
+
parent = sym.get("parent")
|
|
192
|
+
lines = sym.get("lines", [0, 0])
|
|
193
|
+
line_start = lines[0] if lines else None
|
|
194
|
+
line_end = lines[1] if len(lines) > 1 else line_start
|
|
195
|
+
|
|
196
|
+
kind = {
|
|
197
|
+
"function": "Function",
|
|
198
|
+
"class": "Class",
|
|
199
|
+
"method": "Method",
|
|
200
|
+
"variable": "Variable",
|
|
201
|
+
}.get(sym_type, "Function")
|
|
202
|
+
|
|
203
|
+
qn = make_qualified_name(file_path, name, parent)
|
|
204
|
+
|
|
205
|
+
nodes.append(
|
|
206
|
+
{
|
|
207
|
+
"kind": kind,
|
|
208
|
+
"name": name,
|
|
209
|
+
"qualified_name": qn,
|
|
210
|
+
"file_path": file_path,
|
|
211
|
+
"line_start": line_start,
|
|
212
|
+
"line_end": line_end,
|
|
213
|
+
"language": language,
|
|
214
|
+
"parent_name": parent,
|
|
215
|
+
"signature": sym.get("signature"),
|
|
216
|
+
"is_test": is_test,
|
|
217
|
+
"file_hash": file_hash,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Track for resolution
|
|
222
|
+
all_symbols.setdefault(name.lower(), []).append(qn)
|
|
223
|
+
|
|
224
|
+
# CONTAINS edge: file → symbol, or class → method
|
|
225
|
+
if parent:
|
|
226
|
+
parent_qn = make_qualified_name(file_path, parent)
|
|
227
|
+
edges.append(
|
|
228
|
+
{
|
|
229
|
+
"kind": "CONTAINS",
|
|
230
|
+
"source_qualified": parent_qn,
|
|
231
|
+
"target_qualified": qn,
|
|
232
|
+
"file_path": file_path,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
edges.append(
|
|
237
|
+
{
|
|
238
|
+
"kind": "CONTAINS",
|
|
239
|
+
"source_qualified": file_qn,
|
|
240
|
+
"target_qualified": qn,
|
|
241
|
+
"file_path": file_path,
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# INHERITS edge for classes
|
|
246
|
+
if kind == "Class":
|
|
247
|
+
bases = _extract_bases_from_signature(sym.get("signature"))
|
|
248
|
+
for base_name in bases:
|
|
249
|
+
# Store with unresolved target; will resolve later
|
|
250
|
+
edges.append(
|
|
251
|
+
{
|
|
252
|
+
"kind": "INHERITS",
|
|
253
|
+
"source_qualified": qn,
|
|
254
|
+
"target_qualified": f"__unresolved__::{base_name}",
|
|
255
|
+
"file_path": file_path,
|
|
256
|
+
"extra": {"raw_target": base_name},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# CALLS edges (intra-file, same file deps)
|
|
261
|
+
deps = sym.get("deps") or sym.get("dependencies") or []
|
|
262
|
+
for dep_name in deps:
|
|
263
|
+
# Try same-file resolution
|
|
264
|
+
dep_qn = None
|
|
265
|
+
for s in symbols:
|
|
266
|
+
if s.get("name") == dep_name:
|
|
267
|
+
dep_qn = make_qualified_name(file_path, dep_name, s.get("parent"))
|
|
268
|
+
break
|
|
269
|
+
if not dep_qn:
|
|
270
|
+
dep_qn = f"__unresolved__::{dep_name}"
|
|
271
|
+
|
|
272
|
+
edges.append(
|
|
273
|
+
{
|
|
274
|
+
"kind": "CALLS",
|
|
275
|
+
"source_qualified": qn,
|
|
276
|
+
"target_qualified": dep_qn,
|
|
277
|
+
"file_path": file_path,
|
|
278
|
+
"extra": {"raw_target": dep_name},
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
self.store.batch_upsert_nodes(nodes)
|
|
283
|
+
self.store.batch_upsert_edges(edges)
|
|
284
|
+
stats["files_processed"] += 1
|
|
285
|
+
stats["nodes_created"] += len(nodes)
|
|
286
|
+
stats["edges_created"] += len(edges)
|
|
287
|
+
|
|
288
|
+
def _resolve_calls_edges(
|
|
289
|
+
self,
|
|
290
|
+
files: dict,
|
|
291
|
+
index: dict,
|
|
292
|
+
all_symbols: dict[str, list[str]],
|
|
293
|
+
stats: dict,
|
|
294
|
+
):
|
|
295
|
+
"""Resolve __unresolved__ CALLS/INHERITS edges using the global index."""
|
|
296
|
+
unresolved = self.store.conn.execute(
|
|
297
|
+
"SELECT id, kind, source_qualified, target_qualified, file_path, extra "
|
|
298
|
+
"FROM edges WHERE target_qualified LIKE '__unresolved__%'"
|
|
299
|
+
).fetchall()
|
|
300
|
+
|
|
301
|
+
updates = []
|
|
302
|
+
for edge in unresolved:
|
|
303
|
+
raw_target = edge["target_qualified"].split("::", 1)[-1]
|
|
304
|
+
source_file = edge["file_path"]
|
|
305
|
+
|
|
306
|
+
# Resolution strategies
|
|
307
|
+
resolved_qn = None
|
|
308
|
+
|
|
309
|
+
# 1. Check index for symbol name
|
|
310
|
+
candidates = all_symbols.get(raw_target.lower(), [])
|
|
311
|
+
if len(candidates) == 1:
|
|
312
|
+
resolved_qn = candidates[0]
|
|
313
|
+
elif len(candidates) > 1:
|
|
314
|
+
# Prefer same-file, then imported files
|
|
315
|
+
file_imports = files.get(source_file, {}).get("imports", [])
|
|
316
|
+
for c in candidates:
|
|
317
|
+
c_file = c.split("::")[0]
|
|
318
|
+
if c_file == source_file:
|
|
319
|
+
resolved_qn = c
|
|
320
|
+
break
|
|
321
|
+
if not resolved_qn:
|
|
322
|
+
for c in candidates:
|
|
323
|
+
c_file = c.split("::")[0]
|
|
324
|
+
if c_file in file_imports or any(
|
|
325
|
+
c_file.endswith(imp) for imp in file_imports
|
|
326
|
+
):
|
|
327
|
+
resolved_qn = c
|
|
328
|
+
break
|
|
329
|
+
if not resolved_qn:
|
|
330
|
+
resolved_qn = candidates[0] # best guess
|
|
331
|
+
|
|
332
|
+
if resolved_qn:
|
|
333
|
+
updates.append((resolved_qn, edge["id"]))
|
|
334
|
+
|
|
335
|
+
if updates:
|
|
336
|
+
self.store.conn.executemany(
|
|
337
|
+
"UPDATE edges SET target_qualified = ? WHERE id = ?",
|
|
338
|
+
updates,
|
|
339
|
+
)
|
|
340
|
+
self.store.conn.commit()
|
|
341
|
+
|
|
342
|
+
def _create_import_edges(self, files: dict, stats: dict):
|
|
343
|
+
"""Create IMPORTS_FROM edges from file import data."""
|
|
344
|
+
edges = []
|
|
345
|
+
for file_path, file_info in files.items():
|
|
346
|
+
for imp in file_info.get("imports", []):
|
|
347
|
+
# imp might be a file path or module name
|
|
348
|
+
target_qn = imp if imp in files else None
|
|
349
|
+
if not target_qn:
|
|
350
|
+
# Try finding the file
|
|
351
|
+
for fpath in files:
|
|
352
|
+
if fpath.endswith(imp) or imp in fpath:
|
|
353
|
+
target_qn = fpath
|
|
354
|
+
break
|
|
355
|
+
if target_qn:
|
|
356
|
+
edges.append(
|
|
357
|
+
{
|
|
358
|
+
"kind": "IMPORTS_FROM",
|
|
359
|
+
"source_qualified": file_path,
|
|
360
|
+
"target_qualified": target_qn,
|
|
361
|
+
"file_path": file_path,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
if edges:
|
|
365
|
+
self.store.batch_upsert_edges(edges)
|
|
366
|
+
stats["edges_created"] += len(edges)
|
|
367
|
+
|
|
368
|
+
def _create_tested_by_edges(
|
|
369
|
+
self,
|
|
370
|
+
files: dict,
|
|
371
|
+
all_symbols: dict[str, list[str]],
|
|
372
|
+
stats: dict,
|
|
373
|
+
):
|
|
374
|
+
"""Infer TESTED_BY edges: test functions that CALL production functions."""
|
|
375
|
+
edges = []
|
|
376
|
+
for file_path, file_info in files.items():
|
|
377
|
+
if not _is_test_file(file_path):
|
|
378
|
+
continue
|
|
379
|
+
for sym in file_info.get("symbols", []):
|
|
380
|
+
if not sym.get("name", "").startswith("test_"):
|
|
381
|
+
continue
|
|
382
|
+
test_qn = make_qualified_name(file_path, sym["name"], sym.get("parent"))
|
|
383
|
+
# Look at what this test calls
|
|
384
|
+
deps = sym.get("deps") or sym.get("dependencies") or []
|
|
385
|
+
for dep_name in deps:
|
|
386
|
+
# Find production symbol
|
|
387
|
+
candidates = all_symbols.get(dep_name.lower(), [])
|
|
388
|
+
for c in candidates:
|
|
389
|
+
c_file = c.split("::")[0]
|
|
390
|
+
if not _is_test_file(c_file):
|
|
391
|
+
edges.append(
|
|
392
|
+
{
|
|
393
|
+
"kind": "TESTED_BY",
|
|
394
|
+
"source_qualified": c,
|
|
395
|
+
"target_qualified": test_qn,
|
|
396
|
+
"file_path": file_path,
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
break # One TESTED_BY per dep
|
|
400
|
+
|
|
401
|
+
if edges:
|
|
402
|
+
self.store.batch_upsert_edges(edges)
|
|
403
|
+
stats["edges_created"] += len(edges)
|
|
404
|
+
|
|
405
|
+
def _rebuild_fts(self):
|
|
406
|
+
"""Rebuild FTS5 index from nodes table."""
|
|
407
|
+
from .search import rebuild_fts_index
|
|
408
|
+
|
|
409
|
+
rebuild_fts_index(self.store.conn)
|