node-walk 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.
- node_walk/__init__.py +3 -0
- node_walk/analysis/__init__.py +12 -0
- node_walk/analysis/base.py +205 -0
- node_walk/analysis/python/__init__.py +10 -0
- node_walk/analysis/python/analyzer.py +44 -0
- node_walk/analysis/python/scope.py +44 -0
- node_walk/analysis/python/visitor.py +559 -0
- node_walk/analysis/python_analyzer.py +14 -0
- node_walk/cli/__init__.py +1 -0
- node_walk/cli/main.py +560 -0
- node_walk/indexer.py +188 -0
- node_walk/ir/__init__.py +39 -0
- node_walk/ir/enums.py +61 -0
- node_walk/ir/models.py +100 -0
- node_walk/query/__init__.py +1 -0
- node_walk/query/engine.py +368 -0
- node_walk/storage/__init__.py +13 -0
- node_walk/storage/base.py +79 -0
- node_walk/storage/repository.py +14 -0
- node_walk/storage/schema.py +130 -0
- node_walk/storage/sqlite_store.py +285 -0
- node_walk-0.1.0.dist-info/METADATA +107 -0
- node_walk-0.1.0.dist-info/RECORD +26 -0
- node_walk-0.1.0.dist-info/WHEEL +4 -0
- node_walk-0.1.0.dist-info/entry_points.txt +4 -0
- node_walk-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query engine — all semantic graph navigation operations.
|
|
3
|
+
|
|
4
|
+
This is the single layer that knows about graph traversal. The CLI,
|
|
5
|
+
future browser API, and LLM skills all go through this module.
|
|
6
|
+
|
|
7
|
+
All traversal operations (walk, trace, blast_radius) run directly on
|
|
8
|
+
SQLite using recursive CTEs — no in-memory graph library needed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus, SymbolKind
|
|
19
|
+
from node_walk.ir.models import Relationship, Symbol, SourceLocation
|
|
20
|
+
from node_walk.storage.base import GraphStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Result types
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SymbolMatch:
|
|
30
|
+
"""A symbol returned by find_symbol(), possibly with a match score."""
|
|
31
|
+
symbol: Symbol
|
|
32
|
+
score: float = 1.0 # 1.0 = exact, < 1.0 = fuzzy
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SourceRange:
|
|
37
|
+
"""Exact source location returned by get_source()."""
|
|
38
|
+
file_path: str
|
|
39
|
+
start_line: int
|
|
40
|
+
end_line: int
|
|
41
|
+
lines: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class WalkResult:
|
|
46
|
+
"""A symbol reached during a bounded graph walk."""
|
|
47
|
+
symbol: Symbol
|
|
48
|
+
depth: int
|
|
49
|
+
via_relationship: RelationshipType | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Query engine
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class QueryEngine:
|
|
58
|
+
"""
|
|
59
|
+
Semantic graph navigation engine.
|
|
60
|
+
|
|
61
|
+
Instantiate with a GraphStore and call query methods directly.
|
|
62
|
+
All methods return typed result objects, never raw DB rows.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, store: GraphStore) -> None:
|
|
66
|
+
self._store = store
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Symbol lookup
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def find_symbol(self, query: str, limit: int = 20) -> list[SymbolMatch]:
|
|
73
|
+
"""
|
|
74
|
+
Find symbols whose name or qualified name matches *query*.
|
|
75
|
+
|
|
76
|
+
Matching priority:
|
|
77
|
+
1. Exact qualified_name match
|
|
78
|
+
2. Exact name match
|
|
79
|
+
3. Case-insensitive suffix match (e.g. "createUser" matches "module.Class.createUser")
|
|
80
|
+
4. Substring match on name
|
|
81
|
+
"""
|
|
82
|
+
results: list[SymbolMatch] = []
|
|
83
|
+
seen: set[str] = set()
|
|
84
|
+
|
|
85
|
+
def add(sym: Symbol, score: float) -> None:
|
|
86
|
+
if sym.id not in seen:
|
|
87
|
+
seen.add(sym.id)
|
|
88
|
+
results.append(SymbolMatch(symbol=sym, score=score))
|
|
89
|
+
|
|
90
|
+
# 1. Exact qualified name
|
|
91
|
+
for s in self._store.find_symbols_by_qualified_name(query):
|
|
92
|
+
if s.qualified_name == query:
|
|
93
|
+
add(s, 1.0)
|
|
94
|
+
|
|
95
|
+
# 2. Exact name
|
|
96
|
+
for s in self._store.find_symbols_by_name(query, exact=True):
|
|
97
|
+
add(s, 0.95)
|
|
98
|
+
|
|
99
|
+
# 3. Case-insensitive suffix / substring
|
|
100
|
+
for s in self._store.find_symbols_by_name(query, exact=False):
|
|
101
|
+
score = 0.8 if s.qualified_name.lower().endswith(query.lower()) else 0.6
|
|
102
|
+
add(s, score)
|
|
103
|
+
|
|
104
|
+
results.sort(key=lambda m: -m.score)
|
|
105
|
+
return results[:limit]
|
|
106
|
+
|
|
107
|
+
def get_symbol_by_id(self, symbol_id: str) -> Symbol | None:
|
|
108
|
+
return self._store.get_symbol(symbol_id)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Definition
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def get_definition(self, symbol_id: str) -> Symbol | None:
|
|
115
|
+
"""Return the canonical definition symbol for *symbol_id*."""
|
|
116
|
+
return self._store.get_symbol(symbol_id)
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Callers / callees
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def get_callers(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
|
|
123
|
+
"""
|
|
124
|
+
Return all symbols that directly call *symbol_id*.
|
|
125
|
+
Returns (caller_symbol, relationship) pairs.
|
|
126
|
+
"""
|
|
127
|
+
rels = self._store.get_relationships_to(symbol_id, RelationshipType.CALLS)
|
|
128
|
+
return self._resolve_source_pairs(rels)
|
|
129
|
+
|
|
130
|
+
def get_callees(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
|
|
131
|
+
"""Return all symbols directly called by *symbol_id*."""
|
|
132
|
+
rels = self._store.get_relationships_from(symbol_id, RelationshipType.CALLS)
|
|
133
|
+
return self._resolve_target_pairs(rels)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# References
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def get_references(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
|
|
140
|
+
"""Return all symbols that reference (but don't call) *symbol_id*."""
|
|
141
|
+
rels = self._store.get_relationships_to(symbol_id, RelationshipType.REFERENCES)
|
|
142
|
+
return self._resolve_source_pairs(rels)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Implementations / extensions
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def get_implementations(self, symbol_id: str) -> list[Symbol]:
|
|
149
|
+
"""
|
|
150
|
+
Return symbols that IMPLEMENTS or EXTENDS *symbol_id*.
|
|
151
|
+
Useful for finding concrete implementations of ABCs / Protocols.
|
|
152
|
+
"""
|
|
153
|
+
impls = self._store.get_relationships_to(symbol_id, RelationshipType.IMPLEMENTS)
|
|
154
|
+
exts = self._store.get_relationships_to(symbol_id, RelationshipType.EXTENDS)
|
|
155
|
+
syms: list[Symbol] = []
|
|
156
|
+
for rel in impls + exts:
|
|
157
|
+
s = self._store.get_symbol(rel.source_id)
|
|
158
|
+
if s:
|
|
159
|
+
syms.append(s)
|
|
160
|
+
return syms
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Imports
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def get_importers(self, symbol_id: str) -> list[Symbol]:
|
|
167
|
+
"""Return files/modules that import *symbol_id*."""
|
|
168
|
+
rels = self._store.get_relationships_to(symbol_id, RelationshipType.IMPORTS)
|
|
169
|
+
return [s for rel in rels if (s := self._store.get_symbol(rel.source_id))]
|
|
170
|
+
|
|
171
|
+
def get_imports(self, symbol_id: str) -> list[Relationship]:
|
|
172
|
+
"""Return all IMPORTS relationships from *symbol_id*."""
|
|
173
|
+
return self._store.get_relationships_from(symbol_id, RelationshipType.IMPORTS)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Containment
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def get_children(self, symbol_id: str) -> list[Symbol]:
|
|
180
|
+
"""Return symbols directly contained by *symbol_id*."""
|
|
181
|
+
rels = self._store.get_relationships_from(symbol_id, RelationshipType.CONTAINS)
|
|
182
|
+
return [s for rel in rels if (s := self._store.get_symbol(rel.target_id))]
|
|
183
|
+
|
|
184
|
+
def get_parents(self, symbol_id: str) -> list[Symbol]:
|
|
185
|
+
"""Return symbols that contain *symbol_id*."""
|
|
186
|
+
rels = self._store.get_relationships_to(symbol_id, RelationshipType.CONTAINS)
|
|
187
|
+
return [s for rel in rels if (s := self._store.get_symbol(rel.source_id))]
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Bounded graph walk (BFS via SQLite recursive CTE)
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def walk(
|
|
194
|
+
self,
|
|
195
|
+
start_id: str,
|
|
196
|
+
rel_types: list[RelationshipType] | None = None,
|
|
197
|
+
direction: Literal["out", "in", "both"] = "out",
|
|
198
|
+
depth: int = 3,
|
|
199
|
+
) -> list[WalkResult]:
|
|
200
|
+
"""
|
|
201
|
+
BFS walk from *start_id* along *rel_types* edges up to *depth* hops.
|
|
202
|
+
|
|
203
|
+
direction:
|
|
204
|
+
"out" — follow source → target
|
|
205
|
+
"in" — follow target → source (reverse)
|
|
206
|
+
"both" — both directions
|
|
207
|
+
|
|
208
|
+
Returns a list of WalkResult ordered by depth.
|
|
209
|
+
"""
|
|
210
|
+
rel_type_values = [r.value for r in rel_types] if rel_types else None
|
|
211
|
+
rows = self._walk_sql(start_id, rel_type_values, direction, depth)
|
|
212
|
+
|
|
213
|
+
results: list[WalkResult] = []
|
|
214
|
+
for row in rows:
|
|
215
|
+
sym = self._store.get_symbol(row["symbol_id"])
|
|
216
|
+
if sym and sym.id != start_id:
|
|
217
|
+
results.append(
|
|
218
|
+
WalkResult(
|
|
219
|
+
symbol=sym,
|
|
220
|
+
depth=row["depth"],
|
|
221
|
+
via_relationship=RelationshipType(row["rel_type"]) if row["rel_type"] else None,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return results
|
|
225
|
+
|
|
226
|
+
def _walk_sql(
|
|
227
|
+
self,
|
|
228
|
+
start_id: str,
|
|
229
|
+
rel_types: list[str] | None,
|
|
230
|
+
direction: str,
|
|
231
|
+
depth: int,
|
|
232
|
+
) -> list[sqlite3.Row]:
|
|
233
|
+
"""
|
|
234
|
+
Execute a recursive CTE to perform bounded BFS on the relationships table.
|
|
235
|
+
Returns raw rows with (symbol_id, depth, rel_type).
|
|
236
|
+
"""
|
|
237
|
+
conn = self._store._conn # type: ignore[attr-defined]
|
|
238
|
+
|
|
239
|
+
if direction == "out":
|
|
240
|
+
edge_join = "JOIN relationships r ON r.source_id = walk.symbol_id"
|
|
241
|
+
next_sym = "r.target_id"
|
|
242
|
+
elif direction == "in":
|
|
243
|
+
edge_join = "JOIN relationships r ON r.target_id = walk.symbol_id"
|
|
244
|
+
next_sym = "r.source_id"
|
|
245
|
+
else: # both
|
|
246
|
+
edge_join = "JOIN relationships r ON (r.source_id = walk.symbol_id OR r.target_id = walk.symbol_id)"
|
|
247
|
+
next_sym = "CASE WHEN r.source_id = walk.symbol_id THEN r.target_id ELSE r.source_id END"
|
|
248
|
+
|
|
249
|
+
sql = f"""
|
|
250
|
+
WITH RECURSIVE walk(symbol_id, depth, rel_type) AS (
|
|
251
|
+
SELECT ?, 0, NULL
|
|
252
|
+
UNION
|
|
253
|
+
SELECT {next_sym}, walk.depth + 1, r.type
|
|
254
|
+
FROM walk
|
|
255
|
+
{edge_join}
|
|
256
|
+
WHERE walk.depth < ?
|
|
257
|
+
AND {next_sym} != ''
|
|
258
|
+
)
|
|
259
|
+
SELECT DISTINCT symbol_id, depth, rel_type FROM walk ORDER BY depth
|
|
260
|
+
"""
|
|
261
|
+
# Execute with just start_id + depth; filter by rel_type in Python
|
|
262
|
+
# (avoids the complexity of duplicating ? params across the UNION)
|
|
263
|
+
rows = conn.execute(sql, [start_id, depth]).fetchall()
|
|
264
|
+
if rel_types:
|
|
265
|
+
rows = [r for r in rows if r["rel_type"] is None or r["rel_type"] in rel_types]
|
|
266
|
+
return rows
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Trace
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def trace(
|
|
273
|
+
self,
|
|
274
|
+
start_id: str,
|
|
275
|
+
depth: int = 5,
|
|
276
|
+
rel_types: list[RelationshipType] | None = None,
|
|
277
|
+
) -> list[WalkResult]:
|
|
278
|
+
"""
|
|
279
|
+
Follow outgoing edges (default: CALLS + IMPORTS) from start to depth.
|
|
280
|
+
Returns all reachable nodes in BFS order.
|
|
281
|
+
"""
|
|
282
|
+
if rel_types is None:
|
|
283
|
+
rel_types = [RelationshipType.CALLS, RelationshipType.IMPORTS]
|
|
284
|
+
return self.walk(start_id, rel_types=rel_types, direction="out", depth=depth)
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
# Blast radius
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def blast_radius(
|
|
291
|
+
self,
|
|
292
|
+
start_id: str,
|
|
293
|
+
depth: int = 3,
|
|
294
|
+
rel_types: list[RelationshipType] | None = None,
|
|
295
|
+
) -> list[WalkResult]:
|
|
296
|
+
"""
|
|
297
|
+
Walk *inward* (reverse edges) to find everything that depends on *start_id*.
|
|
298
|
+
Default relationship types: CALLS, IMPORTS, REFERENCES.
|
|
299
|
+
"""
|
|
300
|
+
if rel_types is None:
|
|
301
|
+
rel_types = [
|
|
302
|
+
RelationshipType.CALLS,
|
|
303
|
+
RelationshipType.IMPORTS,
|
|
304
|
+
RelationshipType.REFERENCES,
|
|
305
|
+
]
|
|
306
|
+
return self.walk(start_id, rel_types=rel_types, direction="in", depth=depth)
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
# Source retrieval
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def get_source(self, symbol_id: str) -> SourceRange | None:
|
|
313
|
+
"""
|
|
314
|
+
Return the exact source lines for a symbol.
|
|
315
|
+
Reads from disk on every call (no caching in MVP).
|
|
316
|
+
"""
|
|
317
|
+
sym = self._store.get_symbol(symbol_id)
|
|
318
|
+
if not sym:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
file_info = self._store.get_file(sym.file_id)
|
|
322
|
+
if not file_info:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
all_lines = Path(file_info.path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
327
|
+
except OSError:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
# Convert to 0-indexed slice
|
|
331
|
+
start = max(0, sym.start_line - 1)
|
|
332
|
+
end = min(len(all_lines), sym.end_line)
|
|
333
|
+
snippet = all_lines[start:end]
|
|
334
|
+
|
|
335
|
+
return SourceRange(
|
|
336
|
+
file_path=file_info.path,
|
|
337
|
+
start_line=sym.start_line,
|
|
338
|
+
end_line=sym.end_line,
|
|
339
|
+
lines=snippet,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Stats
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def stats(self) -> dict[str, int]:
|
|
347
|
+
return self._store.stats()
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
# Private helpers
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def _resolve_source_pairs(self, rels: list[Relationship]) -> list[tuple[Symbol, Relationship]]:
|
|
354
|
+
pairs: list[tuple[Symbol, Relationship]] = []
|
|
355
|
+
for rel in rels:
|
|
356
|
+
s = self._store.get_symbol(rel.source_id)
|
|
357
|
+
if s:
|
|
358
|
+
pairs.append((s, rel))
|
|
359
|
+
return pairs
|
|
360
|
+
|
|
361
|
+
def _resolve_target_pairs(self, rels: list[Relationship]) -> list[tuple[Symbol, Relationship]]:
|
|
362
|
+
pairs: list[tuple[Symbol, Relationship]] = []
|
|
363
|
+
for rel in rels:
|
|
364
|
+
if rel.target_id:
|
|
365
|
+
s = self._store.get_symbol(rel.target_id)
|
|
366
|
+
if s:
|
|
367
|
+
pairs.append((s, rel))
|
|
368
|
+
return pairs
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node_walk.storage — graph persistence layer.
|
|
3
|
+
|
|
4
|
+
Canonical imports:
|
|
5
|
+
from node_walk.storage.base import GraphStore
|
|
6
|
+
from node_walk.storage.sqlite_store import SQLiteGraphStore
|
|
7
|
+
from node_walk.storage import schema
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from node_walk.storage.base import GraphStore
|
|
11
|
+
from node_walk.storage.sqlite_store import SQLiteGraphStore
|
|
12
|
+
|
|
13
|
+
__all__ = ["GraphStore", "SQLiteGraphStore"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage base — abstract GraphStore interface.
|
|
3
|
+
|
|
4
|
+
All storage backends must implement GraphStore. This keeps the
|
|
5
|
+
query engine and indexer decoupled from any concrete technology.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
from node_walk.ir.models import AnalysisResult, FileInfo, Relationship, Symbol
|
|
13
|
+
from node_walk.ir.enums import RelationshipType, ResolutionStatus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GraphStore(ABC):
|
|
17
|
+
"""Abstract graph storage backend."""
|
|
18
|
+
|
|
19
|
+
# --- Write operations ---------------------------------------------------
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def store_result(self, result: AnalysisResult) -> None:
|
|
23
|
+
"""Persist a single file's analysis result (file + symbols + relationships)."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def store_results(self, results: list[AnalysisResult]) -> None:
|
|
28
|
+
"""Persist multiple files' results in a single transaction."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def clear(self) -> None:
|
|
33
|
+
"""Remove all data. Used before a full re-index."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def update_relationship(
|
|
38
|
+
self, rel_id: str, target_id: str, resolution: ResolutionStatus
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Update a relationship's resolved target and status (cross-file resolution pass)."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
# --- Read operations ----------------------------------------------------
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get_file(self, file_id: str) -> FileInfo | None: ...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def get_all_files(self) -> list[FileInfo]: ...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def get_symbol(self, symbol_id: str) -> Symbol | None: ...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def find_symbols_by_name(self, name: str, exact: bool = False) -> list[Symbol]: ...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def find_symbols_by_qualified_name(self, qname: str) -> list[Symbol]: ...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def get_all_symbols(self) -> list[Symbol]: ...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_relationships_from(
|
|
65
|
+
self, symbol_id: str, rel_type: RelationshipType | None = None
|
|
66
|
+
) -> list[Relationship]: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get_relationships_to(
|
|
70
|
+
self, symbol_id: str, rel_type: RelationshipType | None = None
|
|
71
|
+
) -> list[Relationship]: ...
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def get_all_unresolved_relationships(self) -> list[Relationship]: ...
|
|
75
|
+
|
|
76
|
+
# --- Stats --------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def stats(self) -> dict[str, int]: ...
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node_walk.storage.repository — backward-compatibility shim.
|
|
3
|
+
|
|
4
|
+
The GraphStore interface and SQLiteGraphStore implementation now live in:
|
|
5
|
+
- node_walk.storage.base (GraphStore ABC)
|
|
6
|
+
- node_walk.storage.sqlite_store (SQLiteGraphStore)
|
|
7
|
+
|
|
8
|
+
This module re-exports both so existing imports continue to work.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from node_walk.storage.base import GraphStore
|
|
12
|
+
from node_walk.storage.sqlite_store import SQLiteGraphStore
|
|
13
|
+
|
|
14
|
+
__all__ = ["GraphStore", "SQLiteGraphStore"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite schema for CodeGraph.
|
|
3
|
+
|
|
4
|
+
Tables:
|
|
5
|
+
files — one row per discovered source file
|
|
6
|
+
symbols — one row per code symbol (class, function, …)
|
|
7
|
+
relationships — one row per semantic edge in the graph
|
|
8
|
+
|
|
9
|
+
The schema is created idempotently using CREATE TABLE IF NOT EXISTS,
|
|
10
|
+
so it is safe to call initialize() multiple times.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# DDL
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_CREATE_FILES = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
path TEXT NOT NULL UNIQUE,
|
|
25
|
+
language TEXT NOT NULL,
|
|
26
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
27
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_CREATE_SYMBOLS = """
|
|
33
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
qualified_name TEXT NOT NULL,
|
|
37
|
+
kind TEXT NOT NULL,
|
|
38
|
+
language TEXT NOT NULL,
|
|
39
|
+
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
40
|
+
start_line INTEGER NOT NULL,
|
|
41
|
+
end_line INTEGER NOT NULL,
|
|
42
|
+
signature TEXT NOT NULL DEFAULT '',
|
|
43
|
+
parent_id TEXT REFERENCES symbols(id) ON DELETE SET NULL,
|
|
44
|
+
docstring TEXT NOT NULL DEFAULT '',
|
|
45
|
+
is_async INTEGER NOT NULL DEFAULT 0
|
|
46
|
+
);
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_CREATE_RELATIONSHIPS = """
|
|
50
|
+
CREATE TABLE IF NOT EXISTS relationships (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
source_id TEXT NOT NULL,
|
|
53
|
+
target_id TEXT NOT NULL DEFAULT '',
|
|
54
|
+
type TEXT NOT NULL,
|
|
55
|
+
source_file_id TEXT,
|
|
56
|
+
source_line INTEGER,
|
|
57
|
+
source_col INTEGER,
|
|
58
|
+
resolution TEXT NOT NULL DEFAULT 'resolved',
|
|
59
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
60
|
+
);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Indexes
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
_CREATE_INDEXES = [
|
|
68
|
+
"CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);",
|
|
69
|
+
"CREATE INDEX IF NOT EXISTS idx_symbols_qname ON symbols(qualified_name);",
|
|
70
|
+
"CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id);",
|
|
71
|
+
"CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);",
|
|
72
|
+
"CREATE INDEX IF NOT EXISTS idx_symbols_parent_id ON symbols(parent_id);",
|
|
73
|
+
"CREATE INDEX IF NOT EXISTS idx_rels_source_id ON relationships(source_id);",
|
|
74
|
+
"CREATE INDEX IF NOT EXISTS idx_rels_target_id ON relationships(target_id);",
|
|
75
|
+
"CREATE INDEX IF NOT EXISTS idx_rels_type ON relationships(type);",
|
|
76
|
+
"CREATE INDEX IF NOT EXISTS idx_rels_source_type ON relationships(source_id, type);",
|
|
77
|
+
"CREATE INDEX IF NOT EXISTS idx_rels_target_type ON relationships(target_id, type);",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Schema metadata
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
_CREATE_META = """
|
|
85
|
+
CREATE TABLE IF NOT EXISTS node_walk_meta (
|
|
86
|
+
key TEXT PRIMARY KEY,
|
|
87
|
+
value TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
_SCHEMA_VERSION = "1"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Public function
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def initialize(conn: sqlite3.Connection) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Create all tables and indexes in *conn* if they do not already exist.
|
|
102
|
+
Safe to call repeatedly; never drops data.
|
|
103
|
+
"""
|
|
104
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
105
|
+
conn.execute("PRAGMA foreign_keys=ON;")
|
|
106
|
+
|
|
107
|
+
conn.execute(_CREATE_FILES)
|
|
108
|
+
conn.execute(_CREATE_SYMBOLS)
|
|
109
|
+
conn.execute(_CREATE_RELATIONSHIPS)
|
|
110
|
+
conn.execute(_CREATE_META)
|
|
111
|
+
|
|
112
|
+
for idx_sql in _CREATE_INDEXES:
|
|
113
|
+
conn.execute(idx_sql)
|
|
114
|
+
|
|
115
|
+
conn.execute(
|
|
116
|
+
"INSERT OR IGNORE INTO node_walk_meta(key, value) VALUES (?, ?)",
|
|
117
|
+
("schema_version", _SCHEMA_VERSION),
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_schema_version(conn: sqlite3.Connection) -> str | None:
|
|
123
|
+
"""Return the stored schema version, or None if the meta table is missing/empty."""
|
|
124
|
+
try:
|
|
125
|
+
row = conn.execute(
|
|
126
|
+
"SELECT value FROM node_walk_meta WHERE key = 'schema_version'"
|
|
127
|
+
).fetchone()
|
|
128
|
+
return row[0] if row else None
|
|
129
|
+
except sqlite3.OperationalError:
|
|
130
|
+
return None
|