codegraph-py 1.0.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.
- codegraph/__init__.py +12 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +911 -0
- codegraph/codegraph.py +618 -0
- codegraph/context/__init__.py +216 -0
- codegraph/db/__init__.py +11 -0
- codegraph/db/connection.py +163 -0
- codegraph/db/queries.py +752 -0
- codegraph/db/schema.sql +151 -0
- codegraph/directory.py +160 -0
- codegraph/errors.py +101 -0
- codegraph/extraction/__init__.py +956 -0
- codegraph/extraction/languages/__init__.py +64 -0
- codegraph/extraction/languages/base.py +132 -0
- codegraph/extraction/languages/c_cfg.py +53 -0
- codegraph/extraction/languages/cpp_cfg.py +60 -0
- codegraph/extraction/languages/dart_cfg.py +53 -0
- codegraph/extraction/languages/go_cfg.py +70 -0
- codegraph/extraction/languages/java_cfg.py +62 -0
- codegraph/extraction/languages/javascript_cfg.py +84 -0
- codegraph/extraction/languages/kotlin_cfg.py +52 -0
- codegraph/extraction/languages/lua_cfg.py +65 -0
- codegraph/extraction/languages/python_cfg.py +75 -0
- codegraph/extraction/languages/ruby_cfg.py +48 -0
- codegraph/extraction/languages/rust_cfg.py +68 -0
- codegraph/extraction/languages/scala_cfg.py +59 -0
- codegraph/extraction/languages/swift_cfg.py +64 -0
- codegraph/extraction/languages/typescript_cfg.py +89 -0
- codegraph/extraction/tree_sitter_extractor.py +689 -0
- codegraph/graph/__init__.py +685 -0
- codegraph/installer/__init__.py +6 -0
- codegraph/mcp/__init__.py +668 -0
- codegraph/project_config.py +191 -0
- codegraph/resolution/__init__.py +337 -0
- codegraph/search/__init__.py +653 -0
- codegraph/sync/__init__.py +204 -0
- codegraph/types.py +334 -0
- codegraph/ui/__init__.py +11 -0
- codegraph/utils.py +218 -0
- codegraph_py-1.0.0.dist-info/METADATA +238 -0
- codegraph_py-1.0.0.dist-info/RECORD +44 -0
- codegraph_py-1.0.0.dist-info/WHEEL +5 -0
- codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
- codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeGraph Graph Traversal Module
|
|
3
|
+
|
|
4
|
+
Provides graph traversal, path finding, and dependency analysis capabilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from codegraph.db.queries import QueryBuilder
|
|
15
|
+
from codegraph.types import Node, Edge, EdgeKind, NodeKind
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TraversalResult:
|
|
20
|
+
"""Result of a graph traversal."""
|
|
21
|
+
nodes: List[Node] = field(default_factory=list)
|
|
22
|
+
edges: List[Edge] = field(default_factory=list)
|
|
23
|
+
depths: Dict[str, int] = field(default_factory=dict)
|
|
24
|
+
paths: Dict[str, List[str]] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PathResult:
|
|
29
|
+
"""Result of a path finding operation."""
|
|
30
|
+
found: bool
|
|
31
|
+
path: List[str] = field(default_factory=list)
|
|
32
|
+
nodes: List[Node] = field(default_factory=list)
|
|
33
|
+
edges: List[Edge] = field(default_factory=list)
|
|
34
|
+
length: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DependencyInfo:
|
|
39
|
+
"""Information about a dependency relationship."""
|
|
40
|
+
source: str
|
|
41
|
+
target: str
|
|
42
|
+
kind: str
|
|
43
|
+
file_path: Optional[str] = None
|
|
44
|
+
line: Optional[int] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NodeMetrics:
|
|
49
|
+
"""Metrics for a node."""
|
|
50
|
+
node_id: str
|
|
51
|
+
incoming_edges: int = 0
|
|
52
|
+
outgoing_edges: int = 0
|
|
53
|
+
call_depth: int = 0
|
|
54
|
+
fan_out: int = 0
|
|
55
|
+
fan_in: int = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GraphTraverser:
|
|
59
|
+
"""Graph traversal and path finding capabilities."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, db: sqlite3.Connection, db_path: str = ''):
|
|
62
|
+
self._db = db
|
|
63
|
+
self._qb = QueryBuilder(db, db_path)
|
|
64
|
+
|
|
65
|
+
def bfs_traverse(
|
|
66
|
+
self,
|
|
67
|
+
start_id: str,
|
|
68
|
+
max_depth: Optional[int] = None,
|
|
69
|
+
edge_kinds: Optional[List[str]] = None,
|
|
70
|
+
direction: str = 'outgoing',
|
|
71
|
+
) -> TraversalResult:
|
|
72
|
+
"""Breadth-first traversal from a starting node.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
start_id: Starting node ID
|
|
76
|
+
max_depth: Maximum traversal depth (None for unlimited)
|
|
77
|
+
edge_kinds: Filter by edge kinds
|
|
78
|
+
direction: 'outgoing', 'incoming', or 'both'
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
TraversalResult with visited nodes and edges
|
|
82
|
+
"""
|
|
83
|
+
result = TraversalResult()
|
|
84
|
+
visited: Set[str] = set()
|
|
85
|
+
queue: deque[Tuple[str, int]] = deque([(start_id, 0)])
|
|
86
|
+
visited.add(start_id)
|
|
87
|
+
result.depths[start_id] = 0
|
|
88
|
+
|
|
89
|
+
# Get starting node
|
|
90
|
+
start_node = self._qb.get_node_by_id(start_id)
|
|
91
|
+
if start_node:
|
|
92
|
+
result.nodes.append(start_node)
|
|
93
|
+
|
|
94
|
+
while queue:
|
|
95
|
+
current_id, depth = queue.popleft()
|
|
96
|
+
|
|
97
|
+
if max_depth is not None and depth >= max_depth:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Get edges based on direction
|
|
101
|
+
if direction in ('outgoing', 'both'):
|
|
102
|
+
outgoing = self._qb.get_outgoing_edges(current_id, edge_kinds)
|
|
103
|
+
for edge in outgoing:
|
|
104
|
+
result.edges.append(edge)
|
|
105
|
+
if edge.target not in visited:
|
|
106
|
+
visited.add(edge.target)
|
|
107
|
+
result.depths[edge.target] = depth + 1
|
|
108
|
+
queue.append((edge.target, depth + 1))
|
|
109
|
+
target_node = self._qb.get_node_by_id(edge.target)
|
|
110
|
+
if target_node:
|
|
111
|
+
result.nodes.append(target_node)
|
|
112
|
+
|
|
113
|
+
if direction in ('incoming', 'both'):
|
|
114
|
+
incoming = self._qb.get_incoming_edges(current_id, edge_kinds)
|
|
115
|
+
for edge in incoming:
|
|
116
|
+
result.edges.append(edge)
|
|
117
|
+
if edge.source not in visited:
|
|
118
|
+
visited.add(edge.source)
|
|
119
|
+
result.depths[edge.source] = depth + 1
|
|
120
|
+
queue.append((edge.source, depth + 1))
|
|
121
|
+
source_node = self._qb.get_node_by_id(edge.source)
|
|
122
|
+
if source_node:
|
|
123
|
+
result.nodes.append(source_node)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def dfs_traverse(
|
|
128
|
+
self,
|
|
129
|
+
start_id: str,
|
|
130
|
+
max_depth: Optional[int] = None,
|
|
131
|
+
edge_kinds: Optional[List[str]] = None,
|
|
132
|
+
direction: str = 'outgoing',
|
|
133
|
+
) -> TraversalResult:
|
|
134
|
+
"""Depth-first traversal from a starting node.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
start_id: Starting node ID
|
|
138
|
+
max_depth: Maximum traversal depth (None for unlimited)
|
|
139
|
+
edge_kinds: Filter by edge kinds
|
|
140
|
+
direction: 'outgoing', 'incoming', or 'both'
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
TraversalResult with visited nodes and edges
|
|
144
|
+
"""
|
|
145
|
+
result = TraversalResult()
|
|
146
|
+
visited: Set[str] = set()
|
|
147
|
+
|
|
148
|
+
def dfs(current_id: str, depth: int) -> None:
|
|
149
|
+
if max_depth is not None and depth >= max_depth:
|
|
150
|
+
return
|
|
151
|
+
if current_id in visited:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
visited.add(current_id)
|
|
155
|
+
result.depths[current_id] = depth
|
|
156
|
+
|
|
157
|
+
node = self._qb.get_node_by_id(current_id)
|
|
158
|
+
if node:
|
|
159
|
+
result.nodes.append(node)
|
|
160
|
+
|
|
161
|
+
# Get edges based on direction
|
|
162
|
+
edges = []
|
|
163
|
+
if direction in ('outgoing', 'both'):
|
|
164
|
+
edges.extend(self._qb.get_outgoing_edges(current_id, edge_kinds))
|
|
165
|
+
if direction in ('incoming', 'both'):
|
|
166
|
+
edges.extend(self._qb.get_incoming_edges(current_id, edge_kinds))
|
|
167
|
+
|
|
168
|
+
for edge in edges:
|
|
169
|
+
result.edges.append(edge)
|
|
170
|
+
neighbor_id = edge.target if direction in ('outgoing', 'both') and edge.target != current_id else edge.source
|
|
171
|
+
if neighbor_id != current_id:
|
|
172
|
+
dfs(neighbor_id, depth + 1)
|
|
173
|
+
|
|
174
|
+
dfs(start_id, 0)
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
def getCallers(self, node_id: str, include_indirect: bool = False) -> List[Node]:
|
|
178
|
+
"""Get nodes that call/ reference the given node.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
node_id: Target node ID
|
|
182
|
+
include_indirect: If True, include indirect callers through the call chain
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of caller nodes
|
|
186
|
+
"""
|
|
187
|
+
callers: List[Node] = []
|
|
188
|
+
seen: Set[str] = set()
|
|
189
|
+
|
|
190
|
+
def collect_callers(nid: str) -> None:
|
|
191
|
+
if nid in seen:
|
|
192
|
+
return
|
|
193
|
+
seen.add(nid)
|
|
194
|
+
|
|
195
|
+
edges = self._qb.get_incoming_edges(nid, [EdgeKind.CALLS, EdgeKind.REFERENCES])
|
|
196
|
+
for edge in edges:
|
|
197
|
+
caller_node = self._qb.get_node_by_id(edge.source)
|
|
198
|
+
if caller_node and edge.source not in seen:
|
|
199
|
+
callers.append(caller_node)
|
|
200
|
+
if include_indirect:
|
|
201
|
+
collect_callers(edge.source)
|
|
202
|
+
|
|
203
|
+
collect_callers(node_id)
|
|
204
|
+
return callers
|
|
205
|
+
|
|
206
|
+
def getCallees(self, node_id: str, include_indirect: bool = False) -> List[Node]:
|
|
207
|
+
"""Get nodes that the given node calls/ references.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
node_id: Source node ID
|
|
211
|
+
include_indirect: If True, include indirect callees through the call chain
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of callee nodes
|
|
215
|
+
"""
|
|
216
|
+
callees: List[Node] = []
|
|
217
|
+
seen: Set[str] = set()
|
|
218
|
+
|
|
219
|
+
def collect_callees(nid: str) -> None:
|
|
220
|
+
if nid in seen:
|
|
221
|
+
return
|
|
222
|
+
seen.add(nid)
|
|
223
|
+
|
|
224
|
+
edges = self._qb.get_outgoing_edges(nid, [EdgeKind.CALLS, EdgeKind.REFERENCES])
|
|
225
|
+
for edge in edges:
|
|
226
|
+
callee_node = self._qb.get_node_by_id(edge.target)
|
|
227
|
+
if callee_node and edge.target not in seen:
|
|
228
|
+
callees.append(callee_node)
|
|
229
|
+
if include_indirect:
|
|
230
|
+
collect_callees(edge.target)
|
|
231
|
+
|
|
232
|
+
collect_callees(node_id)
|
|
233
|
+
return callees
|
|
234
|
+
|
|
235
|
+
def getImpactRadius(
|
|
236
|
+
self,
|
|
237
|
+
node_id: str,
|
|
238
|
+
radius: int = 3,
|
|
239
|
+
edge_kinds: Optional[List[str]] = None,
|
|
240
|
+
) -> TraversalResult:
|
|
241
|
+
"""Get all nodes within N hops of the given node.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
node_id: Starting node ID
|
|
245
|
+
radius: Number of hops to traverse
|
|
246
|
+
edge_kinds: Filter by edge kinds
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
TraversalResult with all nodes within radius
|
|
250
|
+
"""
|
|
251
|
+
return self.bfs_traverse(
|
|
252
|
+
start_id=node_id,
|
|
253
|
+
max_depth=radius,
|
|
254
|
+
edge_kinds=edge_kinds,
|
|
255
|
+
direction='both',
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def findPath(
|
|
259
|
+
self,
|
|
260
|
+
start_id: str,
|
|
261
|
+
end_id: str,
|
|
262
|
+
edge_kinds: Optional[List[str]] = None,
|
|
263
|
+
max_length: int = 20,
|
|
264
|
+
) -> PathResult:
|
|
265
|
+
"""Find a path between two nodes using BFS.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
start_id: Starting node ID
|
|
269
|
+
end_id: Target node ID
|
|
270
|
+
edge_kinds: Filter by edge kinds
|
|
271
|
+
max_length: Maximum path length
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
PathResult with found path or empty result
|
|
275
|
+
"""
|
|
276
|
+
if start_id == end_id:
|
|
277
|
+
node = self._qb.get_node_by_id(start_id)
|
|
278
|
+
return PathResult(
|
|
279
|
+
found=True,
|
|
280
|
+
path=[start_id],
|
|
281
|
+
nodes=[node] if node else [],
|
|
282
|
+
length=0,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
queue: deque[Tuple[str, List[str]]] = deque([(start_id, [start_id])])
|
|
286
|
+
visited: Set[str] = {start_id}
|
|
287
|
+
|
|
288
|
+
while queue:
|
|
289
|
+
current_id, path = queue.popleft()
|
|
290
|
+
|
|
291
|
+
if len(path) >= max_length:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Check outgoing edges
|
|
295
|
+
edges = self._qb.get_outgoing_edges(current_id, edge_kinds)
|
|
296
|
+
for edge in edges:
|
|
297
|
+
if edge.target == end_id:
|
|
298
|
+
full_path = path + [edge.target]
|
|
299
|
+
nodes = self._qb.get_nodes_by_ids(full_path)
|
|
300
|
+
return PathResult(
|
|
301
|
+
found=True,
|
|
302
|
+
path=full_path,
|
|
303
|
+
nodes=nodes,
|
|
304
|
+
edges=edges,
|
|
305
|
+
length=len(full_path) - 1,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if edge.target not in visited:
|
|
309
|
+
visited.add(edge.target)
|
|
310
|
+
queue.append((edge.target, path + [edge.target]))
|
|
311
|
+
|
|
312
|
+
return PathResult(found=False)
|
|
313
|
+
|
|
314
|
+
def getAncestors(
|
|
315
|
+
self,
|
|
316
|
+
node_id: str,
|
|
317
|
+
edge_kinds: Optional[List[str]] = None,
|
|
318
|
+
max_depth: Optional[int] = None,
|
|
319
|
+
) -> List[Node]:
|
|
320
|
+
"""Get ancestor nodes (parent, grandparent, etc.) via incoming edges.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
node_id: Starting node ID
|
|
324
|
+
edge_kinds: Filter by edge kinds (e.g., 'extends', 'imports')
|
|
325
|
+
max_depth: Maximum depth to traverse
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of ancestor nodes
|
|
329
|
+
"""
|
|
330
|
+
result = self.bfs_traverse(
|
|
331
|
+
start_id=node_id,
|
|
332
|
+
max_depth=max_depth,
|
|
333
|
+
edge_kinds=edge_kinds,
|
|
334
|
+
direction='incoming',
|
|
335
|
+
)
|
|
336
|
+
return result.nodes
|
|
337
|
+
|
|
338
|
+
def getChildren(
|
|
339
|
+
self,
|
|
340
|
+
node_id: str,
|
|
341
|
+
edge_kinds: Optional[List[str]] = None,
|
|
342
|
+
max_depth: Optional[int] = None,
|
|
343
|
+
) -> List[Node]:
|
|
344
|
+
"""Get child nodes via outgoing edges.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
node_id: Starting node ID
|
|
348
|
+
edge_kinds: Filter by edge kinds (e.g., 'contains', 'calls')
|
|
349
|
+
max_depth: Maximum depth to traverse
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
List of child nodes
|
|
353
|
+
"""
|
|
354
|
+
result = self.bfs_traverse(
|
|
355
|
+
start_id=node_id,
|
|
356
|
+
max_depth=max_depth,
|
|
357
|
+
edge_kinds=edge_kinds,
|
|
358
|
+
direction='outgoing',
|
|
359
|
+
)
|
|
360
|
+
return result.nodes
|
|
361
|
+
|
|
362
|
+
def getTypeHierarchy(
|
|
363
|
+
self,
|
|
364
|
+
node_id: str,
|
|
365
|
+
) -> Dict[str, List[Node]]:
|
|
366
|
+
"""Get type hierarchy for a node (super types and sub types).
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
node_id: Node ID to get hierarchy for
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Dict with 'super' and 'sub' keys containing type lists
|
|
373
|
+
"""
|
|
374
|
+
hierarchy: Dict[str, List[Node]] = {
|
|
375
|
+
'super': [], # Parent types
|
|
376
|
+
'sub': [], # Child types
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Get super types (extends, implements)
|
|
380
|
+
super_edges = self._qb.get_outgoing_edges(
|
|
381
|
+
node_id,
|
|
382
|
+
[EdgeKind.EXTENDS, EdgeKind.IMPLEMENTS]
|
|
383
|
+
)
|
|
384
|
+
for edge in super_edges:
|
|
385
|
+
super_node = self._qb.get_node_by_id(edge.target)
|
|
386
|
+
if super_node:
|
|
387
|
+
hierarchy['super'].append(super_node)
|
|
388
|
+
|
|
389
|
+
# Get sub types
|
|
390
|
+
sub_edges = self._qb.get_incoming_edges(
|
|
391
|
+
node_id,
|
|
392
|
+
[EdgeKind.EXTENDS, EdgeKind.IMPLEMENTS]
|
|
393
|
+
)
|
|
394
|
+
for edge in sub_edges:
|
|
395
|
+
sub_node = self._qb.get_node_by_id(edge.source)
|
|
396
|
+
if sub_node:
|
|
397
|
+
hierarchy['sub'].append(sub_node)
|
|
398
|
+
|
|
399
|
+
return hierarchy
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class GraphQueryManager:
|
|
403
|
+
"""High-level graph queries and analysis."""
|
|
404
|
+
|
|
405
|
+
def __init__(self, db: sqlite3.Connection, db_path: str = ''):
|
|
406
|
+
self._db = db
|
|
407
|
+
self._qb = QueryBuilder(db, db_path)
|
|
408
|
+
self._traverser = GraphTraverser(db, db_path)
|
|
409
|
+
|
|
410
|
+
def getContext(
|
|
411
|
+
self,
|
|
412
|
+
node_id: str,
|
|
413
|
+
depth: int = 2,
|
|
414
|
+
include_code: bool = False,
|
|
415
|
+
) -> Dict[str, Any]:
|
|
416
|
+
"""Get comprehensive context around a node.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
node_id: Focal node ID
|
|
420
|
+
depth: Traversal depth
|
|
421
|
+
include_code: Include source code snippets
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Dict with context information
|
|
425
|
+
"""
|
|
426
|
+
focal = self._qb.get_node_by_id(node_id)
|
|
427
|
+
if not focal:
|
|
428
|
+
return {}
|
|
429
|
+
|
|
430
|
+
# Get traversal results
|
|
431
|
+
outgoing = self._traverser.bfs_traverse(node_id, max_depth=depth, direction='outgoing')
|
|
432
|
+
incoming = self._traverser.bfs_traverse(node_id, max_depth=depth, direction='incoming')
|
|
433
|
+
|
|
434
|
+
# Get direct callers and callees
|
|
435
|
+
callers = self._traverser.getCallers(node_id)
|
|
436
|
+
callees = self._traverser.getCallees(node_id)
|
|
437
|
+
|
|
438
|
+
# Get type hierarchy
|
|
439
|
+
type_hierarchy = self._traverser.getTypeHierarchy(node_id)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
'focal': focal,
|
|
443
|
+
'ancestors': incoming.nodes,
|
|
444
|
+
'children': outgoing.nodes,
|
|
445
|
+
'callers': callers,
|
|
446
|
+
'callees': callees,
|
|
447
|
+
'super_types': type_hierarchy.get('super', []),
|
|
448
|
+
'sub_types': type_hierarchy.get('sub', []),
|
|
449
|
+
'outgoing_edges': outgoing.edges,
|
|
450
|
+
'incoming_edges': incoming.edges,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
def getFileDependencies(
|
|
454
|
+
self,
|
|
455
|
+
file_path: str,
|
|
456
|
+
transitive: bool = False,
|
|
457
|
+
) -> Dict[str, List[DependencyInfo]]:
|
|
458
|
+
"""Get dependencies for a file.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
file_path: Path to the file
|
|
462
|
+
transitive: If True, include transitive dependencies
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Dict with 'imports' and 'exports' keys
|
|
466
|
+
"""
|
|
467
|
+
nodes = self._qb.get_nodes_by_file(file_path)
|
|
468
|
+
|
|
469
|
+
dependencies: Dict[str, List[DependencyInfo]] = {
|
|
470
|
+
'imports': [],
|
|
471
|
+
'exports': [],
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# Get import edges from nodes in this file
|
|
475
|
+
for node in nodes:
|
|
476
|
+
outgoing = self._qb.get_outgoing_edges(
|
|
477
|
+
node.id,
|
|
478
|
+
[EdgeKind.IMPORTS, EdgeKind.REFERENCES]
|
|
479
|
+
)
|
|
480
|
+
for edge in outgoing:
|
|
481
|
+
target_node = self._qb.get_node_by_id(edge.target)
|
|
482
|
+
if target_node:
|
|
483
|
+
dependencies['imports'].append(DependencyInfo(
|
|
484
|
+
source=node.id,
|
|
485
|
+
target=edge.target,
|
|
486
|
+
kind=edge.kind,
|
|
487
|
+
file_path=target_node.file_path,
|
|
488
|
+
line=edge.line,
|
|
489
|
+
))
|
|
490
|
+
|
|
491
|
+
# Get export edges
|
|
492
|
+
incoming = self._qb.get_incoming_edges(node.id, [EdgeKind.EXPORTS])
|
|
493
|
+
for edge in incoming:
|
|
494
|
+
source_node = self._qb.get_node_by_id(edge.source)
|
|
495
|
+
if source_node:
|
|
496
|
+
dependencies['exports'].append(DependencyInfo(
|
|
497
|
+
source=edge.source,
|
|
498
|
+
target=node.id,
|
|
499
|
+
kind=edge.kind,
|
|
500
|
+
file_path=source_node.file_path,
|
|
501
|
+
line=edge.line,
|
|
502
|
+
))
|
|
503
|
+
|
|
504
|
+
# Add transitive dependencies if requested
|
|
505
|
+
if transitive:
|
|
506
|
+
visited_files: Set[str] = {file_path}
|
|
507
|
+
queue = list(dependencies['imports'])
|
|
508
|
+
|
|
509
|
+
while queue:
|
|
510
|
+
dep = queue.pop(0)
|
|
511
|
+
if dep.file_path and dep.file_path in visited_files:
|
|
512
|
+
continue
|
|
513
|
+
if dep.file_path:
|
|
514
|
+
visited_files.add(dep.file_path)
|
|
515
|
+
sub_deps = self.getFileDependencies(dep.file_path, transitive=False)
|
|
516
|
+
dependencies['imports'].extend(sub_deps.get('imports', []))
|
|
517
|
+
|
|
518
|
+
return dependencies
|
|
519
|
+
|
|
520
|
+
def getExportedSymbols(self, file_path: str) -> List[Node]:
|
|
521
|
+
"""Get exported symbols from a file.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
file_path: Path to the file
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
List of exported nodes
|
|
528
|
+
"""
|
|
529
|
+
nodes = self._qb.get_nodes_by_file(file_path)
|
|
530
|
+
exported = []
|
|
531
|
+
|
|
532
|
+
for node in nodes:
|
|
533
|
+
# Check if node is exported
|
|
534
|
+
if node.is_exported:
|
|
535
|
+
exported.append(node)
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
# Check for export edges
|
|
539
|
+
edges = self._qb.get_incoming_edges(node.id, [EdgeKind.EXPORTS])
|
|
540
|
+
if edges:
|
|
541
|
+
exported.append(node)
|
|
542
|
+
|
|
543
|
+
return exported
|
|
544
|
+
|
|
545
|
+
def findCircularDependencies(
|
|
546
|
+
self,
|
|
547
|
+
start_id: Optional[str] = None,
|
|
548
|
+
) -> List[List[str]]:
|
|
549
|
+
"""Find circular dependencies in the graph.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
start_id: Optional starting node ID
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List of cycles, each as a list of node IDs
|
|
556
|
+
"""
|
|
557
|
+
cycles: List[List[str]] = []
|
|
558
|
+
|
|
559
|
+
if start_id:
|
|
560
|
+
nodes_to_check = [start_id]
|
|
561
|
+
else:
|
|
562
|
+
# Check all nodes
|
|
563
|
+
all_files = self._qb.get_all_files()
|
|
564
|
+
nodes_to_check = []
|
|
565
|
+
for f in all_files:
|
|
566
|
+
file_nodes = self._qb.get_nodes_by_file(f.path)
|
|
567
|
+
for n in file_nodes:
|
|
568
|
+
if n.kind in (NodeKind.MODULE, NodeKind.CLASS, NodeKind.FUNCTION):
|
|
569
|
+
nodes_to_check.append(n.id)
|
|
570
|
+
|
|
571
|
+
for node_id in nodes_to_check:
|
|
572
|
+
visited: Set[str] = set()
|
|
573
|
+
path: List[str] = []
|
|
574
|
+
|
|
575
|
+
def dfs(current: str) -> bool:
|
|
576
|
+
if current in visited:
|
|
577
|
+
# Found a cycle
|
|
578
|
+
if current in path:
|
|
579
|
+
idx = path.index(current)
|
|
580
|
+
cycle = path[idx:] + [current]
|
|
581
|
+
if cycle not in cycles:
|
|
582
|
+
cycles.append(cycle)
|
|
583
|
+
return True
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
visited.add(current)
|
|
587
|
+
path.append(current)
|
|
588
|
+
|
|
589
|
+
edges = self._qb.get_outgoing_edges(current, [EdgeKind.IMPORTS, EdgeKind.CALLS])
|
|
590
|
+
for edge in edges:
|
|
591
|
+
if dfs(edge.target):
|
|
592
|
+
return True
|
|
593
|
+
|
|
594
|
+
path.pop()
|
|
595
|
+
return False
|
|
596
|
+
|
|
597
|
+
dfs(node_id)
|
|
598
|
+
|
|
599
|
+
return cycles
|
|
600
|
+
|
|
601
|
+
def getNodeMetrics(self, node_id: str) -> NodeMetrics:
|
|
602
|
+
"""Get metrics for a node.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
node_id: Node ID
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
NodeMetrics with various measurements
|
|
609
|
+
"""
|
|
610
|
+
metrics = NodeMetrics(node_id=node_id)
|
|
611
|
+
|
|
612
|
+
# Get outgoing edges
|
|
613
|
+
outgoing = self._qb.get_outgoing_edges(node_id)
|
|
614
|
+
metrics.outgoing_edges = len(outgoing)
|
|
615
|
+
metrics.fan_out = len([e for e in outgoing if e.kind in (EdgeKind.CALLS, EdgeKind.REFERENCES)])
|
|
616
|
+
|
|
617
|
+
# Get incoming edges
|
|
618
|
+
incoming = self._qb.get_incoming_edges(node_id)
|
|
619
|
+
metrics.incoming_edges = len(incoming)
|
|
620
|
+
metrics.fan_in = len([e for e in incoming if e.kind in (EdgeKind.CALLS, EdgeKind.REFERENCES)])
|
|
621
|
+
|
|
622
|
+
# Calculate call depth (longest path from any root)
|
|
623
|
+
result = self._traverser.bfs_traverse(node_id, direction='incoming', edge_kinds=[EdgeKind.CALLS])
|
|
624
|
+
metrics.call_depth = max(result.depths.values()) if result.depths else 0
|
|
625
|
+
|
|
626
|
+
return metrics
|
|
627
|
+
|
|
628
|
+
def findDeadCode(
|
|
629
|
+
self,
|
|
630
|
+
exclude_exports: bool = True,
|
|
631
|
+
min_fan_in: int = 0,
|
|
632
|
+
) -> Dict[str, List[Node]]:
|
|
633
|
+
"""Find potentially dead/unused code.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
exclude_exports: Exclude exported symbols from results
|
|
637
|
+
min_fan_in: Minimum fan-in (incoming references) to consider alive
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
Dict with categories of dead code
|
|
641
|
+
"""
|
|
642
|
+
all_nodes = []
|
|
643
|
+
files = self._qb.get_all_files()
|
|
644
|
+
|
|
645
|
+
for f in files:
|
|
646
|
+
nodes = self._qb.get_nodes_by_file(f.path)
|
|
647
|
+
all_nodes.extend(nodes)
|
|
648
|
+
|
|
649
|
+
dead_code: Dict[str, List[Node]] = {
|
|
650
|
+
'unexported_functions': [],
|
|
651
|
+
'unused_imports': [],
|
|
652
|
+
'unreferenced': [],
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
for node in all_nodes:
|
|
656
|
+
# Skip certain kinds
|
|
657
|
+
if node.kind not in (NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.CLASS, NodeKind.VARIABLE):
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
# Get incoming references
|
|
661
|
+
incoming = self._qb.get_incoming_edges(node.id, [EdgeKind.CALLS, EdgeKind.REFERENCES])
|
|
662
|
+
|
|
663
|
+
# Check if exported
|
|
664
|
+
is_exported = node.is_exported
|
|
665
|
+
export_edges = self._qb.get_incoming_edges(node.id, [EdgeKind.EXPORTS])
|
|
666
|
+
if export_edges:
|
|
667
|
+
is_exported = True
|
|
668
|
+
|
|
669
|
+
if not is_exported and exclude_exports:
|
|
670
|
+
if node.kind in (NodeKind.FUNCTION, NodeKind.METHOD):
|
|
671
|
+
dead_code['unexported_functions'].append(node)
|
|
672
|
+
|
|
673
|
+
# Check for unreferenced
|
|
674
|
+
if len(incoming) <= min_fan_in and not is_exported:
|
|
675
|
+
if node.kind in (NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.CLASS):
|
|
676
|
+
dead_code['unreferenced'].append(node)
|
|
677
|
+
|
|
678
|
+
# Find unused imports
|
|
679
|
+
all_import_nodes = self._qb.get_nodes_by_kind(NodeKind.IMPORT)
|
|
680
|
+
for imp in all_import_nodes:
|
|
681
|
+
incoming = self._qb.get_incoming_edges(imp.id, [EdgeKind.REFERENCES])
|
|
682
|
+
if not incoming:
|
|
683
|
+
dead_code['unused_imports'].append(imp)
|
|
684
|
+
|
|
685
|
+
return dead_code
|