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,517 @@
1
+ """GraphStore — SQLite-backed graph storage with WAL mode.
2
+
3
+ Provides CRUD operations for nodes, edges, and flows. All queries use
4
+ parameterized SQL to prevent injection. Batch operations respect the
5
+ SQLite 999-variable limit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import time
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ from .schema import BATCH_SIZE, ensure_schema, is_fts5_available
17
+
18
+
19
+ class GraphStore:
20
+ """SQLite graph store with WAL mode for concurrent access."""
21
+
22
+ def __init__(self, db_path: str | Path):
23
+ self.db_path = Path(db_path)
24
+ self._conn = sqlite3.connect(str(self.db_path), timeout=30, check_same_thread=False)
25
+ self._conn.row_factory = sqlite3.Row
26
+ self._conn.execute("PRAGMA journal_mode=WAL")
27
+ self._conn.execute("PRAGMA busy_timeout=5000")
28
+ self._schema_version = ensure_schema(self._conn)
29
+ self._fts_available: bool | None = None
30
+
31
+ @property
32
+ def conn(self) -> sqlite3.Connection:
33
+ return self._conn
34
+
35
+ @property
36
+ def fts_available(self) -> bool:
37
+ if self._fts_available is None:
38
+ self._fts_available = is_fts5_available(self._conn)
39
+ return self._fts_available
40
+
41
+ def close(self):
42
+ if self._conn:
43
+ self._conn.close()
44
+ self._conn = None
45
+
46
+ # ------------------------------------------------------------------
47
+ # Node CRUD
48
+ # ------------------------------------------------------------------
49
+
50
+ def upsert_node(
51
+ self,
52
+ kind: str,
53
+ name: str,
54
+ qualified_name: str,
55
+ file_path: str,
56
+ line_start: int | None = None,
57
+ line_end: int | None = None,
58
+ language: str | None = None,
59
+ parent_name: str | None = None,
60
+ signature: str | None = None,
61
+ is_test: bool = False,
62
+ file_hash: str | None = None,
63
+ extra: dict | None = None,
64
+ ) -> int:
65
+ """Insert or replace a node. Returns the node ID."""
66
+ now = time.time()
67
+ extra_json = json.dumps(extra) if extra else "{}"
68
+ cur = self._conn.execute(
69
+ """INSERT OR REPLACE INTO nodes
70
+ (kind, name, qualified_name, file_path, line_start, line_end,
71
+ language, parent_name, signature, is_test, file_hash, extra, updated_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
73
+ (
74
+ kind,
75
+ name,
76
+ qualified_name,
77
+ file_path,
78
+ line_start,
79
+ line_end,
80
+ language,
81
+ parent_name,
82
+ signature,
83
+ int(is_test),
84
+ file_hash,
85
+ extra_json,
86
+ now,
87
+ ),
88
+ )
89
+ self._conn.commit()
90
+ assert cur.lastrowid is not None
91
+ return cur.lastrowid
92
+
93
+ def batch_upsert_nodes(self, nodes: list[dict]):
94
+ """Bulk insert/replace nodes."""
95
+ now = time.time()
96
+ rows = []
97
+ for n in nodes:
98
+ extra_json = json.dumps(n.get("extra", {})) if n.get("extra") else "{}"
99
+ rows.append(
100
+ (
101
+ n["kind"],
102
+ n["name"],
103
+ n["qualified_name"],
104
+ n["file_path"],
105
+ n.get("line_start"),
106
+ n.get("line_end"),
107
+ n.get("language"),
108
+ n.get("parent_name"),
109
+ n.get("signature"),
110
+ int(n.get("is_test", False)),
111
+ n.get("file_hash"),
112
+ extra_json,
113
+ now,
114
+ )
115
+ )
116
+ self._conn.executemany(
117
+ """INSERT OR REPLACE INTO nodes
118
+ (kind, name, qualified_name, file_path, line_start, line_end,
119
+ language, parent_name, signature, is_test, file_hash, extra, updated_at)
120
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
121
+ rows,
122
+ )
123
+ self._conn.commit()
124
+
125
+ def get_node(self, qualified_name: str) -> sqlite3.Row | None:
126
+ return cast(
127
+ "sqlite3.Row | None",
128
+ self._conn.execute(
129
+ "SELECT * FROM nodes WHERE qualified_name = ?", (qualified_name,)
130
+ ).fetchone(),
131
+ )
132
+
133
+ def get_node_by_id(self, node_id: int) -> sqlite3.Row | None:
134
+ return cast(
135
+ "sqlite3.Row | None",
136
+ self._conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone(),
137
+ )
138
+
139
+ def get_nodes_by_file(self, file_path: str) -> list[sqlite3.Row]:
140
+ return self._conn.execute(
141
+ "SELECT * FROM nodes WHERE file_path = ?", (file_path,)
142
+ ).fetchall()
143
+
144
+ def get_nodes_by_name(self, name: str) -> list[sqlite3.Row]:
145
+ return self._conn.execute("SELECT * FROM nodes WHERE name = ?", (name,)).fetchall()
146
+
147
+ def get_all_nodes(self, kind: str | None = None) -> list[sqlite3.Row]:
148
+ if kind:
149
+ return self._conn.execute("SELECT * FROM nodes WHERE kind = ?", (kind,)).fetchall()
150
+ return self._conn.execute("SELECT * FROM nodes").fetchall()
151
+
152
+ def delete_file_nodes(self, file_path: str):
153
+ """Delete all nodes and their edges for a file."""
154
+ # Get qualified names for edge cleanup
155
+ qns = [
156
+ row[0]
157
+ for row in self._conn.execute(
158
+ "SELECT qualified_name FROM nodes WHERE file_path = ?", (file_path,)
159
+ ).fetchall()
160
+ ]
161
+ if qns:
162
+ for i in range(0, len(qns), BATCH_SIZE):
163
+ batch = qns[i : i + BATCH_SIZE]
164
+ placeholders = ",".join("?" for _ in batch)
165
+ self._conn.execute(
166
+ f"DELETE FROM edges WHERE source_qualified IN ({placeholders})",
167
+ batch,
168
+ )
169
+ self._conn.execute(
170
+ f"DELETE FROM edges WHERE target_qualified IN ({placeholders})",
171
+ batch,
172
+ )
173
+ self._conn.execute("DELETE FROM nodes WHERE file_path = ?", (file_path,))
174
+ # Also delete edges originating from this file
175
+ self._conn.execute("DELETE FROM edges WHERE file_path = ?", (file_path,))
176
+ self._conn.commit()
177
+
178
+ def get_file_hash(self, file_path: str) -> str | None:
179
+ """Get stored hash for a File node."""
180
+ row = self._conn.execute(
181
+ "SELECT file_hash FROM nodes WHERE file_path = ? AND kind = 'File'",
182
+ (file_path,),
183
+ ).fetchone()
184
+ return row[0] if row else None
185
+
186
+ # ------------------------------------------------------------------
187
+ # Edge CRUD
188
+ # ------------------------------------------------------------------
189
+
190
+ def upsert_edge(
191
+ self,
192
+ kind: str,
193
+ source_qualified: str,
194
+ target_qualified: str,
195
+ file_path: str,
196
+ extra: dict | None = None,
197
+ ) -> int:
198
+ """Insert an edge (ignores duplicates)."""
199
+ now = time.time()
200
+ extra_json = json.dumps(extra) if extra else "{}"
201
+ cur = self._conn.execute(
202
+ """INSERT OR IGNORE INTO edges
203
+ (kind, source_qualified, target_qualified, file_path, extra, updated_at)
204
+ VALUES (?, ?, ?, ?, ?, ?)""",
205
+ (kind, source_qualified, target_qualified, file_path, extra_json, now),
206
+ )
207
+ self._conn.commit()
208
+ assert cur.lastrowid is not None
209
+ return cur.lastrowid
210
+
211
+ def batch_upsert_edges(self, edges: list[dict]):
212
+ """Bulk insert edges (ignores duplicates)."""
213
+ now = time.time()
214
+ rows = []
215
+ for e in edges:
216
+ extra_json = json.dumps(e.get("extra", {})) if e.get("extra") else "{}"
217
+ rows.append(
218
+ (
219
+ e["kind"],
220
+ e["source_qualified"],
221
+ e["target_qualified"],
222
+ e["file_path"],
223
+ extra_json,
224
+ now,
225
+ )
226
+ )
227
+ self._conn.executemany(
228
+ """INSERT OR IGNORE INTO edges
229
+ (kind, source_qualified, target_qualified, file_path, extra, updated_at)
230
+ VALUES (?, ?, ?, ?, ?, ?)""",
231
+ rows,
232
+ )
233
+ self._conn.commit()
234
+
235
+ def get_edges_from(self, source_qualified: str, kind: str | None = None) -> list[sqlite3.Row]:
236
+ if kind:
237
+ return self._conn.execute(
238
+ "SELECT * FROM edges WHERE source_qualified = ? AND kind = ?",
239
+ (source_qualified, kind),
240
+ ).fetchall()
241
+ return self._conn.execute(
242
+ "SELECT * FROM edges WHERE source_qualified = ?", (source_qualified,)
243
+ ).fetchall()
244
+
245
+ def get_edges_to(self, target_qualified: str, kind: str | None = None) -> list[sqlite3.Row]:
246
+ if kind:
247
+ return self._conn.execute(
248
+ "SELECT * FROM edges WHERE target_qualified = ? AND kind = ?",
249
+ (target_qualified, kind),
250
+ ).fetchall()
251
+ return self._conn.execute(
252
+ "SELECT * FROM edges WHERE target_qualified = ?", (target_qualified,)
253
+ ).fetchall()
254
+
255
+ # ------------------------------------------------------------------
256
+ # Flow operations
257
+ # ------------------------------------------------------------------
258
+
259
+ def insert_flow(
260
+ self,
261
+ name: str,
262
+ entry_point_id: int,
263
+ depth: int,
264
+ node_count: int,
265
+ file_count: int,
266
+ criticality: float,
267
+ path_ids: list[int],
268
+ ) -> int:
269
+ """Insert a flow and its memberships."""
270
+ now = time.time()
271
+ cur = self._conn.execute(
272
+ """INSERT INTO flows
273
+ (name, entry_point_id, depth, node_count, file_count, criticality,
274
+ path_json, updated_at)
275
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
276
+ (
277
+ name,
278
+ entry_point_id,
279
+ depth,
280
+ node_count,
281
+ file_count,
282
+ criticality,
283
+ json.dumps(path_ids),
284
+ now,
285
+ ),
286
+ )
287
+ flow_id = cur.lastrowid
288
+ assert flow_id is not None
289
+ memberships = [(flow_id, node_id, pos) for pos, node_id in enumerate(path_ids)]
290
+ self._conn.executemany(
291
+ "INSERT OR IGNORE INTO flow_memberships (flow_id, node_id, position) VALUES (?, ?, ?)",
292
+ memberships,
293
+ )
294
+ self._conn.commit()
295
+ return flow_id
296
+
297
+ def get_flows(self, sort_by: str = "criticality", limit: int = 20) -> list[sqlite3.Row]:
298
+ order = "criticality DESC" if sort_by == "criticality" else "name ASC"
299
+ return self._conn.execute(
300
+ f"SELECT * FROM flows ORDER BY {order} LIMIT ?", (limit,)
301
+ ).fetchall()
302
+
303
+ def get_flow_memberships(self, node_id: int) -> list[sqlite3.Row]:
304
+ return self._conn.execute(
305
+ "SELECT * FROM flow_memberships WHERE node_id = ?", (node_id,)
306
+ ).fetchall()
307
+
308
+ def count_flow_memberships(self, node_id: int) -> int:
309
+ row = self._conn.execute(
310
+ "SELECT COUNT(*) FROM flow_memberships WHERE node_id = ?", (node_id,)
311
+ ).fetchone()
312
+ return row[0] if row else 0
313
+
314
+ def clear_flows(self):
315
+ """Delete all flows and memberships."""
316
+ self._conn.execute("DELETE FROM flow_memberships")
317
+ self._conn.execute("DELETE FROM flows")
318
+ self._conn.commit()
319
+
320
+ # ------------------------------------------------------------------
321
+ # Community operations
322
+ # ------------------------------------------------------------------
323
+
324
+ def insert_community(
325
+ self,
326
+ name: str,
327
+ cohesion: float,
328
+ node_count: int = 0,
329
+ file_prefix: str | None = None,
330
+ keywords: list[str] | None = None,
331
+ ) -> int:
332
+ """Insert a community. Returns community ID."""
333
+ now = time.time()
334
+ kw_json = json.dumps(keywords or [])
335
+ cur = self._conn.execute(
336
+ """INSERT INTO communities (name, node_count, cohesion, file_prefix, keywords, updated_at)
337
+ VALUES (?, ?, ?, ?, ?, ?)""",
338
+ (name, node_count, cohesion, file_prefix, kw_json, now),
339
+ )
340
+ self._conn.commit()
341
+ assert cur.lastrowid is not None
342
+ return cur.lastrowid
343
+
344
+ def add_community_members(self, community_id: int, node_ids: list[int]):
345
+ """Add nodes to a community."""
346
+ rows = [(community_id, nid) for nid in node_ids]
347
+ self._conn.executemany(
348
+ "INSERT OR IGNORE INTO community_members (community_id, node_id) VALUES (?, ?)",
349
+ rows,
350
+ )
351
+ self._conn.commit()
352
+
353
+ def get_communities(self, sort_by: str = "node_count", limit: int = 20) -> list[sqlite3.Row]:
354
+ order = {"node_count": "node_count DESC", "cohesion": "cohesion DESC", "name": "name ASC"}
355
+ order_sql = order.get(sort_by, "node_count DESC")
356
+ return self._conn.execute(
357
+ f"SELECT * FROM communities ORDER BY {order_sql} LIMIT ?", (limit,)
358
+ ).fetchall()
359
+
360
+ def get_community_members(self, community_id: int) -> list[sqlite3.Row]:
361
+ return self._conn.execute(
362
+ """SELECT n.* FROM nodes n
363
+ JOIN community_members cm ON cm.node_id = n.id
364
+ WHERE cm.community_id = ?""",
365
+ (community_id,),
366
+ ).fetchall()
367
+
368
+ def get_node_community(self, node_id: int) -> sqlite3.Row | None:
369
+ row = self._conn.execute(
370
+ """SELECT c.* FROM communities c
371
+ JOIN community_members cm ON cm.community_id = c.id
372
+ WHERE cm.node_id = ?""",
373
+ (node_id,),
374
+ ).fetchone()
375
+ return cast("sqlite3.Row | None", row)
376
+
377
+ def clear_communities(self):
378
+ """Delete all communities and memberships."""
379
+ self._conn.execute("DELETE FROM community_members")
380
+ self._conn.execute("DELETE FROM communities")
381
+ self._conn.commit()
382
+
383
+ # ------------------------------------------------------------------
384
+ # Route operations
385
+ # ------------------------------------------------------------------
386
+
387
+ def insert_route(
388
+ self,
389
+ method: str,
390
+ path: str,
391
+ file_path: str,
392
+ handler_name: str | None = None,
393
+ framework: str | None = None,
394
+ tags: list[str] | None = None,
395
+ confidence: str = "high",
396
+ ) -> int:
397
+ now = time.time()
398
+ tags_json = json.dumps(tags or [])
399
+ cur = self._conn.execute(
400
+ """INSERT INTO routes (method, path, file_path, handler_name, framework, tags, confidence, updated_at)
401
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
402
+ (method, path, file_path, handler_name, framework, tags_json, confidence, now),
403
+ )
404
+ self._conn.commit()
405
+ assert cur.lastrowid is not None
406
+ return cur.lastrowid
407
+
408
+ def get_routes(
409
+ self, framework: str | None = None, file_path: str | None = None
410
+ ) -> list[sqlite3.Row]:
411
+ if framework and file_path:
412
+ return self._conn.execute(
413
+ "SELECT * FROM routes WHERE framework = ? AND file_path = ?",
414
+ (framework, file_path),
415
+ ).fetchall()
416
+ if framework:
417
+ return self._conn.execute(
418
+ "SELECT * FROM routes WHERE framework = ?", (framework,)
419
+ ).fetchall()
420
+ if file_path:
421
+ return self._conn.execute(
422
+ "SELECT * FROM routes WHERE file_path = ?", (file_path,)
423
+ ).fetchall()
424
+ return self._conn.execute("SELECT * FROM routes").fetchall()
425
+
426
+ def clear_routes(self):
427
+ self._conn.execute("DELETE FROM routes")
428
+ self._conn.commit()
429
+
430
+ # ------------------------------------------------------------------
431
+ # Schema/ORM operations
432
+ # ------------------------------------------------------------------
433
+
434
+ def insert_schema(
435
+ self,
436
+ name: str,
437
+ file_path: str,
438
+ orm: str | None = None,
439
+ fields: list[dict] | None = None,
440
+ relations: list[str] | None = None,
441
+ ) -> int:
442
+ now = time.time()
443
+ fields_json = json.dumps(fields or [])
444
+ relations_json = json.dumps(relations or [])
445
+ cur = self._conn.execute(
446
+ """INSERT INTO schemas (name, file_path, orm, fields, relations, updated_at)
447
+ VALUES (?, ?, ?, ?, ?, ?)""",
448
+ (name, file_path, orm, fields_json, relations_json, now),
449
+ )
450
+ self._conn.commit()
451
+ assert cur.lastrowid is not None
452
+ return cur.lastrowid
453
+
454
+ def get_schemas(self, orm: str | None = None) -> list[sqlite3.Row]:
455
+ if orm:
456
+ return self._conn.execute("SELECT * FROM schemas WHERE orm = ?", (orm,)).fetchall()
457
+ return self._conn.execute("SELECT * FROM schemas").fetchall()
458
+
459
+ def clear_schemas(self):
460
+ self._conn.execute("DELETE FROM schemas")
461
+ self._conn.commit()
462
+
463
+ # ------------------------------------------------------------------
464
+ # Stats
465
+ # ------------------------------------------------------------------
466
+
467
+ def get_stats(self) -> dict:
468
+ """Get graph statistics."""
469
+ node_count = self._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
470
+ edge_count = self._conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
471
+ file_count = self._conn.execute(
472
+ "SELECT COUNT(*) FROM nodes WHERE kind = 'File'"
473
+ ).fetchone()[0]
474
+ flow_count = self._conn.execute("SELECT COUNT(*) FROM flows").fetchone()[0]
475
+ community_count = self._conn.execute("SELECT COUNT(*) FROM communities").fetchone()[0]
476
+ route_count = self._conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0]
477
+ schema_count = self._conn.execute("SELECT COUNT(*) FROM schemas").fetchone()[0]
478
+
479
+ kind_counts = {}
480
+ for row in self._conn.execute("SELECT kind, COUNT(*) FROM nodes GROUP BY kind").fetchall():
481
+ kind_counts[row[0]] = row[1]
482
+
483
+ edge_kind_counts = {}
484
+ for row in self._conn.execute("SELECT kind, COUNT(*) FROM edges GROUP BY kind").fetchall():
485
+ edge_kind_counts[row[0]] = row[1]
486
+
487
+ return {
488
+ "nodes": node_count,
489
+ "edges": edge_count,
490
+ "files": file_count,
491
+ "flows": flow_count,
492
+ "communities": community_count,
493
+ "routes": route_count,
494
+ "schemas": schema_count,
495
+ "nodes_by_kind": kind_counts,
496
+ "edges_by_kind": edge_kind_counts,
497
+ "fts_available": self.fts_available,
498
+ "schema_version": self._schema_version,
499
+ }
500
+
501
+ # ------------------------------------------------------------------
502
+ # Batch helpers
503
+ # ------------------------------------------------------------------
504
+
505
+ def batch_get_nodes(self, qualified_names: set[str]) -> list[sqlite3.Row]:
506
+ """Fetch nodes by qualified names in batches."""
507
+ results = []
508
+ qns = list(qualified_names)
509
+ for i in range(0, len(qns), BATCH_SIZE):
510
+ batch = qns[i : i + BATCH_SIZE]
511
+ placeholders = ",".join("?" for _ in batch)
512
+ rows = self._conn.execute(
513
+ f"SELECT * FROM nodes WHERE qualified_name IN ({placeholders})",
514
+ batch,
515
+ ).fetchall()
516
+ results.extend(rows)
517
+ return results