krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/graph.py
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"""Code Knowledge Graph - Phase 1.
|
|
2
|
+
|
|
3
|
+
Builds a queryable map of the codebase using Tree-sitter, NetworkX, and SQLite.
|
|
4
|
+
Tracks what calls what, what imports what, what inherits from what.
|
|
5
|
+
|
|
6
|
+
Schema:
|
|
7
|
+
- Node types: Module (file), Class, Function (methods distinguished by parent_class attribute)
|
|
8
|
+
- Edge types: imports (Module→Module), calls (Function→Function), inherits (Class→Class),
|
|
9
|
+
defines (Module→Class, Module→Function, Class→Function)
|
|
10
|
+
|
|
11
|
+
Sync model:
|
|
12
|
+
- SQLite is source of truth
|
|
13
|
+
- On session start: load full graph from SQLite into in-memory NetworkX MultiDiGraph
|
|
14
|
+
- All reads during session hit in-memory graph only
|
|
15
|
+
- All writes write to SQLite AND patch in-memory graph (never let them drift)
|
|
16
|
+
|
|
17
|
+
Invalidation:
|
|
18
|
+
- Wholesale per-file replace (not incremental diffing)
|
|
19
|
+
- On file save: delete all nodes for that file, re-parse, re-insert, re-resolve
|
|
20
|
+
|
|
21
|
+
Cross-file resolution:
|
|
22
|
+
- Build module path index first
|
|
23
|
+
- Look up imports in index
|
|
24
|
+
- Never resolve by name-similarity guessing
|
|
25
|
+
- External packages recorded as metadata on unresolved edges
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
import sqlite3
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Optional
|
|
34
|
+
|
|
35
|
+
import networkx as nx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Dataclasses
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
@dataclass
|
|
42
|
+
class Node:
|
|
43
|
+
id: str
|
|
44
|
+
type: str # Module, Class, Function
|
|
45
|
+
name: str
|
|
46
|
+
qualified_name: str
|
|
47
|
+
file_path: str
|
|
48
|
+
line_start: int
|
|
49
|
+
line_end: int
|
|
50
|
+
language: str
|
|
51
|
+
parent_class: Optional[str] = None # For methods
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Edge:
|
|
56
|
+
id: str
|
|
57
|
+
source_id: str
|
|
58
|
+
target_id: str
|
|
59
|
+
type: str # imports, calls, inherits, defines
|
|
60
|
+
resolved: bool
|
|
61
|
+
raw_reference: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# Graph Database
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
class GraphDB:
|
|
68
|
+
"""SQLite persistence for the code knowledge graph."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, db_path: str):
|
|
71
|
+
self.db_path = Path(db_path)
|
|
72
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
74
|
+
self._init_db()
|
|
75
|
+
|
|
76
|
+
def _init_db(self) -> None:
|
|
77
|
+
"""Initialize SQLite schema with WAL mode for concurrency."""
|
|
78
|
+
conn = self._get_conn()
|
|
79
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
80
|
+
conn.execute("""
|
|
81
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
type TEXT NOT NULL,
|
|
84
|
+
name TEXT NOT NULL,
|
|
85
|
+
qualified_name TEXT NOT NULL,
|
|
86
|
+
file_path TEXT NOT NULL,
|
|
87
|
+
line_start INTEGER NOT NULL,
|
|
88
|
+
line_end INTEGER NOT NULL,
|
|
89
|
+
language TEXT NOT NULL,
|
|
90
|
+
parent_class TEXT
|
|
91
|
+
)
|
|
92
|
+
""")
|
|
93
|
+
conn.execute("""
|
|
94
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
95
|
+
id TEXT PRIMARY KEY,
|
|
96
|
+
source_id TEXT NOT NULL,
|
|
97
|
+
target_id TEXT NOT NULL,
|
|
98
|
+
type TEXT NOT NULL,
|
|
99
|
+
resolved BOOLEAN NOT NULL,
|
|
100
|
+
raw_reference TEXT,
|
|
101
|
+
FOREIGN KEY (source_id) REFERENCES nodes(id),
|
|
102
|
+
FOREIGN KEY (target_id) REFERENCES nodes(id)
|
|
103
|
+
)
|
|
104
|
+
""")
|
|
105
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path)")
|
|
106
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id)")
|
|
107
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id)")
|
|
108
|
+
conn.commit()
|
|
109
|
+
|
|
110
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
111
|
+
if self._conn is None:
|
|
112
|
+
self._conn = sqlite3.connect(self.db_path)
|
|
113
|
+
return self._conn
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
if self._conn:
|
|
117
|
+
self._conn.close()
|
|
118
|
+
self._conn = None
|
|
119
|
+
|
|
120
|
+
def insert_node(self, node: Node) -> None:
|
|
121
|
+
conn = self._get_conn()
|
|
122
|
+
conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
INSERT OR REPLACE INTO nodes
|
|
125
|
+
(id, type, name, qualified_name, file_path, line_start, line_end, language, parent_class)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
127
|
+
""",
|
|
128
|
+
(
|
|
129
|
+
node.id,
|
|
130
|
+
node.type,
|
|
131
|
+
node.name,
|
|
132
|
+
node.qualified_name,
|
|
133
|
+
node.file_path,
|
|
134
|
+
node.line_start,
|
|
135
|
+
node.line_end,
|
|
136
|
+
node.language,
|
|
137
|
+
node.parent_class,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
conn.commit()
|
|
141
|
+
|
|
142
|
+
def insert_edge(self, edge: Edge) -> None:
|
|
143
|
+
conn = self._get_conn()
|
|
144
|
+
conn.execute(
|
|
145
|
+
"""
|
|
146
|
+
INSERT OR REPLACE INTO edges
|
|
147
|
+
(id, source_id, target_id, type, resolved, raw_reference)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
149
|
+
""",
|
|
150
|
+
(edge.id, edge.source_id, edge.target_id, edge.type, edge.resolved, edge.raw_reference),
|
|
151
|
+
)
|
|
152
|
+
conn.commit()
|
|
153
|
+
|
|
154
|
+
def delete_nodes_for_file(self, file_path: str) -> list[str]:
|
|
155
|
+
"""Delete all nodes for a file and return their IDs."""
|
|
156
|
+
conn = self._get_conn()
|
|
157
|
+
cursor = conn.execute("SELECT id FROM nodes WHERE file_path = ?", (file_path,))
|
|
158
|
+
node_ids = [row[0] for row in cursor.fetchall()]
|
|
159
|
+
if node_ids:
|
|
160
|
+
placeholders = ",".join("?" * len(node_ids))
|
|
161
|
+
conn.execute(f"DELETE FROM edges WHERE source_id IN ({placeholders})", node_ids)
|
|
162
|
+
conn.execute(f"DELETE FROM edges WHERE target_id IN ({placeholders})", node_ids)
|
|
163
|
+
conn.execute("DELETE FROM nodes WHERE file_path = ?", (file_path,))
|
|
164
|
+
conn.commit()
|
|
165
|
+
return node_ids
|
|
166
|
+
|
|
167
|
+
def load_all_nodes(self) -> list[Node]:
|
|
168
|
+
conn = self._get_conn()
|
|
169
|
+
cursor = conn.execute("SELECT * FROM nodes")
|
|
170
|
+
nodes = []
|
|
171
|
+
for row in cursor.fetchall():
|
|
172
|
+
nodes.append(
|
|
173
|
+
Node(
|
|
174
|
+
id=row[0],
|
|
175
|
+
type=row[1],
|
|
176
|
+
name=row[2],
|
|
177
|
+
qualified_name=row[3],
|
|
178
|
+
file_path=row[4],
|
|
179
|
+
line_start=row[5],
|
|
180
|
+
line_end=row[6],
|
|
181
|
+
language=row[7],
|
|
182
|
+
parent_class=row[8],
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return nodes
|
|
186
|
+
|
|
187
|
+
def load_all_edges(self) -> list[Edge]:
|
|
188
|
+
conn = self._get_conn()
|
|
189
|
+
cursor = conn.execute("SELECT * FROM edges")
|
|
190
|
+
edges = []
|
|
191
|
+
for row in cursor.fetchall():
|
|
192
|
+
edges.append(
|
|
193
|
+
Edge(
|
|
194
|
+
id=row[0],
|
|
195
|
+
source_id=row[1],
|
|
196
|
+
target_id=row[2],
|
|
197
|
+
type=row[3],
|
|
198
|
+
resolved=bool(row[4]),
|
|
199
|
+
raw_reference=row[5],
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return edges
|
|
203
|
+
|
|
204
|
+
def get_unresolved_edges_targeting_module(self, module_path: str) -> list[Edge]:
|
|
205
|
+
"""Get unresolved edges that might resolve to a module path."""
|
|
206
|
+
conn = self._get_conn()
|
|
207
|
+
cursor = conn.execute(
|
|
208
|
+
"""
|
|
209
|
+
SELECT * FROM edges
|
|
210
|
+
WHERE resolved = 0 AND raw_reference LIKE ?
|
|
211
|
+
""",
|
|
212
|
+
(f"%{module_path}%",),
|
|
213
|
+
)
|
|
214
|
+
edges = []
|
|
215
|
+
for row in cursor.fetchall():
|
|
216
|
+
edges.append(
|
|
217
|
+
Edge(
|
|
218
|
+
id=row[0],
|
|
219
|
+
source_id=row[1],
|
|
220
|
+
target_id=row[2],
|
|
221
|
+
type=row[3],
|
|
222
|
+
resolved=bool(row[4]),
|
|
223
|
+
raw_reference=row[5],
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
return edges
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --------------------------------------------------------------------------- #
|
|
230
|
+
# In-Memory Graph
|
|
231
|
+
# --------------------------------------------------------------------------- #
|
|
232
|
+
class CodeGraph:
|
|
233
|
+
"""In-memory NetworkX MultiDiGraph wrapper for fast queries."""
|
|
234
|
+
|
|
235
|
+
def __init__(self):
|
|
236
|
+
self.graph: nx.MultiDiGraph = nx.MultiDiGraph()
|
|
237
|
+
self.node_by_id: dict[str, Node] = {}
|
|
238
|
+
self.edge_by_id: dict[str, Edge] = {}
|
|
239
|
+
|
|
240
|
+
def add_node(self, node: Node) -> None:
|
|
241
|
+
self.graph.add_node(
|
|
242
|
+
node.id,
|
|
243
|
+
type=node.type,
|
|
244
|
+
name=node.name,
|
|
245
|
+
qualified_name=node.qualified_name,
|
|
246
|
+
file_path=node.file_path,
|
|
247
|
+
line_start=node.line_start,
|
|
248
|
+
line_end=node.line_end,
|
|
249
|
+
language=node.language,
|
|
250
|
+
parent_class=node.parent_class,
|
|
251
|
+
)
|
|
252
|
+
self.node_by_id[node.id] = node
|
|
253
|
+
|
|
254
|
+
def add_edge(self, edge: Edge) -> None:
|
|
255
|
+
# Only add edge to graph if both source and target exist
|
|
256
|
+
# Unresolved edges with empty target_id are stored in edge_by_id but not in graph
|
|
257
|
+
if edge.target_id and edge.source_id:
|
|
258
|
+
self.graph.add_edge(
|
|
259
|
+
edge.source_id,
|
|
260
|
+
edge.target_id,
|
|
261
|
+
key=edge.id,
|
|
262
|
+
type=edge.type,
|
|
263
|
+
resolved=edge.resolved,
|
|
264
|
+
raw_reference=edge.raw_reference,
|
|
265
|
+
)
|
|
266
|
+
self.edge_by_id[edge.id] = edge
|
|
267
|
+
|
|
268
|
+
def remove_nodes(self, node_ids: list[str]) -> None:
|
|
269
|
+
for nid in node_ids:
|
|
270
|
+
if nid in self.graph:
|
|
271
|
+
self.graph.remove_node(nid)
|
|
272
|
+
self.node_by_id.pop(nid, None)
|
|
273
|
+
# Remove edges
|
|
274
|
+
to_remove = [eid for eid, e in self.edge_by_id.items() if e.source_id in node_ids or e.target_id in node_ids]
|
|
275
|
+
for eid in to_remove:
|
|
276
|
+
self.edge_by_id.pop(eid, None)
|
|
277
|
+
|
|
278
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
279
|
+
return self.node_by_id.get(node_id)
|
|
280
|
+
|
|
281
|
+
def get_neighbors(self, node_id: str, edge_type: Optional[str] = None, direction: str = "out") -> list[Node]:
|
|
282
|
+
"""Get neighboring nodes. direction: 'out', 'in', or 'both'."""
|
|
283
|
+
if node_id not in self.graph:
|
|
284
|
+
return []
|
|
285
|
+
neighbors = []
|
|
286
|
+
if direction in ("out", "both"):
|
|
287
|
+
for _, target, key, data in self.graph.out_edges(node_id, keys=True, data=True):
|
|
288
|
+
if edge_type is None or data.get("type") == edge_type:
|
|
289
|
+
node = self.node_by_id.get(target)
|
|
290
|
+
if node:
|
|
291
|
+
neighbors.append(node)
|
|
292
|
+
if direction in ("in", "both"):
|
|
293
|
+
for source, _, key, data in self.graph.in_edges(node_id, keys=True, data=True):
|
|
294
|
+
if edge_type is None or data.get("type") == edge_type:
|
|
295
|
+
node = self.node_by_id.get(source)
|
|
296
|
+
if node:
|
|
297
|
+
neighbors.append(node)
|
|
298
|
+
return neighbors
|
|
299
|
+
|
|
300
|
+
def get_nodes_by_file(self, file_path: str) -> list[Node]:
|
|
301
|
+
return [n for n in self.node_by_id.values() if n.file_path == file_path]
|
|
302
|
+
|
|
303
|
+
def get_nodes_by_type(self, node_type: str) -> list[Node]:
|
|
304
|
+
return [n for n in self.node_by_id.values() if n.type == node_type]
|
|
305
|
+
|
|
306
|
+
def find_nodes_by_name(self, name: str, node_type: Optional[str] = None) -> list[Node]:
|
|
307
|
+
candidates = [n for n in self.node_by_id.values() if n.name == name]
|
|
308
|
+
if node_type:
|
|
309
|
+
candidates = [n for n in candidates if n.type == node_type]
|
|
310
|
+
return candidates
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --------------------------------------------------------------------------- #
|
|
314
|
+
# Module Path Index
|
|
315
|
+
# --------------------------------------------------------------------------- #
|
|
316
|
+
class ModulePathIndex:
|
|
317
|
+
"""Index of importable module paths for cross-file resolution."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, workspace: str):
|
|
320
|
+
self.workspace = Path(workspace)
|
|
321
|
+
self.index: dict[str, str] = {} # module_path -> file_path
|
|
322
|
+
|
|
323
|
+
def build(self) -> None:
|
|
324
|
+
"""Build the module path index for Python."""
|
|
325
|
+
self.index.clear()
|
|
326
|
+
self._build_python_index()
|
|
327
|
+
|
|
328
|
+
def _build_python_index(self) -> None:
|
|
329
|
+
"""Build Python module index respecting package boundaries."""
|
|
330
|
+
for py_file in self.workspace.rglob("*.py"):
|
|
331
|
+
if "__pycache__" in str(py_file) or ".venv" in str(py_file):
|
|
332
|
+
continue
|
|
333
|
+
rel_path = py_file.relative_to(self.workspace)
|
|
334
|
+
# Convert to module path
|
|
335
|
+
parts = list(rel_path.parts)
|
|
336
|
+
if parts[-1] == "__init__.py":
|
|
337
|
+
# Package directory
|
|
338
|
+
if len(parts) > 1:
|
|
339
|
+
module_path = ".".join(parts[:-1])
|
|
340
|
+
else:
|
|
341
|
+
# Root __init__.py - use directory name
|
|
342
|
+
module_path = parts[0].replace(".py", "")
|
|
343
|
+
else:
|
|
344
|
+
# Module file
|
|
345
|
+
if parts[-1].endswith(".py"):
|
|
346
|
+
parts[-1] = parts[-1][:-3]
|
|
347
|
+
module_path = ".".join(parts)
|
|
348
|
+
if module_path:
|
|
349
|
+
self.index[module_path] = str(py_file)
|
|
350
|
+
|
|
351
|
+
def resolve(self, import_path: str) -> Optional[str]:
|
|
352
|
+
"""Resolve an import path to a file path, or None if not found."""
|
|
353
|
+
# Direct match
|
|
354
|
+
if import_path in self.index:
|
|
355
|
+
return self.index[import_path]
|
|
356
|
+
# Try relative resolution (e.g., "package.module" -> "package.module.submodule")
|
|
357
|
+
for key, path in self.index.items():
|
|
358
|
+
if key == import_path or key.startswith(import_path + "."):
|
|
359
|
+
return path
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --------------------------------------------------------------------------- #
|
|
364
|
+
# Python Parser (Tree-sitter with AST fallback)
|
|
365
|
+
# --------------------------------------------------------------------------- #
|
|
366
|
+
class PythonParser:
|
|
367
|
+
"""Parse Python files using Tree-sitter, with AST fallback."""
|
|
368
|
+
|
|
369
|
+
def __init__(self):
|
|
370
|
+
self._parser = None
|
|
371
|
+
self._language = None
|
|
372
|
+
self._use_ast_fallback = False
|
|
373
|
+
|
|
374
|
+
def _get_parser(self):
|
|
375
|
+
"""Lazy-load Tree-sitter parser."""
|
|
376
|
+
if self._use_ast_fallback:
|
|
377
|
+
return None
|
|
378
|
+
if self._parser is None:
|
|
379
|
+
try:
|
|
380
|
+
import tree_sitter_python as tspython
|
|
381
|
+
from tree_sitter import Language, Parser
|
|
382
|
+
|
|
383
|
+
self._language = Language(tspython.language())
|
|
384
|
+
self._parser = Parser()
|
|
385
|
+
self._parser.set_language(self._language)
|
|
386
|
+
except ImportError:
|
|
387
|
+
# Tree-sitter not installed, use AST fallback
|
|
388
|
+
self._use_ast_fallback = True
|
|
389
|
+
return None
|
|
390
|
+
return self._parser
|
|
391
|
+
|
|
392
|
+
def parse_file(self, file_path: str) -> tuple[list[Node], list[Edge]]:
|
|
393
|
+
"""Parse a Python file and extract nodes/edges."""
|
|
394
|
+
parser = self._get_parser()
|
|
395
|
+
if parser is not None:
|
|
396
|
+
return self._parse_with_tree_sitter(file_path)
|
|
397
|
+
else:
|
|
398
|
+
return self._parse_with_ast(file_path)
|
|
399
|
+
|
|
400
|
+
def _parse_with_tree_sitter(self, file_path: str) -> tuple[list[Node], list[Edge]]:
|
|
401
|
+
"""Parse using Tree-sitter."""
|
|
402
|
+
try:
|
|
403
|
+
source = Path(file_path).read_text(encoding="utf-8")
|
|
404
|
+
except Exception:
|
|
405
|
+
return [], []
|
|
406
|
+
|
|
407
|
+
tree = self._parser.parse(source.encode("utf-8"))
|
|
408
|
+
nodes: list[Node] = []
|
|
409
|
+
edges: list[Edge] = []
|
|
410
|
+
|
|
411
|
+
# Extract module node
|
|
412
|
+
module_id = f"module:{file_path}"
|
|
413
|
+
module_node = Node(
|
|
414
|
+
id=module_id,
|
|
415
|
+
type="Module",
|
|
416
|
+
name=Path(file_path).stem,
|
|
417
|
+
qualified_name=Path(file_path).stem,
|
|
418
|
+
file_path=file_path,
|
|
419
|
+
line_start=1,
|
|
420
|
+
line_end=source.count("\n") + 1,
|
|
421
|
+
language="python",
|
|
422
|
+
)
|
|
423
|
+
nodes.append(module_node)
|
|
424
|
+
|
|
425
|
+
# Walk the AST
|
|
426
|
+
self._extract_nodes_and_edges_ts(tree.root_node, file_path, module_id, nodes, edges)
|
|
427
|
+
|
|
428
|
+
return nodes, edges
|
|
429
|
+
|
|
430
|
+
def _parse_with_ast(self, file_path: str) -> tuple[list[Node], list[Edge]]:
|
|
431
|
+
"""Parse using Python's built-in ast module."""
|
|
432
|
+
import ast
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
source = Path(file_path).read_text(encoding="utf-8")
|
|
436
|
+
except Exception:
|
|
437
|
+
return [], []
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
tree = ast.parse(source)
|
|
441
|
+
except SyntaxError:
|
|
442
|
+
return [], []
|
|
443
|
+
|
|
444
|
+
nodes: list[Node] = []
|
|
445
|
+
edges: list[Edge] = []
|
|
446
|
+
|
|
447
|
+
# Extract module node
|
|
448
|
+
module_id = f"module:{file_path}"
|
|
449
|
+
module_node = Node(
|
|
450
|
+
id=module_id,
|
|
451
|
+
type="Module",
|
|
452
|
+
name=Path(file_path).stem,
|
|
453
|
+
qualified_name=Path(file_path).stem,
|
|
454
|
+
file_path=file_path,
|
|
455
|
+
line_start=1,
|
|
456
|
+
line_end=len(source.splitlines()),
|
|
457
|
+
language="python",
|
|
458
|
+
)
|
|
459
|
+
nodes.append(module_node)
|
|
460
|
+
|
|
461
|
+
# Walk the AST
|
|
462
|
+
self._extract_nodes_and_edges_ast(tree, file_path, module_id, nodes, edges)
|
|
463
|
+
|
|
464
|
+
return nodes, edges
|
|
465
|
+
|
|
466
|
+
def _extract_nodes_and_edges_ts(
|
|
467
|
+
self,
|
|
468
|
+
node,
|
|
469
|
+
file_path: str,
|
|
470
|
+
module_id: str,
|
|
471
|
+
nodes: list[Node],
|
|
472
|
+
edges: list[Edge],
|
|
473
|
+
parent_class: Optional[str] = None,
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Recursively extract nodes and edges from Tree-sitter AST."""
|
|
476
|
+
if node.type == "function_definition":
|
|
477
|
+
func_node = self._extract_function_ts(node, file_path, parent_class)
|
|
478
|
+
if func_node:
|
|
479
|
+
nodes.append(func_node)
|
|
480
|
+
edges.append(
|
|
481
|
+
Edge(
|
|
482
|
+
id=f"edge:{module_id}->{func_node.id}",
|
|
483
|
+
source_id=module_id,
|
|
484
|
+
target_id=func_node.id,
|
|
485
|
+
type="defines",
|
|
486
|
+
resolved=True,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
# Extract calls within function
|
|
490
|
+
self._extract_calls_ts(node, func_node.id, edges)
|
|
491
|
+
|
|
492
|
+
elif node.type == "class_definition":
|
|
493
|
+
class_node = self._extract_class_ts(node, file_path)
|
|
494
|
+
if class_node:
|
|
495
|
+
nodes.append(class_node)
|
|
496
|
+
edges.append(
|
|
497
|
+
Edge(
|
|
498
|
+
id=f"edge:{module_id}->{class_node.id}",
|
|
499
|
+
source_id=module_id,
|
|
500
|
+
target_id=class_node.id,
|
|
501
|
+
type="defines",
|
|
502
|
+
resolved=True,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
# Extract inheritance
|
|
506
|
+
self._extract_inheritance_ts(node, class_node.id, edges)
|
|
507
|
+
# Recurse into class body
|
|
508
|
+
for child in node.children:
|
|
509
|
+
self._extract_nodes_and_edges_ts(child, file_path, module_id, nodes, edges, parent_class=class_node.name)
|
|
510
|
+
|
|
511
|
+
elif node.type == "import_statement":
|
|
512
|
+
self._extract_import_ts(node, module_id, edges)
|
|
513
|
+
|
|
514
|
+
elif node.type == "import_from_statement":
|
|
515
|
+
self._extract_import_from_ts(node, module_id, edges)
|
|
516
|
+
|
|
517
|
+
# Recurse into children
|
|
518
|
+
for child in node.children:
|
|
519
|
+
self._extract_nodes_and_edges_ts(child, file_path, module_id, nodes, edges, parent_class)
|
|
520
|
+
|
|
521
|
+
def _extract_nodes_and_edges_ast(
|
|
522
|
+
self,
|
|
523
|
+
node: ast.AST,
|
|
524
|
+
file_path: str,
|
|
525
|
+
module_id: str,
|
|
526
|
+
nodes: list[Node],
|
|
527
|
+
edges: list[Edge],
|
|
528
|
+
parent_class: Optional[str] = None,
|
|
529
|
+
) -> None:
|
|
530
|
+
"""Recursively extract nodes and edges from Python AST."""
|
|
531
|
+
if isinstance(node, ast.FunctionDef):
|
|
532
|
+
func_node = self._extract_function_ast(node, file_path, parent_class)
|
|
533
|
+
if func_node:
|
|
534
|
+
nodes.append(func_node)
|
|
535
|
+
edges.append(
|
|
536
|
+
Edge(
|
|
537
|
+
id=f"edge:{module_id}->{func_node.id}",
|
|
538
|
+
source_id=module_id,
|
|
539
|
+
target_id=func_node.id,
|
|
540
|
+
type="defines",
|
|
541
|
+
resolved=True,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
# Extract calls within function
|
|
545
|
+
self._extract_calls_ast(node, func_node.id, edges)
|
|
546
|
+
|
|
547
|
+
elif isinstance(node, ast.ClassDef):
|
|
548
|
+
class_node = self._extract_class_ast(node, file_path)
|
|
549
|
+
if class_node:
|
|
550
|
+
nodes.append(class_node)
|
|
551
|
+
edges.append(
|
|
552
|
+
Edge(
|
|
553
|
+
id=f"edge:{module_id}->{class_node.id}",
|
|
554
|
+
source_id=module_id,
|
|
555
|
+
target_id=class_node.id,
|
|
556
|
+
type="defines",
|
|
557
|
+
resolved=True,
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
# Extract inheritance
|
|
561
|
+
self._extract_inheritance_ast(node, class_node.id, edges)
|
|
562
|
+
# Recurse into class body
|
|
563
|
+
for child in node.body:
|
|
564
|
+
self._extract_nodes_and_edges_ast(child, file_path, module_id, nodes, edges, parent_class=class_node.name)
|
|
565
|
+
|
|
566
|
+
elif isinstance(node, ast.Import):
|
|
567
|
+
self._extract_import_ast(node, module_id, edges)
|
|
568
|
+
|
|
569
|
+
elif isinstance(node, ast.ImportFrom):
|
|
570
|
+
self._extract_import_from_ast(node, module_id, edges)
|
|
571
|
+
|
|
572
|
+
# Recurse into children
|
|
573
|
+
for child in ast.iter_child_nodes(node):
|
|
574
|
+
self._extract_nodes_and_edges_ast(child, file_path, module_id, nodes, edges, parent_class)
|
|
575
|
+
|
|
576
|
+
def _extract_function_ts(self, node, file_path: str, parent_class: Optional[str]) -> Optional[Node]:
|
|
577
|
+
"""Extract a function node from Tree-sitter."""
|
|
578
|
+
name_node = node.child_by_field_name("name")
|
|
579
|
+
if not name_node:
|
|
580
|
+
return None
|
|
581
|
+
name = name_node.text.decode("utf-8")
|
|
582
|
+
qualified_name = f"{parent_class}.{name}" if parent_class else name
|
|
583
|
+
return Node(
|
|
584
|
+
id=f"function:{file_path}:{qualified_name}",
|
|
585
|
+
type="Function",
|
|
586
|
+
name=name,
|
|
587
|
+
qualified_name=qualified_name,
|
|
588
|
+
file_path=file_path,
|
|
589
|
+
line_start=node.start_point[0] + 1,
|
|
590
|
+
line_end=node.end_point[0] + 1,
|
|
591
|
+
language="python",
|
|
592
|
+
parent_class=parent_class,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _extract_function_ast(self, node: ast.FunctionDef, file_path: str, parent_class: Optional[str]) -> Optional[Node]:
|
|
596
|
+
"""Extract a function node from AST."""
|
|
597
|
+
name = node.name
|
|
598
|
+
qualified_name = f"{parent_class}.{name}" if parent_class else name
|
|
599
|
+
return Node(
|
|
600
|
+
id=f"function:{file_path}:{qualified_name}",
|
|
601
|
+
type="Function",
|
|
602
|
+
name=name,
|
|
603
|
+
qualified_name=qualified_name,
|
|
604
|
+
file_path=file_path,
|
|
605
|
+
line_start=node.lineno,
|
|
606
|
+
line_end=node.end_lineno if node.end_lineno else node.lineno,
|
|
607
|
+
language="python",
|
|
608
|
+
parent_class=parent_class,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def _extract_class_ts(self, node, file_path: str) -> Optional[Node]:
|
|
612
|
+
"""Extract a class node from Tree-sitter."""
|
|
613
|
+
name_node = node.child_by_field_name("name")
|
|
614
|
+
if not name_node:
|
|
615
|
+
return None
|
|
616
|
+
name = name_node.text.decode("utf-8")
|
|
617
|
+
return Node(
|
|
618
|
+
id=f"class:{file_path}:{name}",
|
|
619
|
+
type="Class",
|
|
620
|
+
name=name,
|
|
621
|
+
qualified_name=name,
|
|
622
|
+
file_path=file_path,
|
|
623
|
+
line_start=node.start_point[0] + 1,
|
|
624
|
+
line_end=node.end_point[0] + 1,
|
|
625
|
+
language="python",
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def _extract_class_ast(self, node: ast.ClassDef, file_path: str) -> Optional[Node]:
|
|
629
|
+
"""Extract a class node from AST."""
|
|
630
|
+
name = node.name
|
|
631
|
+
return Node(
|
|
632
|
+
id=f"class:{file_path}:{name}",
|
|
633
|
+
type="Class",
|
|
634
|
+
name=name,
|
|
635
|
+
qualified_name=name,
|
|
636
|
+
file_path=file_path,
|
|
637
|
+
line_start=node.lineno,
|
|
638
|
+
line_end=node.end_lineno if node.end_lineno else node.lineno,
|
|
639
|
+
language="python",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def _extract_calls_ts(self, node, source_id: str, edges: list[Edge]) -> None:
|
|
643
|
+
"""Extract function calls within a node (Tree-sitter)."""
|
|
644
|
+
if node.type == "call":
|
|
645
|
+
func_node = node.child_by_field_name("function")
|
|
646
|
+
if func_node:
|
|
647
|
+
func_name = func_node.text.decode("utf-8")
|
|
648
|
+
# Create unresolved edge - will be resolved later
|
|
649
|
+
edges.append(
|
|
650
|
+
Edge(
|
|
651
|
+
id=f"edge:call:{source_id}:{func_name}",
|
|
652
|
+
source_id=source_id,
|
|
653
|
+
target_id="", # Will be resolved
|
|
654
|
+
type="calls",
|
|
655
|
+
resolved=False,
|
|
656
|
+
raw_reference=func_name,
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
for child in node.children:
|
|
660
|
+
self._extract_calls_ts(child, source_id, edges)
|
|
661
|
+
|
|
662
|
+
def _extract_calls_ast(self, node: ast.FunctionDef, source_id: str, edges: list[Edge]) -> None:
|
|
663
|
+
"""Extract function calls within a node (AST)."""
|
|
664
|
+
for child in ast.walk(node):
|
|
665
|
+
if isinstance(child, ast.Call):
|
|
666
|
+
func_name = None
|
|
667
|
+
if isinstance(child.func, ast.Name):
|
|
668
|
+
func_name = child.func.id
|
|
669
|
+
elif isinstance(child.func, ast.Attribute):
|
|
670
|
+
func_name = child.func.attr
|
|
671
|
+
if func_name:
|
|
672
|
+
edges.append(
|
|
673
|
+
Edge(
|
|
674
|
+
id=f"edge:call:{source_id}:{func_name}",
|
|
675
|
+
source_id=source_id,
|
|
676
|
+
target_id="", # Will be resolved
|
|
677
|
+
type="calls",
|
|
678
|
+
resolved=False,
|
|
679
|
+
raw_reference=func_name,
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def _extract_import_ts(self, node, source_id: str, edges: list[Edge]) -> None:
|
|
684
|
+
"""Extract import statements (Tree-sitter)."""
|
|
685
|
+
for child in node.children:
|
|
686
|
+
if child.type == "dotted_name":
|
|
687
|
+
module_name = child.text.decode("utf-8")
|
|
688
|
+
edges.append(
|
|
689
|
+
Edge(
|
|
690
|
+
id=f"edge:import:{source_id}:{module_name}",
|
|
691
|
+
source_id=source_id,
|
|
692
|
+
target_id="", # Will be resolved
|
|
693
|
+
type="imports",
|
|
694
|
+
resolved=False,
|
|
695
|
+
raw_reference=module_name,
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def _extract_import_ast(self, node: ast.Import, source_id: str, edges: list[Edge]) -> None:
|
|
700
|
+
"""Extract import statements (AST)."""
|
|
701
|
+
for alias in node.names:
|
|
702
|
+
module_name = alias.name
|
|
703
|
+
edges.append(
|
|
704
|
+
Edge(
|
|
705
|
+
id=f"edge:import:{source_id}:{module_name}",
|
|
706
|
+
source_id=source_id,
|
|
707
|
+
target_id="", # Will be resolved
|
|
708
|
+
type="imports",
|
|
709
|
+
resolved=False,
|
|
710
|
+
raw_reference=module_name,
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _extract_import_from_ts(self, node, source_id: str, edges: list[Edge]) -> None:
|
|
715
|
+
"""Extract from ... import statements (Tree-sitter)."""
|
|
716
|
+
for child in node.children:
|
|
717
|
+
if child.type == "dotted_name":
|
|
718
|
+
module_name = child.text.decode("utf-8")
|
|
719
|
+
edges.append(
|
|
720
|
+
Edge(
|
|
721
|
+
id=f"edge:import:{source_id}:{module_name}",
|
|
722
|
+
source_id=source_id,
|
|
723
|
+
target_id="", # Will be resolved
|
|
724
|
+
type="imports",
|
|
725
|
+
resolved=False,
|
|
726
|
+
raw_reference=module_name,
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
def _extract_import_from_ast(self, node: ast.ImportFrom, source_id: str, edges: list[Edge]) -> None:
|
|
731
|
+
"""Extract from ... import statements (AST)."""
|
|
732
|
+
if node.module:
|
|
733
|
+
module_name = node.module
|
|
734
|
+
edges.append(
|
|
735
|
+
Edge(
|
|
736
|
+
id=f"edge:import:{source_id}:{module_name}",
|
|
737
|
+
source_id=source_id,
|
|
738
|
+
target_id="", # Will be resolved
|
|
739
|
+
type="imports",
|
|
740
|
+
resolved=False,
|
|
741
|
+
raw_reference=module_name,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
def _extract_inheritance_ts(self, node, class_id: str, edges: list[Edge]) -> None:
|
|
746
|
+
"""Extract class inheritance (Tree-sitter)."""
|
|
747
|
+
for child in node.children:
|
|
748
|
+
if child.type == "argument_list":
|
|
749
|
+
for arg in child.children:
|
|
750
|
+
if arg.type == "identifier":
|
|
751
|
+
parent_name = arg.text.decode("utf-8")
|
|
752
|
+
edges.append(
|
|
753
|
+
Edge(
|
|
754
|
+
id=f"edge:inherits:{class_id}:{parent_name}",
|
|
755
|
+
source_id=class_id,
|
|
756
|
+
target_id="", # Will be resolved
|
|
757
|
+
type="inherits",
|
|
758
|
+
resolved=False,
|
|
759
|
+
raw_reference=parent_name,
|
|
760
|
+
)
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def _extract_inheritance_ast(self, node: ast.ClassDef, class_id: str, edges: list[Edge]) -> None:
|
|
764
|
+
"""Extract class inheritance (AST)."""
|
|
765
|
+
for base in node.bases:
|
|
766
|
+
if isinstance(base, ast.Name):
|
|
767
|
+
parent_name = base.id
|
|
768
|
+
edges.append(
|
|
769
|
+
Edge(
|
|
770
|
+
id=f"edge:inherits:{class_id}:{parent_name}",
|
|
771
|
+
source_id=class_id,
|
|
772
|
+
target_id="", # Will be resolved
|
|
773
|
+
type="inherits",
|
|
774
|
+
resolved=False,
|
|
775
|
+
raw_reference=parent_name,
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# --------------------------------------------------------------------------- #
|
|
781
|
+
# Main Graph Manager
|
|
782
|
+
# --------------------------------------------------------------------------- #
|
|
783
|
+
class GraphManager:
|
|
784
|
+
"""Main entry point for code knowledge graph operations."""
|
|
785
|
+
|
|
786
|
+
def __init__(self, workspace: str, db_path: str, languages: list[str]):
|
|
787
|
+
self.workspace = workspace
|
|
788
|
+
self.db = GraphDB(db_path)
|
|
789
|
+
self.graph = CodeGraph()
|
|
790
|
+
self.module_index = ModulePathIndex(workspace)
|
|
791
|
+
self.parser = PythonParser()
|
|
792
|
+
self.languages = languages
|
|
793
|
+
self._loaded = False
|
|
794
|
+
|
|
795
|
+
def load(self) -> None:
|
|
796
|
+
"""Load the full graph from SQLite into memory."""
|
|
797
|
+
if self._loaded:
|
|
798
|
+
return
|
|
799
|
+
nodes = self.db.load_all_nodes()
|
|
800
|
+
edges = self.db.load_all_edges()
|
|
801
|
+
for node in nodes:
|
|
802
|
+
self.graph.add_node(node)
|
|
803
|
+
for edge in edges:
|
|
804
|
+
self.graph.add_edge(edge)
|
|
805
|
+
self._loaded = True
|
|
806
|
+
|
|
807
|
+
def build_initial(self) -> None:
|
|
808
|
+
"""Build the initial graph for the workspace."""
|
|
809
|
+
self.module_index.build()
|
|
810
|
+
self._build_graph()
|
|
811
|
+
self.load()
|
|
812
|
+
|
|
813
|
+
def _build_graph(self) -> None:
|
|
814
|
+
"""Build graph by parsing all supported language files."""
|
|
815
|
+
if "python" in self.languages or not self.languages:
|
|
816
|
+
self._build_python_graph()
|
|
817
|
+
|
|
818
|
+
def _build_python_graph(self) -> None:
|
|
819
|
+
"""Build graph for Python files."""
|
|
820
|
+
workspace_path = Path(self.workspace)
|
|
821
|
+
for py_file in workspace_path.rglob("*.py"):
|
|
822
|
+
if "__pycache__" in str(py_file) or ".venv" in str(py_file):
|
|
823
|
+
continue
|
|
824
|
+
self._update_file(str(py_file))
|
|
825
|
+
# Resolve cross-file references
|
|
826
|
+
self._resolve_cross_file_references()
|
|
827
|
+
|
|
828
|
+
def _update_file(self, file_path: str) -> None:
|
|
829
|
+
"""Update graph for a single file (wholesale replace)."""
|
|
830
|
+
# Delete existing nodes/edges for this file
|
|
831
|
+
deleted_ids = self.db.delete_nodes_for_file(file_path)
|
|
832
|
+
if deleted_ids:
|
|
833
|
+
self.graph.remove_nodes(deleted_ids)
|
|
834
|
+
|
|
835
|
+
# Parse file
|
|
836
|
+
nodes, edges = self.parser.parse_file(file_path)
|
|
837
|
+
|
|
838
|
+
# Insert into DB and in-memory graph
|
|
839
|
+
for node in nodes:
|
|
840
|
+
self.db.insert_node(node)
|
|
841
|
+
self.graph.add_node(node)
|
|
842
|
+
for edge in edges:
|
|
843
|
+
self.db.insert_edge(edge)
|
|
844
|
+
self.graph.add_edge(edge)
|
|
845
|
+
|
|
846
|
+
def _resolve_cross_file_references(self) -> None:
|
|
847
|
+
"""Resolve cross-file import/call/inheritance references."""
|
|
848
|
+
# Resolve imports
|
|
849
|
+
for edge in list(self.graph.edge_by_id.values()):
|
|
850
|
+
if not edge.resolved and edge.type in ("imports", "calls", "inherits"):
|
|
851
|
+
target_path = self.module_index.resolve(edge.raw_reference or "")
|
|
852
|
+
if target_path:
|
|
853
|
+
# Find target node
|
|
854
|
+
target_node = self._find_target_node(edge.raw_reference or "", target_path, edge.type)
|
|
855
|
+
if target_node:
|
|
856
|
+
# Update edge
|
|
857
|
+
edge.target_id = target_node.id
|
|
858
|
+
edge.resolved = True
|
|
859
|
+
self.db.insert_edge(edge)
|
|
860
|
+
# Add the resolved edge to the in-memory graph
|
|
861
|
+
self.graph.graph.add_edge(
|
|
862
|
+
edge.source_id,
|
|
863
|
+
edge.target_id,
|
|
864
|
+
key=edge.id,
|
|
865
|
+
type=edge.type,
|
|
866
|
+
resolved=edge.resolved,
|
|
867
|
+
raw_reference=edge.raw_reference,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
def _find_target_node(self, reference: str, file_path: str, edge_type: str) -> Optional[Node]:
|
|
871
|
+
"""Find a target node by reference and file path."""
|
|
872
|
+
nodes_in_file = self.graph.get_nodes_by_file(file_path)
|
|
873
|
+
if edge_type == "imports":
|
|
874
|
+
# For imports, find the module node
|
|
875
|
+
for node in nodes_in_file:
|
|
876
|
+
if node.type == "Module":
|
|
877
|
+
return node
|
|
878
|
+
elif edge_type == "calls":
|
|
879
|
+
# For calls, find a function with matching name
|
|
880
|
+
ref_name = reference.split(".")[-1]
|
|
881
|
+
for node in nodes_in_file:
|
|
882
|
+
if node.type == "Function" and node.name == ref_name:
|
|
883
|
+
return node
|
|
884
|
+
elif edge_type == "inherits":
|
|
885
|
+
# For inheritance, find a class with matching name
|
|
886
|
+
for node in nodes_in_file:
|
|
887
|
+
if node.type == "Class" and node.name == reference:
|
|
888
|
+
return node
|
|
889
|
+
return None
|
|
890
|
+
|
|
891
|
+
def invalidate_file(self, file_path: str) -> None:
|
|
892
|
+
"""Invalidate and re-parse a file."""
|
|
893
|
+
self._update_file(file_path)
|
|
894
|
+
self._resolve_cross_file_references()
|
|
895
|
+
|
|
896
|
+
def query_neighbors(self, node_id: str, hop_limit: int = 1, edge_types: Optional[list[str]] = None) -> list[Node]:
|
|
897
|
+
"""Query neighbors up to hop_limit."""
|
|
898
|
+
if hop_limit < 1:
|
|
899
|
+
return []
|
|
900
|
+
visited = {node_id}
|
|
901
|
+
current_level = {node_id}
|
|
902
|
+
result = []
|
|
903
|
+
|
|
904
|
+
for hop in range(hop_limit):
|
|
905
|
+
next_level = set()
|
|
906
|
+
for nid in current_level:
|
|
907
|
+
neighbors = self.graph.get_neighbors(nid, edge_type=None, direction="out")
|
|
908
|
+
for neighbor in neighbors:
|
|
909
|
+
if neighbor.id not in visited:
|
|
910
|
+
visited.add(neighbor.id)
|
|
911
|
+
next_level.add(neighbor.id)
|
|
912
|
+
result.append(neighbor)
|
|
913
|
+
current_level = next_level
|
|
914
|
+
if not current_level:
|
|
915
|
+
break
|
|
916
|
+
|
|
917
|
+
return result
|
|
918
|
+
|
|
919
|
+
def get_node_by_qualified_name(self, qualified_name: str) -> Optional[Node]:
|
|
920
|
+
"""Find a node by its qualified name."""
|
|
921
|
+
for node in self.graph.node_by_id.values():
|
|
922
|
+
if node.qualified_name == qualified_name:
|
|
923
|
+
return node
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
def close(self) -> None:
|
|
927
|
+
"""Clean up resources."""
|
|
928
|
+
self.db.close()
|