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.
Files changed (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. 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)