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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|