sourcecode 0.3.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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
sourcecode/cli.py CHANGED
@@ -51,6 +51,11 @@ def main(
51
51
  "--dependencies",
52
52
  help="Incluir dependencias directas, versiones exactas y transitivas cuando haya lockfiles compatibles",
53
53
  ),
54
+ graph_modules: bool = typer.Option(
55
+ False,
56
+ "--graph-modules",
57
+ help="Incluir grafo estructural de modulos, imports y relaciones simples del codigo",
58
+ ),
54
59
  no_redact: bool = typer.Option(
55
60
  False,
56
61
  "--no-redact",
@@ -95,6 +100,7 @@ def main(
95
100
 
96
101
  from sourcecode.dependency_analyzer import DependencyAnalyzer
97
102
  from sourcecode.detectors import ProjectDetector, build_default_detectors
103
+ from sourcecode.graph_analyzer import GraphAnalyzer
98
104
  from sourcecode.redactor import SecretRedactor, redact_dict
99
105
  from sourcecode.scanner import FileScanner
100
106
  from sourcecode.schema import AnalysisMetadata, EntryPoint, SourceMap, StackDetection
@@ -118,11 +124,33 @@ def main(
118
124
  filtered[name] = value
119
125
  return filtered
120
126
 
127
+ def prune_workspace_paths(
128
+ tree: dict[str, Any], workspace_paths: list[str]
129
+ ) -> dict[str, Any]:
130
+ pruned = dict(tree)
131
+ for workspace_path in workspace_paths:
132
+ parts = [part for part in workspace_path.split("/") if part]
133
+ if not parts:
134
+ continue
135
+ node = pruned
136
+ for index, part in enumerate(parts):
137
+ if not isinstance(node, dict) or part not in node:
138
+ break
139
+ if index == len(parts) - 1:
140
+ node.pop(part, None)
141
+ break
142
+ child = node.get(part)
143
+ if not isinstance(child, dict):
144
+ break
145
+ node = child
146
+ return pruned
147
+
121
148
  file_tree = filter_sensitive_files(raw_tree)
122
149
  manifests = scanner.find_manifests()
123
150
  detector = ProjectDetector(build_default_detectors())
124
151
  workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
125
152
  dependency_analyzer = DependencyAnalyzer() if dependencies else None
153
+ graph_analyzer = GraphAnalyzer() if graph_modules else None
126
154
 
127
155
  root_manifests = [
128
156
  manifest
@@ -142,6 +170,17 @@ def main(
142
170
  root_dependencies, root_summary = dependency_analyzer.analyze(target)
143
171
  dependency_records.extend(root_dependencies)
144
172
  dependency_summaries.append(root_summary)
173
+ module_graphs = []
174
+ if graph_analyzer is not None:
175
+ root_graph_tree = (
176
+ prune_workspace_paths(
177
+ file_tree,
178
+ [workspace.path for workspace in workspace_analysis.workspaces],
179
+ )
180
+ if workspace_analysis.workspaces
181
+ else file_tree
182
+ )
183
+ module_graphs.append(graph_analyzer.analyze(target, root_graph_tree))
145
184
 
146
185
  for workspace in workspace_analysis.workspaces:
147
186
  workspace_root = target / workspace.path
@@ -174,6 +213,15 @@ def main(
174
213
  )
175
214
  dependency_records.extend(workspace_dependencies)
176
215
  dependency_summaries.append(workspace_summary)
216
+ if graph_analyzer is not None:
217
+ workspace_graph = graph_analyzer.analyze(
218
+ workspace_root,
219
+ workspace_tree,
220
+ workspace=workspace.path,
221
+ )
222
+ module_graphs.append(
223
+ graph_analyzer.prefix_graph(workspace_graph, workspace.path, workspace.path)
224
+ )
177
225
 
178
226
  stacks, project_type = detector.classify_results(
179
227
  file_tree,
@@ -186,6 +234,7 @@ def main(
186
234
  if dependency_analyzer is not None
187
235
  else None
188
236
  )
237
+ module_graph = graph_analyzer.merge_graphs(module_graphs) if graph_analyzer is not None else None
189
238
 
190
239
  # 3. Construir el schema
191
240
  metadata = AnalysisMetadata(analyzed_path=str(target))
@@ -197,6 +246,7 @@ def main(
197
246
  entry_points=entry_points,
198
247
  dependencies=dependency_records,
199
248
  dependency_summary=dependency_summary,
249
+ module_graph=module_graph,
200
250
  )
201
251
 
202
252
  # 4. Serializar (con o sin modo compact)
@@ -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
sourcecode/schema.py CHANGED
@@ -96,6 +96,51 @@ class DependencySummary:
96
96
  limitations: list[str] = field(default_factory=list)
97
97
 
98
98
 
99
+ @dataclass
100
+ class GraphNode:
101
+ """Nodo del grafo estructural del codigo."""
102
+
103
+ id: str
104
+ kind: str
105
+ language: str
106
+ path: str
107
+ symbol: Optional[str] = None
108
+ display_name: Optional[str] = None
109
+ workspace: Optional[str] = None
110
+
111
+
112
+ @dataclass
113
+ class GraphEdge:
114
+ """Arista del grafo estructural del codigo."""
115
+
116
+ source: str
117
+ target: str
118
+ kind: str
119
+ confidence: Literal["high", "medium", "low"] = "medium"
120
+ method: Literal["ast", "heuristic", "unresolved"] = "heuristic"
121
+
122
+
123
+ @dataclass
124
+ class ModuleGraphSummary:
125
+ """Resumen del analisis estructural del grafo."""
126
+
127
+ requested: bool = False
128
+ node_count: int = 0
129
+ edge_count: int = 0
130
+ languages: list[str] = field(default_factory=list)
131
+ methods: list[str] = field(default_factory=list)
132
+ limitations: list[str] = field(default_factory=list)
133
+
134
+
135
+ @dataclass
136
+ class ModuleGraph:
137
+ """Grafo de modulos, simbolos y relaciones del proyecto."""
138
+
139
+ nodes: list[GraphNode] = field(default_factory=list)
140
+ edges: list[GraphEdge] = field(default_factory=list)
141
+ summary: ModuleGraphSummary = field(default_factory=ModuleGraphSummary)
142
+
143
+
99
144
  @dataclass
100
145
  class SourceMap:
101
146
  """Schema completo del output v1.0.
@@ -115,3 +160,4 @@ class SourceMap:
115
160
  entry_points: list[EntryPoint] = field(default_factory=list)
116
161
  dependencies: list[DependencyRecord] = field(default_factory=list)
117
162
  dependency_summary: Optional[DependencySummary] = None
163
+ module_graph: Optional[ModuleGraph] = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Genera un mapa de contexto estructurado de proyectos de software para agentes IA
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -52,6 +52,12 @@ Include direct dependencies, exact versions, and transitive dependencies when co
52
52
  sourcecode . --dependencies
53
53
  ```
54
54
 
55
+ Include an internal module graph with imports and simple structural relations:
56
+
57
+ ```bash
58
+ sourcecode . --graph-modules
59
+ ```
60
+
55
61
  Show the version:
56
62
 
57
63
  ```bash
@@ -64,6 +70,7 @@ Main options:
64
70
  - `--output PATH`: write to a file instead of `stdout`.
65
71
  - `--compact`: return a reduced view with `schema_version`, `project_type`, `stacks`, `entry_points`, and `file_tree_depth1`.
66
72
  - `--dependencies`: include direct dependencies, resolved versions, and transitive relationships when lockfiles make that possible.
73
+ - `--graph-modules`: include a structural graph with modules, imports, and simple relations such as `contains`, `calls`, or `extends`.
67
74
  - `--depth INTEGER`: maximum file tree depth.
68
75
  - `--no-redact`: disable secret redaction.
69
76
 
@@ -161,6 +168,7 @@ The full schema includes:
161
168
  - `entry_points`: detected entry points by stack.
162
169
  - `dependencies`: optional dependency records with declared and resolved versions.
163
170
  - `dependency_summary`: optional summary with ecosystem coverage, counts, and known limitations.
171
+ - `module_graph`: optional structural graph with nodes, edges, and analysis limits.
164
172
 
165
173
  Example dependency block:
166
174
 
@@ -204,6 +212,61 @@ Example dependency block:
204
212
 
205
213
  Dependency analysis is still offline and conservative: if a lockfile does not expose a reliable transitive graph, `sourcecode` reports direct dependencies and records the limitation instead of guessing.
206
214
 
215
+ Example module graph block:
216
+
217
+ ```json
218
+ {
219
+ "module_graph": {
220
+ "nodes": [
221
+ {
222
+ "id": "module:app/main.py",
223
+ "kind": "module",
224
+ "language": "python",
225
+ "path": "app/main.py",
226
+ "symbol": null,
227
+ "display_name": "app/main.py",
228
+ "workspace": null
229
+ },
230
+ {
231
+ "id": "function:app/main.py:run",
232
+ "kind": "function",
233
+ "language": "python",
234
+ "path": "app/main.py",
235
+ "symbol": "run",
236
+ "display_name": "run",
237
+ "workspace": null
238
+ }
239
+ ],
240
+ "edges": [
241
+ {
242
+ "source": "module:app/main.py",
243
+ "target": "module:app/utils.py",
244
+ "kind": "imports",
245
+ "confidence": "high",
246
+ "method": "ast"
247
+ },
248
+ {
249
+ "source": "function:app/main.py:run",
250
+ "target": "function:app/utils.py:helper",
251
+ "kind": "calls",
252
+ "confidence": "medium",
253
+ "method": "ast"
254
+ }
255
+ ],
256
+ "summary": {
257
+ "requested": true,
258
+ "node_count": 2,
259
+ "edge_count": 2,
260
+ "languages": ["python"],
261
+ "methods": ["ast"],
262
+ "limitations": []
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ Graph analysis is also offline and conservative. `sourcecode` prefers partial but defensible edges over pretending to build a perfect semantic call graph, and it records parse failures, unresolved imports, or analysis budgets in `module_graph.summary.limitations`.
269
+
207
270
  Detailed reference: [docs/schema.md](/Users/user/Documents/workspace/atlas/atlas-cli/docs/schema.md).
208
271
 
209
272
  ## Development
@@ -1,10 +1,11 @@
1
- sourcecode/__init__.py,sha256=sw7UZiMGE-MNL0gak-WMvhCd_M-yBDowwBXfPLOuDDI,99
1
+ sourcecode/__init__.py,sha256=iBHGXEsmbN21QO7wvQ3ZZhBy96xjvWrfacPsjTSGcds,99
2
2
  sourcecode/classifier.py,sha256=7ubGTn5vfw9z-9eosLIYDtQUu7d8dvB4XKKDxysUVSM,6910
3
- sourcecode/cli.py,sha256=vNf8DvdKmOyB8Sp3goK4lBDv3qQFzb6tKcULVziGxhA,8167
3
+ sourcecode/cli.py,sha256=NysuO95KIxpBEpRiwor93_WFvdMvjk0ArDRv8KPX440,10136
4
4
  sourcecode/dependency_analyzer.py,sha256=c8tSbUpug-2avKNWCP4-_e2_uhBwWl96LwL1Be8O90M,39067
5
+ sourcecode/graph_analyzer.py,sha256=hskmEr-c5z3HjWr2S_2ccaVSMrpikYdMY-ExbAiaK60,29353
5
6
  sourcecode/redactor.py,sha256=abSf5jH_IzYzpjF3lEN6hCwdXxvmyHs-3DHz9fd8Mic,2883
6
7
  sourcecode/scanner.py,sha256=TvcuD77m_NF8OlTj3XH1b_jO0qrto8c90fND96ecqb0,6182
7
- sourcecode/schema.py,sha256=Z1yg9FzwBsLEA-uIEofVqzXOEhFZNdP3e7LJvyFRcxk,3346
8
+ sourcecode/schema.py,sha256=h5uemXKCrPVpEslO2iM_Kw3ax-4UmpOpkfK50HgTNo4,4517
8
9
  sourcecode/serializer.py,sha256=hezTYOAAvZ2yPn7Te7Eu42ajbMhVCsUkuUZtqx4wjAs,3085
9
10
  sourcecode/tree_utils.py,sha256=5WHQc2L-AGqxKFfYYcQftE6hETxgUxP9MWDuaThcAUw,991
10
11
  sourcecode/workspace.py,sha256=1L4xH38gU89fLeMYSuF3ft7lSdCyTfMfJ9NQt5q4Ric,6904
@@ -27,7 +28,7 @@ sourcecode/detectors/rust.py,sha256=iUz9lE4HfBy8CBaDwGhfh6kLsNIovrH2ay2v3y6qNDc,
27
28
  sourcecode/detectors/systems.py,sha256=qf5M9tio1H6XDorSstTWIxKe0xVLR_QGTJpFGyhDz4A,1690
28
29
  sourcecode/detectors/terraform.py,sha256=5EOKQtTViWo7u3tFK0h45YD8GQPwhrww0cOicxGPPn4,1732
29
30
  sourcecode/detectors/tooling.py,sha256=wPvudLCJZ8_cr9GIx44C8jkLAlEN9uTUZcJsP7eVYnQ,1937
30
- sourcecode-0.3.0.dist-info/METADATA,sha256=kLVxfZvLBfFwiXaIvhRWXCfwi3H-gJasHWAs0-wfiEc,5613
31
- sourcecode-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
32
- sourcecode-0.3.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
33
- sourcecode-0.3.0.dist-info/RECORD,,
31
+ sourcecode-0.4.0.dist-info/METADATA,sha256=gj14EtCMRfrSsiMpfMN1E2AaRObCQCU3Z_Io7Xpx_b4,7339
32
+ sourcecode-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
33
+ sourcecode-0.4.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
34
+ sourcecode-0.4.0.dist-info/RECORD,,