codegraph-brain 0.6.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 (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,589 @@
1
+ """Implements Sqlite store for code graph."""
2
+
3
+ import json
4
+ import sqlite3
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from cgis.core.models import Edge, EdgeType, Node, NodeNamespace, NodeType
9
+
10
+ RAW_CALL_PREFIX = "raw_call:"
11
+
12
+
13
+ @dataclass
14
+ class EdgeStats:
15
+ """Aggregated edge statistics returned by get_edge_stats()."""
16
+
17
+ total: int
18
+ resolved: int # edges whose target is an INTERNAL node
19
+ stdlib: int # edges whose target is a STDLIB virtual node
20
+ external: int # edges whose target is an EXTERNAL virtual node
21
+ unresolved: (
22
+ int # edges whose target is UNKNOWN or raw_call: (post-resolver only UNKNOWN matters)
23
+ )
24
+ unresolved_ratio: float # (unresolved + unknown) / total
25
+ top_unresolved: list[tuple[str, int]] = field(default_factory=list) # top UNKNOWN targets
26
+
27
+
28
+ class SQLiteStore:
29
+ """
30
+ Deterministic SQLite Graph Store.
31
+ Manages persistence of Nodes and Edges with high performance indexing.
32
+ """
33
+
34
+ def __init__(self, db_path: str) -> None:
35
+ """Initialise the store with a path to the SQLite database file."""
36
+ self.db_path = db_path
37
+ self._conn: sqlite3.Connection | None = None
38
+ self._error_message = "Database not connected."
39
+
40
+ def connect(self) -> None:
41
+ """Establishes connection and initializes database schema."""
42
+ if self._conn is not None:
43
+ return
44
+ if self.db_path != ":memory:":
45
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
46
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
47
+ self._conn.row_factory = sqlite3.Row
48
+ self._conn.execute("PRAGMA journal_mode=WAL;")
49
+ self._conn.execute("PRAGMA busy_timeout=5000;")
50
+ self._create_schema()
51
+
52
+ def disconnect(self) -> None:
53
+ """Close the SQLite connection if open."""
54
+ if self._conn:
55
+ self._conn.close()
56
+ self._conn = None
57
+
58
+ def __enter__(self) -> "SQLiteStore":
59
+ """Connect and return self for use as a context manager."""
60
+ self.connect()
61
+ return self
62
+
63
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
64
+ """Disconnect on context manager exit regardless of exception state."""
65
+ self.disconnect()
66
+
67
+ def _create_schema(self) -> None:
68
+ """Create nodes, edges, and files_state tables if they do not yet exist."""
69
+ schema = """
70
+ CREATE TABLE IF NOT EXISTS nodes (
71
+ id TEXT PRIMARY KEY,
72
+ type TEXT NOT NULL,
73
+ name TEXT NOT NULL,
74
+ file_path TEXT NOT NULL,
75
+ start_line INTEGER NOT NULL,
76
+ end_line INTEGER NOT NULL,
77
+ language TEXT NOT NULL,
78
+ ontology_class TEXT,
79
+ domains TEXT,
80
+ confidence_score REAL NOT NULL,
81
+ metadata TEXT,
82
+ namespace TEXT NOT NULL DEFAULT 'INTERNAL'
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS edges (
86
+ id TEXT PRIMARY KEY,
87
+ source TEXT NOT NULL,
88
+ target TEXT NOT NULL,
89
+ type TEXT NOT NULL,
90
+ weight REAL NOT NULL,
91
+ confidence REAL NOT NULL,
92
+ context TEXT,
93
+ file_path TEXT,
94
+ line_number INTEGER
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS files_state (
98
+ file_path TEXT PRIMARY KEY,
99
+ hash TEXT NOT NULL
100
+ );
101
+
102
+ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
103
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source);
104
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target);
105
+ CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
106
+ CREATE INDEX IF NOT EXISTS idx_edges_file_path ON edges(file_path);
107
+ """
108
+ if self._conn:
109
+ self._conn.executescript(schema)
110
+ self._conn.commit()
111
+ self._migrate()
112
+
113
+ def _migrate(self) -> None:
114
+ """Apply incremental schema migrations for existing databases."""
115
+ if not self._conn:
116
+ return
117
+ cols = {row["name"] for row in self._conn.execute("PRAGMA table_info(nodes)").fetchall()}
118
+ if "namespace" not in cols:
119
+ self._conn.execute(
120
+ "ALTER TABLE nodes ADD COLUMN namespace TEXT NOT NULL DEFAULT 'INTERNAL'"
121
+ )
122
+ self._conn.commit()
123
+
124
+ _NODE_INSERT = """
125
+ INSERT OR REPLACE INTO nodes (
126
+ id, type, name, file_path, start_line, end_line, language,
127
+ ontology_class, domains, confidence_score, metadata, namespace
128
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ """
130
+ _EDGE_INSERT = """
131
+ INSERT OR REPLACE INTO edges (
132
+ id, source, target, type, weight, confidence,
133
+ context, file_path, line_number
134
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
135
+ """
136
+
137
+ def _node_to_row(
138
+ self, n: Node
139
+ ) -> tuple[str, str, str, str, int, int, str, str | None, str, float, str, str]:
140
+ """Serialise a Node into a tuple matching the nodes table column order."""
141
+ return (
142
+ n.id,
143
+ n.type.value,
144
+ n.name,
145
+ n.file_path,
146
+ n.start_line,
147
+ n.end_line,
148
+ n.language,
149
+ n.ontology_class,
150
+ json.dumps(n.domains),
151
+ n.confidence_score,
152
+ json.dumps(n.metadata),
153
+ n.namespace.value,
154
+ )
155
+
156
+ def _edge_to_row(
157
+ self, e: Edge
158
+ ) -> tuple[str, str, str, str, float, float, str | None, str | None, int | None]:
159
+ """Serialise an Edge into a tuple matching the edges table column order."""
160
+ return (
161
+ e.id,
162
+ e.source,
163
+ e.target,
164
+ e.type.value,
165
+ e.weight,
166
+ e.confidence,
167
+ e.context,
168
+ e.file_path,
169
+ e.line_number,
170
+ )
171
+
172
+ def upsert_nodes(self, nodes: list[Node]) -> None:
173
+ """Insert or replace nodes without deleting existing ones first."""
174
+ if not self._conn:
175
+ raise RuntimeError(self._error_message)
176
+ with self._conn:
177
+ self._conn.executemany(self._NODE_INSERT, [self._node_to_row(n) for n in nodes])
178
+
179
+ def upsert_edges(self, edges: list[Edge]) -> None:
180
+ """Insert or replace edges without deleting existing ones first."""
181
+ if not self._conn:
182
+ raise RuntimeError(self._error_message)
183
+ with self._conn:
184
+ self._conn.executemany(self._EDGE_INSERT, [self._edge_to_row(e) for e in edges])
185
+
186
+ def delete_semantic_uplift(self) -> None:
187
+ """Remove all DOMAIN_CONCEPT nodes and DOMAIN_DEPENDS_ON edges."""
188
+ if not self._conn:
189
+ raise RuntimeError(self._error_message)
190
+ with self._conn:
191
+ self._conn.execute("DELETE FROM nodes WHERE type = ?", (NodeType.DOMAIN_CONCEPT.value,))
192
+ self._conn.execute(
193
+ "DELETE FROM edges WHERE type = ?", (EdgeType.DOMAIN_DEPENDS_ON.value,)
194
+ )
195
+
196
+ def get_all_nodes(self) -> list[Node]:
197
+ """Return every node currently in the store."""
198
+ if not self._conn:
199
+ raise RuntimeError(self._error_message)
200
+ cursor = self._conn.execute("SELECT * FROM nodes")
201
+ return [self._row_to_node(row) for row in cursor.fetchall()]
202
+
203
+ def get_all_edges(self) -> list[Edge]:
204
+ """Return every edge currently in the store."""
205
+ if not self._conn:
206
+ raise RuntimeError(self._error_message)
207
+ cursor = self._conn.execute("SELECT * FROM edges")
208
+ return [self._row_to_edge(row) for row in cursor.fetchall()]
209
+
210
+ def save_graph(self, nodes: list[Node], edges: list[Edge], overwrite: bool = False) -> None:
211
+ """Persists all nodes and edges inside a single transaction."""
212
+ if not self._conn:
213
+ raise RuntimeError(self._error_message)
214
+ with self._conn:
215
+ if overwrite:
216
+ self._conn.execute("DELETE FROM nodes")
217
+ self._conn.execute("DELETE FROM edges")
218
+ self._conn.executemany(self._EDGE_INSERT, [self._edge_to_row(e) for e in edges])
219
+ self._conn.executemany(self._NODE_INSERT, [self._node_to_row(n) for n in nodes])
220
+
221
+ def clear(self) -> None:
222
+ """Wipe the whole graph — nodes, edges, and the incremental files_state cache.
223
+
224
+ Used for a full rebuild: re-scanning from an empty database drops nodes
225
+ for deleted/renamed files, and clearing ``files_state`` keeps the
226
+ incremental cache consistent with what the re-scan repopulates (so the
227
+ next incremental run isn't forced to re-parse everything).
228
+ """
229
+ if not self._conn:
230
+ raise RuntimeError(self._error_message)
231
+ with self._conn:
232
+ self._conn.execute("DELETE FROM nodes")
233
+ self._conn.execute("DELETE FROM edges")
234
+ self._conn.execute("DELETE FROM files_state")
235
+
236
+ def get_node_count(self) -> int:
237
+ """Return the total node count via a cheap COUNT(*) (no deserialization)."""
238
+ if not self._conn:
239
+ raise RuntimeError(self._error_message)
240
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM nodes").fetchone()
241
+ return int(row["n"]) if row else 0
242
+
243
+ def get_edge_count(self) -> int:
244
+ """Return the total edge count via a cheap COUNT(*) (no join/aggregation)."""
245
+ if not self._conn:
246
+ raise RuntimeError(self._error_message)
247
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM edges").fetchone()
248
+ return int(row["n"]) if row else 0
249
+
250
+ def get_node(self, node_id: str) -> Node | None:
251
+ """Return a single node by FQN, or None if not found."""
252
+ if not self._conn:
253
+ raise RuntimeError(self._error_message)
254
+ cursor = self._conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,))
255
+ row = cursor.fetchone()
256
+ if not row:
257
+ return None
258
+ return self._row_to_node(row)
259
+
260
+ def find_nodes_by_suffix(self, name: str, limit: int = 10) -> list[Node]:
261
+ """Find nodes whose FQN ends with ``.name`` at a dot boundary.
262
+
263
+ Returns only dot-boundary suffix matches ordered by id, capped at
264
+ ``limit``. The id itself is NOT its own dot-boundary suffix — exact
265
+ match policy (``get_node`` short-circuit) lives in the caller
266
+ (``resolve_fqn``). LIKE wildcards in ``name`` (``%``, ``_``) are
267
+ escaped — they are literal characters in FQNs.
268
+
269
+ This method was intentionally kept pure (no intra-storage ``get_node``
270
+ call) to avoid a CALLS chain inside the ``cgis.storage`` domain that
271
+ would violate the pure_utility pattern's 021D triad constraint — the
272
+ self-drift guardrail caught this coupling smell during development.
273
+ """
274
+ if not self._conn:
275
+ raise RuntimeError(self._error_message)
276
+ escaped = name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
277
+ cursor = self._conn.execute(
278
+ "SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' ORDER BY id LIMIT ?",
279
+ (f"%.{escaped}", limit),
280
+ )
281
+ return [self._row_to_node(row) for row in cursor.fetchall()]
282
+
283
+ def search_nodes(
284
+ self,
285
+ query: str,
286
+ kinds: tuple[str, ...] = (),
287
+ fqn_prefix: str | None = None,
288
+ limit: int = 20,
289
+ ) -> list[Node]:
290
+ """Find nodes whose leaf ``name`` contains ``query`` (substring), ranked.
291
+
292
+ Ranking: exact name match > name-prefix match > substring; ties broken by
293
+ shorter FQN then id (deterministic). ``kinds`` filters by NodeType value
294
+ (e.g. ``("FUNCTION", "METHOD")``); ``fqn_prefix`` scopes to ids under that
295
+ prefix. LIKE wildcards (``%``, ``_``) in inputs are escaped — they are
296
+ literal characters in names/FQNs. Powers ``cgis_find_symbol`` (#173).
297
+ """
298
+ if not self._conn:
299
+ raise RuntimeError(self._error_message)
300
+ if not query.strip():
301
+ return []
302
+ esc = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
303
+ sql = "SELECT * FROM nodes WHERE name LIKE ? ESCAPE '\\'"
304
+ params: list[str | int] = [f"%{esc}%"]
305
+ if kinds:
306
+ placeholders = ", ".join(["?"] * len(kinds))
307
+ sql += f" AND type IN ({placeholders})"
308
+ params.extend(kinds)
309
+ if fqn_prefix:
310
+ pfx = fqn_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
311
+ # Segment-boundary match: the prefix itself or a child under it — never
312
+ # `app.svc` accidentally matching `app.svc_alternative` (mirrors _in_domain).
313
+ sql += " AND (id = ? OR id LIKE ? ESCAPE '\\')"
314
+ params.extend([fqn_prefix, f"{pfx}.%"])
315
+ sql += (
316
+ " ORDER BY CASE WHEN name = ? THEN 0 WHEN name LIKE ? ESCAPE '\\' THEN 1 ELSE 2 END,"
317
+ " LENGTH(id), id LIMIT ?"
318
+ )
319
+ params.extend([query, f"{esc}%", limit])
320
+ cursor = self._conn.execute(sql, params)
321
+ return [self._row_to_node(row) for row in cursor.fetchall()]
322
+
323
+ def get_nodes(self, node_ids: list[str]) -> list[Node]:
324
+ """Return nodes matching the given FQN list, fetched in 999-item chunks."""
325
+ if not self._conn:
326
+ raise RuntimeError(self._error_message)
327
+ if not node_ids:
328
+ return []
329
+ unique_ids = list(set(node_ids))
330
+ # SQLite has a limit on the number of host parameters (usually 999)
331
+ chunk_size = 999
332
+ nodes = []
333
+ for i in range(0, len(unique_ids), chunk_size):
334
+ chunk = unique_ids[i : i + chunk_size]
335
+ placeholders = ", ".join(["?"] * len(chunk))
336
+ query = f"SELECT * FROM nodes WHERE id IN ({placeholders})"
337
+ cursor = self._conn.execute(query, chunk)
338
+ nodes.extend([self._row_to_node(row) for row in cursor.fetchall()])
339
+ return nodes
340
+
341
+ def get_outgoing_edges(self, node_id: str) -> list[Edge]:
342
+ """Return all edges where the given node is the source."""
343
+ if not self._conn:
344
+ raise RuntimeError(self._error_message)
345
+ cursor = self._conn.execute("SELECT * FROM edges WHERE source = ?", (node_id,))
346
+ return [self._row_to_edge(row) for row in cursor.fetchall()]
347
+
348
+ def get_incoming_edges(self, node_id: str) -> list[Edge]:
349
+ """Return all edges where the given node is the target."""
350
+ if not self._conn:
351
+ raise RuntimeError(self._error_message)
352
+ cursor = self._conn.execute("SELECT * FROM edges WHERE target = ?", (node_id,))
353
+ return [self._row_to_edge(row) for row in cursor.fetchall()]
354
+
355
+ def get_outgoing_edges_batch(self, node_ids: list[str]) -> list[Edge]:
356
+ """Fetch all outgoing edges for a set of nodes in one query per chunk."""
357
+ return self._get_edges_batch(node_ids, column="source")
358
+
359
+ def get_incoming_edges_batch(self, node_ids: list[str]) -> list[Edge]:
360
+ """Fetch all incoming edges for a set of nodes in one query per chunk."""
361
+ return self._get_edges_batch(node_ids, column="target")
362
+
363
+ def _get_edges_batch(self, node_ids: list[str], column: str) -> list[Edge]:
364
+ """Fetch edges for many nodes at once, chunked to respect SQLite's 999-param limit."""
365
+ if column not in ("source", "target"):
366
+ msg = f"Invalid column: {column!r}. Must be 'source' or 'target'."
367
+ raise ValueError(msg)
368
+ if not self._conn:
369
+ raise RuntimeError(self._error_message)
370
+ if not node_ids:
371
+ return []
372
+ unique_ids = list(set(node_ids))
373
+ chunk_size = 999
374
+ edges: list[Edge] = []
375
+ for i in range(0, len(unique_ids), chunk_size):
376
+ chunk = unique_ids[i : i + chunk_size]
377
+ placeholders = ", ".join(["?"] * len(chunk))
378
+ query = f"SELECT * FROM edges WHERE {column} IN ({placeholders})"
379
+ cursor = self._conn.execute(query, chunk)
380
+ edges.extend(self._row_to_edge(row) for row in cursor.fetchall())
381
+ return edges
382
+
383
+ def get_file_hash(self, file_path: str) -> str | None:
384
+ """Return the stored hash for a file, or None if not yet tracked."""
385
+ if not self._conn:
386
+ raise RuntimeError(self._error_message)
387
+ cursor = self._conn.execute(
388
+ "SELECT hash FROM files_state WHERE file_path = ?", (file_path,)
389
+ )
390
+ row = cursor.fetchone()
391
+ return str(row["hash"]) if row else None
392
+
393
+ def upsert_file_hash(self, file_path: str, file_hash: str) -> None:
394
+ """Insert or update the hash for a file."""
395
+ if not self._conn:
396
+ raise RuntimeError(self._error_message)
397
+ with self._conn:
398
+ self._conn.execute(
399
+ "INSERT OR REPLACE INTO files_state (file_path, hash) VALUES (?, ?)",
400
+ (file_path, file_hash),
401
+ )
402
+
403
+ def delete_file_data(self, file_path: str) -> None:
404
+ """Delete all nodes and edges associated with a file."""
405
+ if not self._conn:
406
+ raise RuntimeError(self._error_message)
407
+ with self._conn:
408
+ # By source (handles structural edges with file_path=None) and by file_path
409
+ self._conn.execute(
410
+ "DELETE FROM edges WHERE source IN (SELECT id FROM nodes WHERE file_path = ?)",
411
+ (file_path,),
412
+ )
413
+ self._conn.execute("DELETE FROM edges WHERE file_path = ?", (file_path,))
414
+ self._conn.execute("DELETE FROM nodes WHERE file_path = ?", (file_path,))
415
+ self._conn.execute("DELETE FROM files_state WHERE file_path = ?", (file_path,))
416
+
417
+ def save_incremental_batch(
418
+ self,
419
+ nodes_by_file: dict[str, list[Node]],
420
+ edges_by_file: dict[str, list[Edge]],
421
+ file_hashes: dict[str, str],
422
+ stale_files: set[str],
423
+ ) -> None:
424
+ """Atomically delete changed/stale files and insert new data in one transaction."""
425
+ if not self._conn:
426
+ raise RuntimeError(self._error_message)
427
+ all_changed = set(file_hashes) | stale_files
428
+ with self._conn:
429
+ for file_path in all_changed:
430
+ self._conn.execute(
431
+ "DELETE FROM edges WHERE source IN (SELECT id FROM nodes WHERE file_path = ?)",
432
+ (file_path,),
433
+ )
434
+ self._conn.execute("DELETE FROM edges WHERE file_path = ?", (file_path,))
435
+ self._conn.execute("DELETE FROM nodes WHERE file_path = ?", (file_path,))
436
+ self._conn.execute("DELETE FROM files_state WHERE file_path = ?", (file_path,))
437
+ for nodes in nodes_by_file.values():
438
+ self._conn.executemany(self._NODE_INSERT, [self._node_to_row(n) for n in nodes])
439
+ for edges in edges_by_file.values():
440
+ self._conn.executemany(self._EDGE_INSERT, [self._edge_to_row(e) for e in edges])
441
+ for file_path, hash_val in file_hashes.items():
442
+ self._conn.execute(
443
+ "INSERT OR REPLACE INTO files_state (file_path, hash) VALUES (?, ?)",
444
+ (file_path, hash_val),
445
+ )
446
+
447
+ def get_nodes_by_file(self, file_path: str) -> list[Node]:
448
+ """Return all nodes belonging to a specific file."""
449
+ if not self._conn:
450
+ raise RuntimeError(self._error_message)
451
+ cursor = self._conn.execute("SELECT * FROM nodes WHERE file_path = ?", (file_path,))
452
+ return [self._row_to_node(row) for row in cursor.fetchall()]
453
+
454
+ def get_structural_subgraph(
455
+ self, target_id: str, max_depth: int = 5
456
+ ) -> tuple[list[Node], list[Edge]]:
457
+ """Return the structural hierarchy rooted at target_id via a single recursive CTE.
458
+
459
+ Traverses only CONTAINS and DECLARES edges. One DB round-trip regardless of depth.
460
+ """
461
+ if not self._conn:
462
+ raise RuntimeError(self._error_message)
463
+ self._conn.execute("PRAGMA recursive_triggers = ON")
464
+ nodes_rows = self._conn.execute(
465
+ """
466
+ WITH RECURSIVE tree(id, depth) AS (
467
+ SELECT id, 0 FROM nodes WHERE id = ?
468
+ UNION ALL
469
+ SELECT e.target, t.depth + 1
470
+ FROM edges e
471
+ JOIN tree t ON e.source = t.id
472
+ WHERE e.type IN ('CONTAINS', 'DECLARES') AND t.depth < ?
473
+ )
474
+ SELECT DISTINCT n.*
475
+ FROM nodes n
476
+ JOIN tree t ON n.id = t.id
477
+ """,
478
+ (target_id, max_depth),
479
+ ).fetchall()
480
+ if not nodes_rows:
481
+ return [], []
482
+ nodes = [self._row_to_node(row) for row in nodes_rows]
483
+ edges_rows = self._conn.execute(
484
+ """
485
+ WITH RECURSIVE tree(id, depth) AS (
486
+ SELECT id, 0 FROM nodes WHERE id = ?
487
+ UNION ALL
488
+ SELECT e.target, t.depth + 1
489
+ FROM edges e
490
+ JOIN tree t ON e.source = t.id
491
+ WHERE e.type IN ('CONTAINS', 'DECLARES') AND t.depth < ?
492
+ )
493
+ SELECT DISTINCT e.*
494
+ FROM edges e
495
+ JOIN tree t ON e.source = t.id
496
+ WHERE e.type IN ('CONTAINS', 'DECLARES') AND t.depth < ?
497
+ """,
498
+ (target_id, max_depth, max_depth),
499
+ ).fetchall()
500
+ edges = [self._row_to_edge(row) for row in edges_rows]
501
+ return nodes, edges
502
+
503
+ def get_edge_stats(self) -> EdgeStats:
504
+ """Return resolution statistics for all edges in the graph."""
505
+ if not self._conn:
506
+ raise RuntimeError(self._error_message)
507
+ row = self._conn.execute(
508
+ """
509
+ SELECT
510
+ COUNT(*) AS total,
511
+ COUNT(CASE WHEN n.namespace = 'INTERNAL' THEN 1 END) AS resolved,
512
+ COUNT(CASE WHEN n.namespace = 'STDLIB' THEN 1 END) AS stdlib,
513
+ COUNT(CASE WHEN n.namespace = 'EXTERNAL' THEN 1 END) AS external,
514
+ COUNT(CASE WHEN n.namespace IS NULL OR n.namespace = 'UNKNOWN'
515
+ OR e.target GLOB ? THEN 1 END) AS unresolved
516
+ FROM edges e
517
+ LEFT JOIN nodes n ON e.target = n.id
518
+ """,
519
+ (f"{RAW_CALL_PREFIX}*",),
520
+ ).fetchone()
521
+ if row is None:
522
+ return EdgeStats(0, 0, 0, 0, 0, 0.0)
523
+ total: int = row["total"]
524
+ resolved: int = row["resolved"]
525
+ stdlib: int = row["stdlib"]
526
+ external: int = row["external"]
527
+ unresolved: int = row["unresolved"]
528
+ ratio = unresolved / total if total else 0.0
529
+ rows = self._conn.execute(
530
+ """
531
+ SELECT e.target, COUNT(*) AS cnt
532
+ FROM edges e
533
+ LEFT JOIN nodes n ON e.target = n.id
534
+ WHERE n.namespace = 'UNKNOWN' OR n.id IS NULL
535
+ GROUP BY e.target
536
+ ORDER BY cnt DESC
537
+ LIMIT 10
538
+ """,
539
+ ).fetchall()
540
+ top = [(r["target"], int(r["cnt"])) for r in rows]
541
+ return EdgeStats(
542
+ total=total,
543
+ resolved=resolved,
544
+ stdlib=stdlib,
545
+ external=external,
546
+ unresolved=unresolved,
547
+ unresolved_ratio=ratio,
548
+ top_unresolved=top,
549
+ )
550
+
551
+ def get_all_tracked_files(self) -> set[str]:
552
+ """Return the set of all file paths currently tracked in files_state."""
553
+ if not self._conn:
554
+ raise RuntimeError(self._error_message)
555
+ cursor = self._conn.execute("SELECT file_path FROM files_state")
556
+ return {row["file_path"] for row in cursor.fetchall()}
557
+
558
+ def _row_to_node(self, row: sqlite3.Row) -> Node:
559
+ """Deserialise a SQLite row into a Node model."""
560
+ return Node(
561
+ id=row["id"],
562
+ type=NodeType(row["type"]),
563
+ name=row["name"],
564
+ file_path=row["file_path"],
565
+ start_line=row["start_line"],
566
+ end_line=row["end_line"],
567
+ language=row["language"],
568
+ ontology_class=row["ontology_class"],
569
+ domains=json.loads(row["domains"]) or [] if row["domains"] else [],
570
+ confidence_score=row["confidence_score"],
571
+ metadata=json.loads(row["metadata"]) or {} if row["metadata"] else {},
572
+ namespace=NodeNamespace(row["namespace"])
573
+ if row["namespace"]
574
+ else NodeNamespace.INTERNAL,
575
+ )
576
+
577
+ def _row_to_edge(self, row: sqlite3.Row) -> Edge:
578
+ """Deserialise a SQLite row into an Edge model."""
579
+ return Edge(
580
+ id=row["id"],
581
+ source=row["source"],
582
+ target=row["target"],
583
+ type=EdgeType(row["type"]),
584
+ weight=row["weight"],
585
+ confidence=row["confidence"],
586
+ context=row["context"],
587
+ file_path=row["file_path"],
588
+ line_number=row["line_number"],
589
+ )