sourcecode 0.2.0__py3-none-any.whl → 0.4.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +78 -0
- sourcecode/dependency_analyzer.py +932 -0
- sourcecode/graph_analyzer.py +759 -0
- sourcecode/schema.py +76 -0
- {sourcecode-0.2.0.dist-info → sourcecode-0.4.0.dist-info}/METADATA +115 -1
- {sourcecode-0.2.0.dist-info → sourcecode-0.4.0.dist-info}/RECORD +9 -7
- {sourcecode-0.2.0.dist-info → sourcecode-0.4.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.2.0.dist-info → sourcecode-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""Analisis estructural offline para modulos, imports y relaciones simples."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from sourcecode.schema import GraphEdge, GraphNode, ModuleGraph, ModuleGraphSummary
|
|
11
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GraphAnalyzer:
|
|
15
|
+
"""Construye un grafo estructural parcial y seguro del proyecto."""
|
|
16
|
+
|
|
17
|
+
_PYTHON_EXTENSIONS = {".py"}
|
|
18
|
+
_NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
|
19
|
+
_GO_EXTENSIONS = {".go"}
|
|
20
|
+
_JVM_EXTENSIONS = {".java", ".kt", ".scala"}
|
|
21
|
+
_SUPPORTED_EXTENSIONS = _PYTHON_EXTENSIONS | _NODE_EXTENSIONS | _GO_EXTENSIONS | _JVM_EXTENSIONS
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
max_files: int = 200,
|
|
27
|
+
max_file_size: int = 200_000,
|
|
28
|
+
max_nodes: int = 1_000,
|
|
29
|
+
max_edges: int = 2_000,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.max_files = max_files
|
|
32
|
+
self.max_file_size = max_file_size
|
|
33
|
+
self.max_nodes = max_nodes
|
|
34
|
+
self.max_edges = max_edges
|
|
35
|
+
|
|
36
|
+
def analyze(
|
|
37
|
+
self,
|
|
38
|
+
root: Path,
|
|
39
|
+
file_tree: dict[str, Any],
|
|
40
|
+
*,
|
|
41
|
+
workspace: str | None = None,
|
|
42
|
+
) -> ModuleGraph:
|
|
43
|
+
source_files = [
|
|
44
|
+
path
|
|
45
|
+
for path in flatten_file_tree(file_tree)
|
|
46
|
+
if Path(path).suffix in self._SUPPORTED_EXTENSIONS and (root / path).is_file()
|
|
47
|
+
]
|
|
48
|
+
limitations: list[str] = []
|
|
49
|
+
if len(source_files) > self.max_files:
|
|
50
|
+
limitations.append(
|
|
51
|
+
f"max_files_reached:{len(source_files)}>{self.max_files}"
|
|
52
|
+
)
|
|
53
|
+
source_files = source_files[: self.max_files]
|
|
54
|
+
|
|
55
|
+
nodes: list[GraphNode] = []
|
|
56
|
+
edges: list[GraphEdge] = []
|
|
57
|
+
node_ids: set[str] = set()
|
|
58
|
+
edge_keys: set[tuple[str, str, str, str]] = set()
|
|
59
|
+
|
|
60
|
+
python_modules = self._build_python_module_map(source_files)
|
|
61
|
+
node_modules = {path: path for path in source_files if Path(path).suffix in self._NODE_EXTENSIONS}
|
|
62
|
+
go_imports = self._build_go_import_map(root, source_files)
|
|
63
|
+
jvm_types = self._build_jvm_type_map(root, source_files)
|
|
64
|
+
|
|
65
|
+
for relative_path in source_files:
|
|
66
|
+
absolute_path = root / relative_path
|
|
67
|
+
try:
|
|
68
|
+
if absolute_path.stat().st_size > self.max_file_size:
|
|
69
|
+
limitations.append(f"file_too_large:{relative_path}")
|
|
70
|
+
continue
|
|
71
|
+
content = absolute_path.read_text(encoding="utf-8", errors="replace")
|
|
72
|
+
except OSError:
|
|
73
|
+
limitations.append(f"read_error:{relative_path}")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
suffix = absolute_path.suffix
|
|
77
|
+
module_node = GraphNode(
|
|
78
|
+
id=f"module:{relative_path}",
|
|
79
|
+
kind="module",
|
|
80
|
+
language=self._language_for_suffix(suffix),
|
|
81
|
+
path=relative_path,
|
|
82
|
+
display_name=relative_path,
|
|
83
|
+
workspace=workspace,
|
|
84
|
+
)
|
|
85
|
+
self._append_node(nodes, node_ids, module_node)
|
|
86
|
+
|
|
87
|
+
file_nodes: list[GraphNode] = []
|
|
88
|
+
file_edges: list[GraphEdge] = []
|
|
89
|
+
file_limitations: list[str] = []
|
|
90
|
+
|
|
91
|
+
if suffix in self._PYTHON_EXTENSIONS:
|
|
92
|
+
file_nodes, file_edges, file_limitations = self._analyze_python_file(
|
|
93
|
+
relative_path, content, python_modules, workspace
|
|
94
|
+
)
|
|
95
|
+
elif suffix in self._NODE_EXTENSIONS:
|
|
96
|
+
file_nodes, file_edges, file_limitations = self._analyze_node_file(
|
|
97
|
+
relative_path, content, node_modules, workspace
|
|
98
|
+
)
|
|
99
|
+
elif suffix in self._GO_EXTENSIONS:
|
|
100
|
+
file_nodes, file_edges, file_limitations = self._analyze_go_file(
|
|
101
|
+
relative_path, content, go_imports, workspace
|
|
102
|
+
)
|
|
103
|
+
elif suffix in self._JVM_EXTENSIONS:
|
|
104
|
+
file_nodes, file_edges, file_limitations = self._analyze_jvm_file(
|
|
105
|
+
relative_path, content, jvm_types, workspace
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
limitations.extend(file_limitations)
|
|
109
|
+
for node in file_nodes:
|
|
110
|
+
self._append_node(nodes, node_ids, node)
|
|
111
|
+
for edge in file_edges:
|
|
112
|
+
self._append_edge(edges, edge_keys, edge)
|
|
113
|
+
if len(nodes) >= self.max_nodes or len(edges) >= self.max_edges:
|
|
114
|
+
limitations.append("graph_budget_reached")
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if len(nodes) > self.max_nodes:
|
|
118
|
+
nodes = nodes[: self.max_nodes]
|
|
119
|
+
if len(edges) > self.max_edges:
|
|
120
|
+
edges = edges[: self.max_edges]
|
|
121
|
+
|
|
122
|
+
summary = ModuleGraphSummary(
|
|
123
|
+
requested=True,
|
|
124
|
+
node_count=len(nodes),
|
|
125
|
+
edge_count=len(edges),
|
|
126
|
+
languages=sorted({node.language for node in nodes}),
|
|
127
|
+
methods=sorted({edge.method for edge in edges}),
|
|
128
|
+
limitations=self._unique(limitations),
|
|
129
|
+
)
|
|
130
|
+
return ModuleGraph(nodes=nodes, edges=edges, summary=summary)
|
|
131
|
+
|
|
132
|
+
def merge_graphs(self, graphs: list[ModuleGraph]) -> ModuleGraph:
|
|
133
|
+
nodes: list[GraphNode] = []
|
|
134
|
+
edges: list[GraphEdge] = []
|
|
135
|
+
limitations: list[str] = []
|
|
136
|
+
node_ids: set[str] = set()
|
|
137
|
+
edge_keys: set[tuple[str, str, str, str]] = set()
|
|
138
|
+
for graph in graphs:
|
|
139
|
+
for node in graph.nodes:
|
|
140
|
+
self._append_node(nodes, node_ids, node)
|
|
141
|
+
for edge in graph.edges:
|
|
142
|
+
self._append_edge(edges, edge_keys, edge)
|
|
143
|
+
limitations.extend(graph.summary.limitations)
|
|
144
|
+
return ModuleGraph(
|
|
145
|
+
nodes=nodes,
|
|
146
|
+
edges=edges,
|
|
147
|
+
summary=ModuleGraphSummary(
|
|
148
|
+
requested=bool(graphs),
|
|
149
|
+
node_count=len(nodes),
|
|
150
|
+
edge_count=len(edges),
|
|
151
|
+
languages=sorted({node.language for node in nodes}),
|
|
152
|
+
methods=sorted({edge.method for edge in edges}),
|
|
153
|
+
limitations=self._unique(limitations),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def prefix_graph(self, graph: ModuleGraph, prefix: str, workspace: str) -> ModuleGraph:
|
|
158
|
+
"""Reescribe ids y paths de un grafo de workspace al espacio de paths del repo."""
|
|
159
|
+
node_id_map: dict[str, str] = {}
|
|
160
|
+
prefixed_nodes: list[GraphNode] = []
|
|
161
|
+
clean_prefix = prefix.strip("/")
|
|
162
|
+
for node in graph.nodes:
|
|
163
|
+
prefixed_path = f"{clean_prefix}/{node.path}".strip("/")
|
|
164
|
+
new_id = self._node_id(node.kind, prefixed_path, node.symbol)
|
|
165
|
+
node_id_map[node.id] = new_id
|
|
166
|
+
prefixed_nodes.append(
|
|
167
|
+
replace(
|
|
168
|
+
node,
|
|
169
|
+
id=new_id,
|
|
170
|
+
path=prefixed_path,
|
|
171
|
+
workspace=workspace,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
prefixed_edges: list[GraphEdge] = []
|
|
175
|
+
for edge in graph.edges:
|
|
176
|
+
prefixed_edges.append(
|
|
177
|
+
replace(
|
|
178
|
+
edge,
|
|
179
|
+
source=node_id_map.get(edge.source, edge.source),
|
|
180
|
+
target=node_id_map.get(edge.target, edge.target),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return ModuleGraph(
|
|
184
|
+
nodes=prefixed_nodes,
|
|
185
|
+
edges=prefixed_edges,
|
|
186
|
+
summary=replace(graph.summary),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _analyze_python_file(
|
|
190
|
+
self,
|
|
191
|
+
relative_path: str,
|
|
192
|
+
content: str,
|
|
193
|
+
module_map: dict[str, str],
|
|
194
|
+
workspace: str | None,
|
|
195
|
+
) -> tuple[list[GraphNode], list[GraphEdge], list[str]]:
|
|
196
|
+
limitations: list[str] = []
|
|
197
|
+
try:
|
|
198
|
+
tree = ast.parse(content, filename=relative_path)
|
|
199
|
+
except SyntaxError:
|
|
200
|
+
return [], [], [f"python_parse_error:{relative_path}"]
|
|
201
|
+
|
|
202
|
+
module_node_id = f"module:{relative_path}"
|
|
203
|
+
current_module = module_map.get(relative_path)
|
|
204
|
+
nodes: list[GraphNode] = []
|
|
205
|
+
edges: list[GraphEdge] = []
|
|
206
|
+
function_node_ids: dict[str, str] = {}
|
|
207
|
+
class_node_ids: dict[str, str] = {}
|
|
208
|
+
imported_function_node_ids: dict[str, str] = {}
|
|
209
|
+
imported_class_node_ids: dict[str, str] = {}
|
|
210
|
+
|
|
211
|
+
for node in tree.body:
|
|
212
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
function_node_id = f"function:{relative_path}:{node.name}"
|
|
214
|
+
function_node_ids[node.name] = function_node_id
|
|
215
|
+
nodes.append(
|
|
216
|
+
GraphNode(
|
|
217
|
+
id=function_node_id,
|
|
218
|
+
kind="function",
|
|
219
|
+
language="python",
|
|
220
|
+
path=relative_path,
|
|
221
|
+
symbol=node.name,
|
|
222
|
+
display_name=node.name,
|
|
223
|
+
workspace=workspace,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
edges.append(
|
|
227
|
+
GraphEdge(
|
|
228
|
+
source=module_node_id,
|
|
229
|
+
target=function_node_id,
|
|
230
|
+
kind="contains",
|
|
231
|
+
confidence="high",
|
|
232
|
+
method="ast",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
elif isinstance(node, ast.ClassDef):
|
|
236
|
+
class_node_id = f"class:{relative_path}:{node.name}"
|
|
237
|
+
class_node_ids[node.name] = class_node_id
|
|
238
|
+
nodes.append(
|
|
239
|
+
GraphNode(
|
|
240
|
+
id=class_node_id,
|
|
241
|
+
kind="class",
|
|
242
|
+
language="python",
|
|
243
|
+
path=relative_path,
|
|
244
|
+
symbol=node.name,
|
|
245
|
+
display_name=node.name,
|
|
246
|
+
workspace=workspace,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
edges.append(
|
|
250
|
+
GraphEdge(
|
|
251
|
+
source=module_node_id,
|
|
252
|
+
target=class_node_id,
|
|
253
|
+
kind="contains",
|
|
254
|
+
confidence="high",
|
|
255
|
+
method="ast",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
for ast_node in ast.walk(tree):
|
|
260
|
+
if isinstance(ast_node, ast.Import):
|
|
261
|
+
for alias in ast_node.names:
|
|
262
|
+
target_path = self._resolve_python_import(alias.name, module_map)
|
|
263
|
+
if target_path is None:
|
|
264
|
+
continue
|
|
265
|
+
edges.append(
|
|
266
|
+
GraphEdge(
|
|
267
|
+
source=module_node_id,
|
|
268
|
+
target=f"module:{target_path}",
|
|
269
|
+
kind="imports",
|
|
270
|
+
confidence="high",
|
|
271
|
+
method="ast",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
elif isinstance(ast_node, ast.ImportFrom):
|
|
275
|
+
target_path = self._resolve_python_from_import(
|
|
276
|
+
ast_node, current_module, module_map
|
|
277
|
+
)
|
|
278
|
+
if target_path is None:
|
|
279
|
+
continue
|
|
280
|
+
edges.append(
|
|
281
|
+
GraphEdge(
|
|
282
|
+
source=module_node_id,
|
|
283
|
+
target=f"module:{target_path}",
|
|
284
|
+
kind="imports",
|
|
285
|
+
confidence="high",
|
|
286
|
+
method="ast",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
for alias in ast_node.names:
|
|
290
|
+
local_name = alias.asname or alias.name
|
|
291
|
+
imported_function_node_ids[local_name] = (
|
|
292
|
+
f"function:{target_path}:{alias.name}"
|
|
293
|
+
)
|
|
294
|
+
imported_class_node_ids[local_name] = (
|
|
295
|
+
f"class:{target_path}:{alias.name}"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
for top_level in tree.body:
|
|
299
|
+
if isinstance(top_level, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
300
|
+
source_id = function_node_ids.get(top_level.name)
|
|
301
|
+
if source_id is None:
|
|
302
|
+
continue
|
|
303
|
+
for nested in ast.walk(top_level):
|
|
304
|
+
if isinstance(nested, ast.Call) and isinstance(nested.func, ast.Name):
|
|
305
|
+
target_id = function_node_ids.get(nested.func.id) or imported_function_node_ids.get(
|
|
306
|
+
nested.func.id
|
|
307
|
+
)
|
|
308
|
+
if target_id is not None and target_id != source_id:
|
|
309
|
+
edges.append(
|
|
310
|
+
GraphEdge(
|
|
311
|
+
source=source_id,
|
|
312
|
+
target=target_id,
|
|
313
|
+
kind="calls",
|
|
314
|
+
confidence="medium",
|
|
315
|
+
method="ast",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
elif isinstance(top_level, ast.ClassDef):
|
|
319
|
+
source_id = class_node_ids.get(top_level.name)
|
|
320
|
+
if source_id is None:
|
|
321
|
+
continue
|
|
322
|
+
for base in top_level.bases:
|
|
323
|
+
if isinstance(base, ast.Name):
|
|
324
|
+
target_id = class_node_ids.get(base.id) or imported_class_node_ids.get(
|
|
325
|
+
base.id
|
|
326
|
+
)
|
|
327
|
+
if target_id is not None:
|
|
328
|
+
edges.append(
|
|
329
|
+
GraphEdge(
|
|
330
|
+
source=source_id,
|
|
331
|
+
target=target_id,
|
|
332
|
+
kind="extends",
|
|
333
|
+
confidence="medium",
|
|
334
|
+
method="ast",
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return nodes, edges, limitations
|
|
338
|
+
|
|
339
|
+
def _analyze_node_file(
|
|
340
|
+
self,
|
|
341
|
+
relative_path: str,
|
|
342
|
+
content: str,
|
|
343
|
+
module_map: dict[str, str],
|
|
344
|
+
workspace: str | None,
|
|
345
|
+
) -> tuple[list[GraphNode], list[GraphEdge], list[str]]:
|
|
346
|
+
module_node_id = f"module:{relative_path}"
|
|
347
|
+
nodes: list[GraphNode] = []
|
|
348
|
+
edges: list[GraphEdge] = []
|
|
349
|
+
limitations: list[str] = []
|
|
350
|
+
|
|
351
|
+
function_pattern = re.compile(
|
|
352
|
+
r"^\s*(?:export\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)",
|
|
353
|
+
re.MULTILINE,
|
|
354
|
+
)
|
|
355
|
+
class_pattern = re.compile(
|
|
356
|
+
r"^\s*(?:export\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z_][A-Za-z0-9_]*))?",
|
|
357
|
+
re.MULTILINE,
|
|
358
|
+
)
|
|
359
|
+
function_node_ids: dict[str, str] = {}
|
|
360
|
+
class_node_ids: dict[str, str] = {}
|
|
361
|
+
|
|
362
|
+
for match in function_pattern.finditer(content):
|
|
363
|
+
name = match.group(1)
|
|
364
|
+
node_id = f"function:{relative_path}:{name}"
|
|
365
|
+
function_node_ids[name] = node_id
|
|
366
|
+
nodes.append(
|
|
367
|
+
GraphNode(
|
|
368
|
+
id=node_id,
|
|
369
|
+
kind="function",
|
|
370
|
+
language="nodejs",
|
|
371
|
+
path=relative_path,
|
|
372
|
+
symbol=name,
|
|
373
|
+
display_name=name,
|
|
374
|
+
workspace=workspace,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
edges.append(
|
|
378
|
+
GraphEdge(
|
|
379
|
+
source=module_node_id,
|
|
380
|
+
target=node_id,
|
|
381
|
+
kind="contains",
|
|
382
|
+
confidence="medium",
|
|
383
|
+
method="heuristic",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
for match in class_pattern.finditer(content):
|
|
388
|
+
name = match.group(1)
|
|
389
|
+
base = match.group(2)
|
|
390
|
+
node_id = f"class:{relative_path}:{name}"
|
|
391
|
+
class_node_ids[name] = node_id
|
|
392
|
+
nodes.append(
|
|
393
|
+
GraphNode(
|
|
394
|
+
id=node_id,
|
|
395
|
+
kind="class",
|
|
396
|
+
language="nodejs",
|
|
397
|
+
path=relative_path,
|
|
398
|
+
symbol=name,
|
|
399
|
+
display_name=name,
|
|
400
|
+
workspace=workspace,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
edges.append(
|
|
404
|
+
GraphEdge(
|
|
405
|
+
source=module_node_id,
|
|
406
|
+
target=node_id,
|
|
407
|
+
kind="contains",
|
|
408
|
+
confidence="medium",
|
|
409
|
+
method="heuristic",
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
if base and base in class_node_ids:
|
|
413
|
+
edges.append(
|
|
414
|
+
GraphEdge(
|
|
415
|
+
source=node_id,
|
|
416
|
+
target=class_node_ids[base],
|
|
417
|
+
kind="extends",
|
|
418
|
+
confidence="low",
|
|
419
|
+
method="heuristic",
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
patterns = [
|
|
424
|
+
re.compile(r"""import\s+(?:[^'"]+?\s+from\s+)?['"]([^'"]+)['"]"""),
|
|
425
|
+
re.compile(r"""require\(\s*['"]([^'"]+)['"]\s*\)"""),
|
|
426
|
+
]
|
|
427
|
+
found_specs: set[str] = set()
|
|
428
|
+
for pattern in patterns:
|
|
429
|
+
for match in pattern.finditer(content):
|
|
430
|
+
spec = match.group(1)
|
|
431
|
+
if spec in found_specs:
|
|
432
|
+
continue
|
|
433
|
+
found_specs.add(spec)
|
|
434
|
+
if spec.startswith("."):
|
|
435
|
+
target_path = self._resolve_node_import(relative_path, spec, module_map)
|
|
436
|
+
if target_path is None:
|
|
437
|
+
limitations.append(f"node_unresolved:{relative_path}:{spec}")
|
|
438
|
+
continue
|
|
439
|
+
edges.append(
|
|
440
|
+
GraphEdge(
|
|
441
|
+
source=module_node_id,
|
|
442
|
+
target=f"module:{target_path}",
|
|
443
|
+
kind="imports",
|
|
444
|
+
confidence="medium",
|
|
445
|
+
method="heuristic",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
else:
|
|
449
|
+
limitations.append(f"node_external:{relative_path}:{spec}")
|
|
450
|
+
|
|
451
|
+
return nodes, edges, limitations
|
|
452
|
+
|
|
453
|
+
def _analyze_go_file(
|
|
454
|
+
self,
|
|
455
|
+
relative_path: str,
|
|
456
|
+
content: str,
|
|
457
|
+
import_map: dict[str, str],
|
|
458
|
+
workspace: str | None,
|
|
459
|
+
) -> tuple[list[GraphNode], list[GraphEdge], list[str]]:
|
|
460
|
+
module_node_id = f"module:{relative_path}"
|
|
461
|
+
nodes: list[GraphNode] = []
|
|
462
|
+
edges: list[GraphEdge] = []
|
|
463
|
+
limitations: list[str] = []
|
|
464
|
+
|
|
465
|
+
for match in re.finditer(r"(?m)^func\s+([A-ZA-Za-z_][A-Za-z0-9_]*)\s*\(", content):
|
|
466
|
+
name = match.group(1)
|
|
467
|
+
node_id = f"function:{relative_path}:{name}"
|
|
468
|
+
nodes.append(
|
|
469
|
+
GraphNode(
|
|
470
|
+
id=node_id,
|
|
471
|
+
kind="function",
|
|
472
|
+
language="go",
|
|
473
|
+
path=relative_path,
|
|
474
|
+
symbol=name,
|
|
475
|
+
display_name=name,
|
|
476
|
+
workspace=workspace,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
edges.append(
|
|
480
|
+
GraphEdge(
|
|
481
|
+
source=module_node_id,
|
|
482
|
+
target=node_id,
|
|
483
|
+
kind="contains",
|
|
484
|
+
confidence="medium",
|
|
485
|
+
method="heuristic",
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
import_specs = re.findall(r'"([^"]+)"', self._extract_go_import_block(content))
|
|
490
|
+
for spec in import_specs:
|
|
491
|
+
target_path = import_map.get(spec)
|
|
492
|
+
if target_path is None:
|
|
493
|
+
continue
|
|
494
|
+
edges.append(
|
|
495
|
+
GraphEdge(
|
|
496
|
+
source=module_node_id,
|
|
497
|
+
target=f"module:{target_path}",
|
|
498
|
+
kind="imports",
|
|
499
|
+
confidence="medium",
|
|
500
|
+
method="heuristic",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
return nodes, edges, limitations
|
|
504
|
+
|
|
505
|
+
def _analyze_jvm_file(
|
|
506
|
+
self,
|
|
507
|
+
relative_path: str,
|
|
508
|
+
content: str,
|
|
509
|
+
type_map: dict[str, tuple[str, str]],
|
|
510
|
+
workspace: str | None,
|
|
511
|
+
) -> tuple[list[GraphNode], list[GraphEdge], list[str]]:
|
|
512
|
+
module_node_id = f"module:{relative_path}"
|
|
513
|
+
language = self._language_for_suffix(Path(relative_path).suffix)
|
|
514
|
+
nodes: list[GraphNode] = []
|
|
515
|
+
edges: list[GraphEdge] = []
|
|
516
|
+
limitations: list[str] = []
|
|
517
|
+
imported_type_map = {
|
|
518
|
+
imported.split(".")[-1]: imported
|
|
519
|
+
for imported in re.findall(r"(?m)^\s*import\s+([A-Za-z0-9_.]+)", content)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class_pattern = re.compile(
|
|
523
|
+
r"(?m)^\s*(?:public\s+|private\s+|protected\s+|internal\s+)?"
|
|
524
|
+
r"(?:class|interface|object|trait)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
525
|
+
r"(?:\s+extends\s+([A-Za-z_][A-Za-z0-9_.]*))?"
|
|
526
|
+
)
|
|
527
|
+
local_class_ids: dict[str, str] = {}
|
|
528
|
+
for match in class_pattern.finditer(content):
|
|
529
|
+
name = match.group(1)
|
|
530
|
+
base = match.group(2)
|
|
531
|
+
node_id = f"class:{relative_path}:{name}"
|
|
532
|
+
local_class_ids[name] = node_id
|
|
533
|
+
nodes.append(
|
|
534
|
+
GraphNode(
|
|
535
|
+
id=node_id,
|
|
536
|
+
kind="class",
|
|
537
|
+
language=language,
|
|
538
|
+
path=relative_path,
|
|
539
|
+
symbol=name,
|
|
540
|
+
display_name=name,
|
|
541
|
+
workspace=workspace,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
edges.append(
|
|
545
|
+
GraphEdge(
|
|
546
|
+
source=module_node_id,
|
|
547
|
+
target=node_id,
|
|
548
|
+
kind="contains",
|
|
549
|
+
confidence="medium",
|
|
550
|
+
method="heuristic",
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
if base:
|
|
554
|
+
base_short = base.split(".")[-1]
|
|
555
|
+
if base_short in local_class_ids:
|
|
556
|
+
edges.append(
|
|
557
|
+
GraphEdge(
|
|
558
|
+
source=node_id,
|
|
559
|
+
target=local_class_ids[base_short],
|
|
560
|
+
kind="extends",
|
|
561
|
+
confidence="low",
|
|
562
|
+
method="heuristic",
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
imported_base = imported_type_map.get(base_short)
|
|
567
|
+
fqcn = imported_base or base
|
|
568
|
+
mapping = type_map.get(fqcn)
|
|
569
|
+
if mapping is None:
|
|
570
|
+
continue
|
|
571
|
+
base_path, _base_name = mapping
|
|
572
|
+
edges.append(
|
|
573
|
+
GraphEdge(
|
|
574
|
+
source=node_id,
|
|
575
|
+
target=f"class:{base_path}:{base_short}",
|
|
576
|
+
kind="extends",
|
|
577
|
+
confidence="low",
|
|
578
|
+
method="heuristic",
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
for imported in imported_type_map.values():
|
|
583
|
+
mapping = type_map.get(imported)
|
|
584
|
+
if mapping is None:
|
|
585
|
+
limitations.append(f"jvm_unresolved:{relative_path}:{imported}")
|
|
586
|
+
continue
|
|
587
|
+
target_path, _symbol = mapping
|
|
588
|
+
edges.append(
|
|
589
|
+
GraphEdge(
|
|
590
|
+
source=module_node_id,
|
|
591
|
+
target=f"module:{target_path}",
|
|
592
|
+
kind="imports",
|
|
593
|
+
confidence="medium",
|
|
594
|
+
method="heuristic",
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
return nodes, edges, limitations
|
|
598
|
+
|
|
599
|
+
def _build_python_module_map(self, source_files: list[str]) -> dict[str, str]:
|
|
600
|
+
module_map: dict[str, str] = {}
|
|
601
|
+
for relative_path in source_files:
|
|
602
|
+
path = PurePosixPath(relative_path)
|
|
603
|
+
if path.suffix not in self._PYTHON_EXTENSIONS:
|
|
604
|
+
continue
|
|
605
|
+
if path.name == "__init__.py":
|
|
606
|
+
module_name = ".".join(path.parts[:-1])
|
|
607
|
+
else:
|
|
608
|
+
module_name = ".".join(path.with_suffix("").parts)
|
|
609
|
+
if module_name:
|
|
610
|
+
module_map[relative_path] = module_name
|
|
611
|
+
return module_map
|
|
612
|
+
|
|
613
|
+
def _build_go_import_map(self, root: Path, source_files: list[str]) -> dict[str, str]:
|
|
614
|
+
go_mod = root / "go.mod"
|
|
615
|
+
if not go_mod.exists():
|
|
616
|
+
return {}
|
|
617
|
+
module_name = ""
|
|
618
|
+
for line in go_mod.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
619
|
+
stripped = line.strip()
|
|
620
|
+
if stripped.startswith("module "):
|
|
621
|
+
module_name = stripped.removeprefix("module ").strip()
|
|
622
|
+
break
|
|
623
|
+
if not module_name:
|
|
624
|
+
return {}
|
|
625
|
+
mapping: dict[str, str] = {}
|
|
626
|
+
for relative_path in source_files:
|
|
627
|
+
path = PurePosixPath(relative_path)
|
|
628
|
+
if path.suffix not in self._GO_EXTENSIONS:
|
|
629
|
+
continue
|
|
630
|
+
directory = "." if len(path.parts) == 1 else "/".join(path.parts[:-1])
|
|
631
|
+
import_path = module_name if directory == "." else f"{module_name}/{directory}"
|
|
632
|
+
mapping[import_path] = relative_path
|
|
633
|
+
return mapping
|
|
634
|
+
|
|
635
|
+
def _build_jvm_type_map(self, root: Path, source_files: list[str]) -> dict[str, tuple[str, str]]:
|
|
636
|
+
mapping: dict[str, tuple[str, str]] = {}
|
|
637
|
+
package_pattern = re.compile(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)")
|
|
638
|
+
type_pattern = re.compile(
|
|
639
|
+
r"(?m)^\s*(?:public\s+|private\s+|protected\s+|internal\s+)?"
|
|
640
|
+
r"(?:class|interface|object|trait)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
641
|
+
)
|
|
642
|
+
for relative_path in source_files:
|
|
643
|
+
if Path(relative_path).suffix not in self._JVM_EXTENSIONS:
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
content = (root / relative_path).read_text(encoding="utf-8", errors="replace")
|
|
647
|
+
except OSError:
|
|
648
|
+
continue
|
|
649
|
+
package_match = package_pattern.search(content)
|
|
650
|
+
type_match = type_pattern.search(content)
|
|
651
|
+
if type_match is None:
|
|
652
|
+
continue
|
|
653
|
+
package_name = package_match.group(1) if package_match else ""
|
|
654
|
+
symbol = type_match.group(1)
|
|
655
|
+
fqcn = f"{package_name}.{symbol}" if package_name else symbol
|
|
656
|
+
mapping[fqcn] = (relative_path, symbol)
|
|
657
|
+
return mapping
|
|
658
|
+
|
|
659
|
+
def _resolve_python_import(
|
|
660
|
+
self, module_name: str, module_map: dict[str, str]
|
|
661
|
+
) -> Optional[str]:
|
|
662
|
+
for path, candidate in module_map.items():
|
|
663
|
+
if candidate == module_name:
|
|
664
|
+
return path
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
def _resolve_python_from_import(
|
|
668
|
+
self,
|
|
669
|
+
node: ast.ImportFrom,
|
|
670
|
+
current_module: str | None,
|
|
671
|
+
module_map: dict[str, str],
|
|
672
|
+
) -> Optional[str]:
|
|
673
|
+
if node.level and current_module:
|
|
674
|
+
package_parts = current_module.split(".")
|
|
675
|
+
base_parts = package_parts[:-1]
|
|
676
|
+
if node.level > 1:
|
|
677
|
+
base_parts = base_parts[: max(0, len(base_parts) - (node.level - 1))]
|
|
678
|
+
target_module = ".".join(base_parts + ([node.module] if node.module else []))
|
|
679
|
+
else:
|
|
680
|
+
target_module = node.module or ""
|
|
681
|
+
if not target_module:
|
|
682
|
+
return None
|
|
683
|
+
return self._resolve_python_import(target_module, module_map)
|
|
684
|
+
|
|
685
|
+
def _resolve_node_import(
|
|
686
|
+
self, source_path: str, spec: str, module_map: dict[str, str]
|
|
687
|
+
) -> Optional[str]:
|
|
688
|
+
base_dir = PurePosixPath(source_path).parent
|
|
689
|
+
spec_path = PurePosixPath(spec)
|
|
690
|
+
candidate_base = (base_dir / spec_path).as_posix()
|
|
691
|
+
candidates = [
|
|
692
|
+
candidate_base,
|
|
693
|
+
f"{candidate_base}.js",
|
|
694
|
+
f"{candidate_base}.jsx",
|
|
695
|
+
f"{candidate_base}.ts",
|
|
696
|
+
f"{candidate_base}.tsx",
|
|
697
|
+
f"{candidate_base}/index.js",
|
|
698
|
+
f"{candidate_base}/index.ts",
|
|
699
|
+
f"{candidate_base}/index.tsx",
|
|
700
|
+
]
|
|
701
|
+
for candidate in candidates:
|
|
702
|
+
normalized = PurePosixPath(candidate).as_posix()
|
|
703
|
+
if normalized in module_map:
|
|
704
|
+
return normalized
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
def _extract_go_import_block(self, content: str) -> str:
|
|
708
|
+
block_match = re.search(r"import\s*\((.*?)\)", content, re.DOTALL)
|
|
709
|
+
if block_match is not None:
|
|
710
|
+
return block_match.group(1)
|
|
711
|
+
single_imports = re.findall(r'(?m)^\s*import\s+"([^"]+)"', content)
|
|
712
|
+
return "\n".join(f'"{item}"' for item in single_imports)
|
|
713
|
+
|
|
714
|
+
def _append_node(self, nodes: list[GraphNode], node_ids: set[str], node: GraphNode) -> None:
|
|
715
|
+
if len(nodes) >= self.max_nodes or node.id in node_ids:
|
|
716
|
+
return
|
|
717
|
+
node_ids.add(node.id)
|
|
718
|
+
nodes.append(node)
|
|
719
|
+
|
|
720
|
+
def _append_edge(
|
|
721
|
+
self,
|
|
722
|
+
edges: list[GraphEdge],
|
|
723
|
+
edge_keys: set[tuple[str, str, str, str]],
|
|
724
|
+
edge: GraphEdge,
|
|
725
|
+
) -> None:
|
|
726
|
+
if len(edges) >= self.max_edges:
|
|
727
|
+
return
|
|
728
|
+
key = (edge.source, edge.target, edge.kind, edge.method)
|
|
729
|
+
if key in edge_keys:
|
|
730
|
+
return
|
|
731
|
+
edge_keys.add(key)
|
|
732
|
+
edges.append(edge)
|
|
733
|
+
|
|
734
|
+
def _language_for_suffix(self, suffix: str) -> str:
|
|
735
|
+
if suffix in self._PYTHON_EXTENSIONS:
|
|
736
|
+
return "python"
|
|
737
|
+
if suffix in self._NODE_EXTENSIONS:
|
|
738
|
+
return "nodejs"
|
|
739
|
+
if suffix in self._GO_EXTENSIONS:
|
|
740
|
+
return "go"
|
|
741
|
+
if suffix == ".java":
|
|
742
|
+
return "java"
|
|
743
|
+
if suffix == ".kt":
|
|
744
|
+
return "kotlin"
|
|
745
|
+
if suffix == ".scala":
|
|
746
|
+
return "scala"
|
|
747
|
+
return "unknown"
|
|
748
|
+
|
|
749
|
+
def _node_id(self, kind: str, path: str, symbol: str | None = None) -> str:
|
|
750
|
+
if symbol:
|
|
751
|
+
return f"{kind}:{path}:{symbol}"
|
|
752
|
+
return f"{kind}:{path}"
|
|
753
|
+
|
|
754
|
+
def _unique(self, values: list[str]) -> list[str]:
|
|
755
|
+
result: list[str] = []
|
|
756
|
+
for value in values:
|
|
757
|
+
if value not in result:
|
|
758
|
+
result.append(value)
|
|
759
|
+
return result
|