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.
@@ -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