sourcecode 0.4.0__py3-none-any.whl → 0.6.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.4.0"
3
+ __version__ = "0.6.0"
sourcecode/cli.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Any, Optional
6
+ from typing import Any, Optional, cast
7
7
 
8
8
  import typer
9
9
 
@@ -16,6 +16,8 @@ app = typer.Typer(
16
16
  )
17
17
 
18
18
  FORMAT_CHOICES = ["json", "yaml"]
19
+ GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
20
+ GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
19
21
 
20
22
 
21
23
  def version_callback(value: bool) -> None:
@@ -56,6 +58,23 @@ def main(
56
58
  "--graph-modules",
57
59
  help="Incluir grafo estructural de modulos, imports y relaciones simples del codigo",
58
60
  ),
61
+ graph_detail: str = typer.Option(
62
+ "high",
63
+ "--graph-detail",
64
+ help="Nivel de detalle del grafo: high|medium|full",
65
+ show_default=True,
66
+ ),
67
+ max_nodes: Optional[int] = typer.Option(
68
+ None,
69
+ "--max-nodes",
70
+ help="Limite de nodos para `--graph-modules` en modos high/medium",
71
+ min=1,
72
+ ),
73
+ graph_edges: Optional[str] = typer.Option(
74
+ None,
75
+ "--graph-edges",
76
+ help="Tipos de arista para `--graph-modules` separados por comas: imports,calls,contains,extends",
77
+ ),
59
78
  no_redact: bool = typer.Option(
60
79
  False,
61
80
  "--no-redact",
@@ -85,6 +104,12 @@ def main(
85
104
  err=True,
86
105
  )
87
106
  raise typer.Exit(code=1)
107
+ if graph_detail not in GRAPH_DETAIL_CHOICES:
108
+ typer.echo(
109
+ f"Error: valor invalido '{graph_detail}' para --graph-detail. Opciones: {', '.join(GRAPH_DETAIL_CHOICES)}",
110
+ err=True,
111
+ )
112
+ raise typer.Exit(code=1)
88
113
 
89
114
  # Resolver y validar path
90
115
  target = path.resolve()
@@ -100,7 +125,7 @@ def main(
100
125
 
101
126
  from sourcecode.dependency_analyzer import DependencyAnalyzer
102
127
  from sourcecode.detectors import ProjectDetector, build_default_detectors
103
- from sourcecode.graph_analyzer import GraphAnalyzer
128
+ from sourcecode.graph_analyzer import GraphAnalyzer, GraphDetail
104
129
  from sourcecode.redactor import SecretRedactor, redact_dict
105
130
  from sourcecode.scanner import FileScanner
106
131
  from sourcecode.schema import AnalysisMetadata, EntryPoint, SourceMap, StackDetection
@@ -151,6 +176,21 @@ def main(
151
176
  workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
152
177
  dependency_analyzer = DependencyAnalyzer() if dependencies else None
153
178
  graph_analyzer = GraphAnalyzer() if graph_modules else None
179
+ parsed_graph_edges = (
180
+ {edge.strip() for edge in graph_edges.split(",") if edge.strip()}
181
+ if graph_edges
182
+ else None
183
+ )
184
+ if parsed_graph_edges is not None:
185
+ invalid_edges = sorted(parsed_graph_edges - GRAPH_EDGE_CHOICES)
186
+ if invalid_edges:
187
+ typer.echo(
188
+ "Error: valores invalidos para --graph-edges: "
189
+ f"{', '.join(invalid_edges)}. Opciones: {', '.join(sorted(GRAPH_EDGE_CHOICES))}",
190
+ err=True,
191
+ )
192
+ raise typer.Exit(code=1)
193
+ graph_detail_typed = cast(GraphDetail, graph_detail)
154
194
 
155
195
  root_manifests = [
156
196
  manifest
@@ -180,7 +220,14 @@ def main(
180
220
  if workspace_analysis.workspaces
181
221
  else file_tree
182
222
  )
183
- module_graphs.append(graph_analyzer.analyze(target, root_graph_tree))
223
+ module_graphs.append(
224
+ graph_analyzer.analyze(
225
+ target,
226
+ root_graph_tree,
227
+ detail="full",
228
+ entry_points=entry_points,
229
+ )
230
+ )
184
231
 
185
232
  for workspace in workspace_analysis.workspaces:
186
233
  workspace_root = target / workspace.path
@@ -215,10 +262,12 @@ def main(
215
262
  dependency_summaries.append(workspace_summary)
216
263
  if graph_analyzer is not None:
217
264
  workspace_graph = graph_analyzer.analyze(
218
- workspace_root,
219
- workspace_tree,
220
- workspace=workspace.path,
221
- )
265
+ workspace_root,
266
+ workspace_tree,
267
+ workspace=workspace.path,
268
+ detail="full",
269
+ entry_points=workspace_entry_points,
270
+ )
222
271
  module_graphs.append(
223
272
  graph_analyzer.prefix_graph(workspace_graph, workspace.path, workspace.path)
224
273
  )
@@ -234,7 +283,17 @@ def main(
234
283
  if dependency_analyzer is not None
235
284
  else None
236
285
  )
237
- module_graph = graph_analyzer.merge_graphs(module_graphs) if graph_analyzer is not None else None
286
+ module_graph = (
287
+ graph_analyzer.merge_graphs(
288
+ module_graphs,
289
+ detail=graph_detail_typed,
290
+ edge_kinds=parsed_graph_edges,
291
+ max_nodes=max_nodes,
292
+ entry_points=entry_points,
293
+ )
294
+ if graph_analyzer is not None
295
+ else None
296
+ )
238
297
 
239
298
  # 3. Construir el schema
240
299
  metadata = AnalysisMetadata(analyzed_path=str(target))
@@ -247,6 +306,7 @@ def main(
247
306
  dependencies=dependency_records,
248
307
  dependency_summary=dependency_summary,
249
308
  module_graph=module_graph,
309
+ module_graph_summary=module_graph.summary if module_graph is not None else None,
250
310
  )
251
311
 
252
312
  # 4. Serializar (con o sin modo compact)
@@ -1,15 +1,19 @@
1
- """Analisis estructural offline para modulos, imports y relaciones simples."""
1
+ """Analisis estructural offline optimizado para LLMs y modo full."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import ast
5
5
  import re
6
+ from collections import Counter, defaultdict
6
7
  from dataclasses import replace
7
8
  from pathlib import Path, PurePosixPath
8
- from typing import Any, Optional
9
+ from typing import Any, Literal, Optional
9
10
 
10
- from sourcecode.schema import GraphEdge, GraphNode, ModuleGraph, ModuleGraphSummary
11
+ from sourcecode.schema import EntryPoint, GraphEdge, GraphNode, ModuleGraph, ModuleGraphSummary
11
12
  from sourcecode.tree_utils import flatten_file_tree
12
13
 
14
+ GraphDetail = Literal["high", "medium", "full"]
15
+ GraphImportance = Literal["high", "medium", "low"]
16
+
13
17
 
14
18
  class GraphAnalyzer:
15
19
  """Construye un grafo estructural parcial y seguro del proyecto."""
@@ -19,6 +23,16 @@ class GraphAnalyzer:
19
23
  _GO_EXTENSIONS = {".go"}
20
24
  _JVM_EXTENSIONS = {".java", ".kt", ".scala"}
21
25
  _SUPPORTED_EXTENSIONS = _PYTHON_EXTENSIONS | _NODE_EXTENSIONS | _GO_EXTENSIONS | _JVM_EXTENSIONS
26
+ _DEFAULT_EDGE_KINDS: dict[GraphDetail, tuple[str, ...]] = {
27
+ "high": ("imports",),
28
+ "medium": ("imports", "calls"),
29
+ "full": ("imports", "calls", "contains", "extends"),
30
+ }
31
+ _DEFAULT_MAX_NODES: dict[GraphDetail, Optional[int]] = {
32
+ "high": 80,
33
+ "medium": 160,
34
+ "full": None,
35
+ }
22
36
 
23
37
  def __init__(
24
38
  self,
@@ -39,6 +53,98 @@ class GraphAnalyzer:
39
53
  file_tree: dict[str, Any],
40
54
  *,
41
55
  workspace: str | None = None,
56
+ detail: GraphDetail = "full",
57
+ edge_kinds: set[str] | None = None,
58
+ max_nodes: int | None = None,
59
+ entry_points: list[EntryPoint] | None = None,
60
+ ) -> ModuleGraph:
61
+ full_graph = self._analyze_full(root, file_tree, workspace=workspace)
62
+ return self._project_graph(
63
+ full_graph,
64
+ detail=detail,
65
+ edge_kinds=edge_kinds,
66
+ max_nodes=max_nodes,
67
+ entry_points=entry_points or [],
68
+ )
69
+
70
+ def merge_graphs(
71
+ self,
72
+ graphs: list[ModuleGraph],
73
+ *,
74
+ detail: GraphDetail = "full",
75
+ edge_kinds: set[str] | None = None,
76
+ max_nodes: int | None = None,
77
+ entry_points: list[EntryPoint] | None = None,
78
+ ) -> ModuleGraph:
79
+ nodes: list[GraphNode] = []
80
+ edges: list[GraphEdge] = []
81
+ limitations: list[str] = []
82
+ node_ids: set[str] = set()
83
+ edge_keys: set[tuple[str, str, str, str]] = set()
84
+ for graph in graphs:
85
+ for node in graph.nodes:
86
+ self._append_node(nodes, node_ids, node)
87
+ for edge in graph.edges:
88
+ self._append_edge(edges, edge_keys, edge)
89
+ limitations.extend(graph.summary.limitations)
90
+ merged = ModuleGraph(
91
+ nodes=nodes,
92
+ edges=edges,
93
+ summary=ModuleGraphSummary(
94
+ requested=bool(graphs),
95
+ node_count=len(nodes),
96
+ edge_count=len(edges),
97
+ languages=sorted({node.language for node in nodes}),
98
+ methods=sorted({edge.method for edge in edges}),
99
+ limitations=self._unique(limitations),
100
+ ),
101
+ )
102
+ return self._project_graph(
103
+ merged,
104
+ detail=detail,
105
+ edge_kinds=edge_kinds,
106
+ max_nodes=max_nodes,
107
+ entry_points=entry_points or [],
108
+ )
109
+
110
+ def prefix_graph(self, graph: ModuleGraph, prefix: str, workspace: str) -> ModuleGraph:
111
+ """Reescribe ids y paths de un grafo de workspace al espacio de paths del repo."""
112
+ node_id_map: dict[str, str] = {}
113
+ prefixed_nodes: list[GraphNode] = []
114
+ clean_prefix = prefix.strip("/")
115
+ for node in graph.nodes:
116
+ prefixed_path = f"{clean_prefix}/{node.path}".strip("/")
117
+ new_id = self._node_id(node.kind, prefixed_path, node.symbol)
118
+ node_id_map[node.id] = new_id
119
+ prefixed_nodes.append(
120
+ replace(
121
+ node,
122
+ id=new_id,
123
+ path=prefixed_path,
124
+ workspace=workspace,
125
+ )
126
+ )
127
+ prefixed_edges: list[GraphEdge] = []
128
+ for edge in graph.edges:
129
+ prefixed_edges.append(
130
+ replace(
131
+ edge,
132
+ source=node_id_map.get(edge.source, edge.source),
133
+ target=node_id_map.get(edge.target, edge.target),
134
+ )
135
+ )
136
+ return ModuleGraph(
137
+ nodes=prefixed_nodes,
138
+ edges=prefixed_edges,
139
+ summary=replace(graph.summary),
140
+ )
141
+
142
+ def _analyze_full(
143
+ self,
144
+ root: Path,
145
+ file_tree: dict[str, Any],
146
+ *,
147
+ workspace: str | None,
42
148
  ) -> ModuleGraph:
43
149
  source_files = [
44
150
  path
@@ -47,9 +153,7 @@ class GraphAnalyzer:
47
153
  ]
48
154
  limitations: list[str] = []
49
155
  if len(source_files) > self.max_files:
50
- limitations.append(
51
- f"max_files_reached:{len(source_files)}>{self.max_files}"
52
- )
156
+ limitations.append(f"max_files_reached:{len(source_files)}>{self.max_files}")
53
157
  source_files = source_files[: self.max_files]
54
158
 
55
159
  nodes: list[GraphNode] = []
@@ -58,7 +162,9 @@ class GraphAnalyzer:
58
162
  edge_keys: set[tuple[str, str, str, str]] = set()
59
163
 
60
164
  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}
165
+ node_modules = {
166
+ path: path for path in source_files if Path(path).suffix in self._NODE_EXTENSIONS
167
+ }
62
168
  go_imports = self._build_go_import_map(root, source_files)
63
169
  jvm_types = self._build_jvm_type_map(root, source_files)
64
170
 
@@ -114,78 +220,392 @@ class GraphAnalyzer:
114
220
  limitations.append("graph_budget_reached")
115
221
  break
116
222
 
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)
223
+ nodes = nodes[: self.max_nodes]
224
+ edges = edges[: self.max_edges]
144
225
  return ModuleGraph(
145
226
  nodes=nodes,
146
227
  edges=edges,
147
228
  summary=ModuleGraphSummary(
148
- requested=bool(graphs),
229
+ requested=True,
149
230
  node_count=len(nodes),
150
231
  edge_count=len(edges),
151
232
  languages=sorted({node.language for node in nodes}),
152
233
  methods=sorted({edge.method for edge in edges}),
234
+ detail="full",
235
+ edge_kinds=sorted({edge.kind for edge in edges}),
153
236
  limitations=self._unique(limitations),
154
237
  ),
155
238
  )
156
239
 
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("/")
240
+ def _project_graph(
241
+ self,
242
+ graph: ModuleGraph,
243
+ *,
244
+ detail: GraphDetail,
245
+ edge_kinds: set[str] | None,
246
+ max_nodes: int | None,
247
+ entry_points: list[EntryPoint],
248
+ ) -> ModuleGraph:
249
+ active_edge_kinds = edge_kinds or set(self._DEFAULT_EDGE_KINDS[detail])
250
+ budget = (
251
+ max_nodes if detail != "full" and max_nodes is not None else self._DEFAULT_MAX_NODES[detail]
252
+ )
253
+ ranked_nodes = self._rank_nodes(graph, entry_points)
254
+ importance_by_id = {node.id: node.importance for node in ranked_nodes}
255
+ if detail == "full":
256
+ projected_nodes = ranked_nodes
257
+ projected_edges = [
258
+ edge for edge in graph.edges if edge.kind in active_edge_kinds
259
+ ]
260
+ truncated = False
261
+ base_limitations = list(graph.summary.limitations)
262
+ elif detail == "medium":
263
+ projected_nodes = self._select_medium_nodes(ranked_nodes, entry_points)
264
+ projected_nodes = self._expand_medium_call_targets(projected_nodes, ranked_nodes, graph.edges)
265
+ projected_edges = [
266
+ edge
267
+ for edge in graph.edges
268
+ if edge.kind in active_edge_kinds
269
+ and edge.source in {node.id for node in projected_nodes}
270
+ and edge.target in {node.id for node in projected_nodes}
271
+ ]
272
+ projected_nodes, projected_edges, truncated, base_limitations = self._apply_budget(
273
+ projected_nodes,
274
+ projected_edges,
275
+ importance_by_id,
276
+ budget,
277
+ list(graph.summary.limitations),
278
+ )
279
+ else:
280
+ collapsed_nodes, collapsed_edges = self._collapse_high_graph(
281
+ ranked_nodes,
282
+ graph.edges,
283
+ entry_points,
284
+ )
285
+ projected_nodes, projected_edges, truncated, base_limitations = self._apply_budget(
286
+ collapsed_nodes,
287
+ [edge for edge in collapsed_edges if edge.kind in active_edge_kinds],
288
+ {node.id: node.importance for node in collapsed_nodes},
289
+ budget,
290
+ list(graph.summary.limitations),
291
+ )
292
+
293
+ summary = self._build_summary(
294
+ projected_nodes,
295
+ projected_edges,
296
+ detail=detail,
297
+ edge_kinds=active_edge_kinds,
298
+ budget=budget,
299
+ entry_points=entry_points,
300
+ limitations=base_limitations,
301
+ truncated=truncated,
302
+ )
303
+ return ModuleGraph(nodes=projected_nodes, edges=projected_edges, summary=summary)
304
+
305
+ def _rank_nodes(self, graph: ModuleGraph, entry_points: list[EntryPoint]) -> list[GraphNode]:
306
+ module_entry_ids = self._entry_point_module_ids(entry_points, graph.nodes)
307
+ incoming_imports = Counter(
308
+ edge.target for edge in graph.edges if edge.kind == "imports"
309
+ )
310
+ incoming_calls = Counter(
311
+ edge.target for edge in graph.edges if edge.kind == "calls"
312
+ )
313
+ outgoing_calls = Counter(
314
+ edge.source for edge in graph.edges if edge.kind == "calls"
315
+ )
316
+
317
+ scored_nodes: list[tuple[int, GraphNode]] = []
162
318
  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
- )
319
+ score = 0
320
+ if node.id in module_entry_ids:
321
+ score += 100
322
+ if node.kind == "module":
323
+ score += 20
324
+ score += incoming_imports[node.id] * 10
325
+ elif node.kind == "function":
326
+ score += outgoing_calls[node.id] * 20
327
+ score += incoming_calls[node.id] * 8
328
+ elif node.kind == "class":
329
+ score += incoming_imports[node.id] * 6
330
+
331
+ if node.path.endswith(("main.py", "index.ts", "index.js", "__main__.py")):
332
+ score += 20
333
+
334
+ importance = self._importance_from_score(score, node.kind)
335
+ scored_nodes.append((score, replace(node, importance=importance)))
336
+
337
+ scored_nodes.sort(
338
+ key=lambda item: (
339
+ -item[0],
340
+ 0 if item[1].kind == "module" else 1,
341
+ item[1].path,
342
+ item[1].symbol or "",
173
343
  )
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),
344
+ )
345
+ return [node for _score, node in scored_nodes]
346
+
347
+ def _importance_from_score(self, score: int, kind: str) -> GraphImportance:
348
+ if score >= 40:
349
+ return "high"
350
+ if kind == "module" and score >= 20:
351
+ return "medium"
352
+ if score >= 12:
353
+ return "medium"
354
+ return "low"
355
+
356
+ def _select_medium_nodes(
357
+ self,
358
+ ranked_nodes: list[GraphNode],
359
+ entry_points: list[EntryPoint],
360
+ ) -> list[GraphNode]:
361
+ entry_module_ids = self._entry_point_module_ids(entry_points, ranked_nodes)
362
+ selected: list[GraphNode] = []
363
+ seen: set[str] = set()
364
+ for node in ranked_nodes:
365
+ include = node.kind == "module" or node.importance == "high"
366
+ if node.id in entry_module_ids:
367
+ include = True
368
+ if include and node.id not in seen:
369
+ seen.add(node.id)
370
+ selected.append(node)
371
+ return selected
372
+
373
+ def _expand_medium_call_targets(
374
+ self,
375
+ selected_nodes: list[GraphNode],
376
+ ranked_nodes: list[GraphNode],
377
+ edges: list[GraphEdge],
378
+ ) -> list[GraphNode]:
379
+ node_map = {node.id: node for node in ranked_nodes}
380
+ selected_ids = {node.id for node in selected_nodes}
381
+ expanded = list(selected_nodes)
382
+ for edge in edges:
383
+ if edge.kind != "calls":
384
+ continue
385
+ if edge.source in selected_ids and edge.target in node_map and edge.target not in selected_ids:
386
+ selected_ids.add(edge.target)
387
+ expanded.append(node_map[edge.target])
388
+ return expanded
389
+
390
+ def _collapse_high_graph(
391
+ self,
392
+ ranked_nodes: list[GraphNode],
393
+ edges: list[GraphEdge],
394
+ entry_points: list[EntryPoint],
395
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
396
+ module_nodes = [node for node in ranked_nodes if node.kind == "module"]
397
+ entry_module_ids = self._entry_point_module_ids(entry_points, module_nodes)
398
+ group_map = self._build_high_group_map(module_nodes, entry_module_ids)
399
+ grouped_nodes: dict[str, GraphNode] = {}
400
+ for node in module_nodes:
401
+ group_path = group_map[node.id]
402
+ if group_path not in grouped_nodes:
403
+ importance: GraphImportance = "high" if node.id in entry_module_ids else "medium"
404
+ grouped_nodes[group_path] = GraphNode(
405
+ id=f"module:{group_path}",
406
+ kind="module",
407
+ language=node.language,
408
+ path=group_path,
409
+ display_name=group_path,
410
+ workspace=node.workspace,
411
+ importance=importance,
181
412
  )
413
+ elif node.importance == "high":
414
+ grouped_nodes[group_path] = replace(
415
+ grouped_nodes[group_path],
416
+ importance="high",
417
+ )
418
+
419
+ grouped_edges: list[GraphEdge] = []
420
+ edge_keys: set[tuple[str, str, str, str]] = set()
421
+ for edge in edges:
422
+ if edge.kind != "imports":
423
+ continue
424
+ source_group = group_map.get(edge.source)
425
+ target_group = group_map.get(edge.target)
426
+ if source_group is None or target_group is None or source_group == target_group:
427
+ continue
428
+ grouped_edge = GraphEdge(
429
+ source=f"module:{source_group}",
430
+ target=f"module:{target_group}",
431
+ kind="imports",
432
+ confidence=edge.confidence,
433
+ method=edge.method,
182
434
  )
183
- return ModuleGraph(
184
- nodes=prefixed_nodes,
185
- edges=prefixed_edges,
186
- summary=replace(graph.summary),
435
+ self._append_edge(grouped_edges, edge_keys, grouped_edge)
436
+
437
+ return list(grouped_nodes.values()), grouped_edges
438
+
439
+ def _build_high_group_map(
440
+ self,
441
+ module_nodes: list[GraphNode],
442
+ entry_module_ids: set[str],
443
+ ) -> dict[str, str]:
444
+ modules_by_parent: dict[str, list[GraphNode]] = defaultdict(list)
445
+ for node in module_nodes:
446
+ parent = str(PurePosixPath(node.path).parent)
447
+ modules_by_parent[parent].append(node)
448
+
449
+ group_map: dict[str, str] = {}
450
+ for node in module_nodes:
451
+ parent = str(PurePosixPath(node.path).parent)
452
+ if parent not in {".", ""} and len(modules_by_parent[parent]) > 1 and node.id not in entry_module_ids:
453
+ group_map[node.id] = parent
454
+ else:
455
+ stem_path = str(PurePosixPath(node.path).with_suffix(""))
456
+ if stem_path.endswith("/__init__"):
457
+ stem_path = stem_path[: -len("/__init__")]
458
+ group_map[node.id] = stem_path or node.path
459
+ return group_map
460
+
461
+ def _apply_budget(
462
+ self,
463
+ nodes: list[GraphNode],
464
+ edges: list[GraphEdge],
465
+ importance_by_id: dict[str, GraphImportance | None],
466
+ budget: int | None,
467
+ limitations: list[str],
468
+ ) -> tuple[list[GraphNode], list[GraphEdge], bool, list[str]]:
469
+ if budget is None or len(nodes) <= budget:
470
+ return nodes, edges, False, limitations
471
+
472
+ priority = {"high": 0, "medium": 1, "low": 2, None: 3}
473
+ trimmed_nodes = sorted(
474
+ nodes,
475
+ key=lambda node: (
476
+ priority[importance_by_id.get(node.id)],
477
+ 0 if node.kind == "module" else 1,
478
+ node.path,
479
+ node.symbol or "",
480
+ ),
481
+ )[:budget]
482
+ kept_ids = {node.id for node in trimmed_nodes}
483
+ trimmed_edges = [
484
+ edge
485
+ for edge in edges
486
+ if edge.source in kept_ids and edge.target in kept_ids
487
+ ]
488
+ new_limitations = list(limitations)
489
+ new_limitations.append(f"node_budget_applied:{len(nodes)}->{budget}")
490
+ return trimmed_nodes, trimmed_edges, True, self._unique(new_limitations)
491
+
492
+ def _build_summary(
493
+ self,
494
+ nodes: list[GraphNode],
495
+ edges: list[GraphEdge],
496
+ *,
497
+ detail: GraphDetail,
498
+ edge_kinds: set[str],
499
+ budget: int | None,
500
+ entry_points: list[EntryPoint],
501
+ limitations: list[str],
502
+ truncated: bool,
503
+ ) -> ModuleGraphSummary:
504
+ return ModuleGraphSummary(
505
+ requested=True,
506
+ node_count=len(nodes),
507
+ edge_count=len(edges),
508
+ languages=sorted({node.language for node in nodes}),
509
+ methods=sorted({edge.method for edge in edges}),
510
+ main_flows=self._derive_main_flows(nodes, edges, entry_points),
511
+ layers=self._infer_layers(nodes),
512
+ entry_points_count=len(entry_points),
513
+ truncated=truncated,
514
+ detail=detail,
515
+ max_nodes_applied=budget,
516
+ edge_kinds=sorted(edge_kinds),
517
+ limitations=self._unique(limitations),
187
518
  )
188
519
 
520
+ def _derive_main_flows(
521
+ self,
522
+ nodes: list[GraphNode],
523
+ edges: list[GraphEdge],
524
+ entry_points: list[EntryPoint],
525
+ ) -> list[str]:
526
+ node_map = {node.id: node for node in nodes}
527
+ adjacency: dict[str, list[str]] = defaultdict(list)
528
+ for edge in edges:
529
+ if edge.kind not in {"imports", "calls"}:
530
+ continue
531
+ adjacency[edge.source].append(edge.target)
532
+
533
+ flows: list[str] = []
534
+ entry_module_ids = self._entry_point_module_ids(entry_points, nodes)
535
+ for source_id in sorted(entry_module_ids):
536
+ if source_id not in adjacency and source_id not in node_map:
537
+ continue
538
+ walk = [self._node_label(node_map[source_id])]
539
+ current = source_id
540
+ visited = {source_id}
541
+ for _ in range(3):
542
+ candidates = [
543
+ target for target in adjacency.get(current, [])
544
+ if target in node_map and target not in visited
545
+ ]
546
+ if not candidates:
547
+ break
548
+ next_target = sorted(
549
+ candidates,
550
+ key=lambda node_id: (
551
+ 0 if (node_map[node_id].importance == "high") else 1,
552
+ 0 if node_map[node_id].kind == "module" else 1,
553
+ node_map[node_id].path,
554
+ ),
555
+ )[0]
556
+ visited.add(next_target)
557
+ walk.append(self._node_label(node_map[next_target]))
558
+ current = next_target
559
+ if len(walk) > 1:
560
+ flows.append(" -> ".join(walk))
561
+ if len(flows) >= 3:
562
+ break
563
+ return flows
564
+
565
+ def _infer_layers(self, nodes: list[GraphNode]) -> list[str]:
566
+ layers: list[str] = []
567
+ for node in nodes:
568
+ if node.kind != "module":
569
+ continue
570
+ parts = PurePosixPath(node.path).parts
571
+ if not parts:
572
+ continue
573
+ layer = parts[0] if len(parts) > 1 else PurePosixPath(node.path).stem
574
+ if layer not in layers:
575
+ layers.append(layer)
576
+ return layers[:10]
577
+
578
+ def _entry_point_module_ids(
579
+ self,
580
+ entry_points: list[EntryPoint],
581
+ nodes: list[GraphNode],
582
+ ) -> set[str]:
583
+ module_ids = {node.id for node in nodes if node.kind == "module"}
584
+ resolved: set[str] = set()
585
+ for entry_point in entry_points:
586
+ entry_path = entry_point.path.strip("/")
587
+ candidates = [f"module:{entry_path}"]
588
+ if "/" in entry_path:
589
+ parent = entry_path.rsplit("/", 1)[0]
590
+ candidates.extend(
591
+ [
592
+ f"module:{parent}/main.py",
593
+ f"module:{parent}/src/main.py",
594
+ f"module:{parent}/index.ts",
595
+ f"module:{parent}/src/index.ts",
596
+ f"module:{parent}/index.js",
597
+ f"module:{parent}/src/index.js",
598
+ ]
599
+ )
600
+ for candidate in candidates:
601
+ if candidate in module_ids:
602
+ resolved.add(candidate)
603
+ break
604
+ return resolved
605
+
606
+ def _node_label(self, node: GraphNode) -> str:
607
+ return node.display_name or node.symbol or node.path
608
+
189
609
  def _analyze_python_file(
190
610
  self,
191
611
  relative_path: str,
@@ -288,12 +708,8 @@ class GraphAnalyzer:
288
708
  )
289
709
  for alias in ast_node.names:
290
710
  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
- )
711
+ imported_function_node_ids[local_name] = f"function:{target_path}:{alias.name}"
712
+ imported_class_node_ids[local_name] = f"class:{target_path}:{alias.name}"
297
713
 
298
714
  for top_level in tree.body:
299
715
  if isinstance(top_level, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -321,9 +737,7 @@ class GraphAnalyzer:
321
737
  continue
322
738
  for base in top_level.bases:
323
739
  if isinstance(base, ast.Name):
324
- target_id = class_node_ids.get(base.id) or imported_class_node_ids.get(
325
- base.id
326
- )
740
+ target_id = class_node_ids.get(base.id) or imported_class_node_ids.get(base.id)
327
741
  if target_id is not None:
328
742
  edges.append(
329
743
  GraphEdge(
sourcecode/schema.py CHANGED
@@ -107,6 +107,7 @@ class GraphNode:
107
107
  symbol: Optional[str] = None
108
108
  display_name: Optional[str] = None
109
109
  workspace: Optional[str] = None
110
+ importance: Optional[Literal["high", "medium", "low"]] = None
110
111
 
111
112
 
112
113
  @dataclass
@@ -129,6 +130,13 @@ class ModuleGraphSummary:
129
130
  edge_count: int = 0
130
131
  languages: list[str] = field(default_factory=list)
131
132
  methods: list[str] = field(default_factory=list)
133
+ main_flows: list[str] = field(default_factory=list)
134
+ layers: list[str] = field(default_factory=list)
135
+ entry_points_count: int = 0
136
+ truncated: bool = False
137
+ detail: Optional[Literal["high", "medium", "full"]] = None
138
+ max_nodes_applied: Optional[int] = None
139
+ edge_kinds: list[str] = field(default_factory=list)
132
140
  limitations: list[str] = field(default_factory=list)
133
141
 
134
142
 
@@ -161,3 +169,4 @@ class SourceMap:
161
169
  dependencies: list[DependencyRecord] = field(default_factory=list)
162
170
  dependency_summary: Optional[DependencySummary] = None
163
171
  module_graph: Optional[ModuleGraph] = None
172
+ module_graph_summary: Optional[ModuleGraphSummary] = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -58,6 +58,14 @@ Include an internal module graph with imports and simple structural relations:
58
58
  sourcecode . --graph-modules
59
59
  ```
60
60
 
61
+ Choose how much graph detail you want:
62
+
63
+ ```bash
64
+ sourcecode . --graph-modules --graph-detail high
65
+ sourcecode . --graph-modules --graph-detail medium
66
+ sourcecode . --graph-modules --graph-detail full
67
+ ```
68
+
61
69
  Show the version:
62
70
 
63
71
  ```bash
@@ -70,7 +78,10 @@ Main options:
70
78
  - `--output PATH`: write to a file instead of `stdout`.
71
79
  - `--compact`: return a reduced view with `schema_version`, `project_type`, `stacks`, `entry_points`, and `file_tree_depth1`.
72
80
  - `--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`.
81
+ - `--graph-modules`: include a structural graph optimized for repository reasoning.
82
+ - `--graph-detail high|medium|full`: choose a summarized, balanced, or full-fidelity graph. Default: `high`.
83
+ - `--max-nodes INTEGER`: cap graph size in `high` and `medium` modes.
84
+ - `--graph-edges imports,calls,contains,extends`: override the default edge kinds for the selected detail level.
74
85
  - `--depth INTEGER`: maximum file tree depth.
75
86
  - `--no-redact`: disable secret redaction.
76
87
 
@@ -169,6 +180,7 @@ The full schema includes:
169
180
  - `dependencies`: optional dependency records with declared and resolved versions.
170
181
  - `dependency_summary`: optional summary with ecosystem coverage, counts, and known limitations.
171
182
  - `module_graph`: optional structural graph with nodes, edges, and analysis limits.
183
+ - `module_graph_summary`: compact graph summary optimized for downstream LLM consumption.
172
184
 
173
185
  Example dependency block:
174
186
 
@@ -219,52 +231,52 @@ Example module graph block:
219
231
  "module_graph": {
220
232
  "nodes": [
221
233
  {
222
- "id": "module:app/main.py",
234
+ "id": "module:app",
223
235
  "kind": "module",
224
236
  "language": "python",
225
- "path": "app/main.py",
237
+ "path": "app",
226
238
  "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"
239
+ "display_name": "app",
240
+ "workspace": null,
241
+ "importance": "high"
254
242
  }
255
243
  ],
244
+ "edges": [],
256
245
  "summary": {
257
246
  "requested": true,
258
- "node_count": 2,
259
- "edge_count": 2,
247
+ "node_count": 1,
248
+ "edge_count": 0,
260
249
  "languages": ["python"],
261
250
  "methods": ["ast"],
251
+ "main_flows": [],
252
+ "layers": ["app"],
253
+ "entry_points_count": 1,
254
+ "truncated": false,
255
+ "detail": "high",
256
+ "max_nodes_applied": 80,
257
+ "edge_kinds": ["imports"],
262
258
  "limitations": []
263
259
  }
260
+ },
261
+ "module_graph_summary": {
262
+ "requested": true,
263
+ "node_count": 1,
264
+ "edge_count": 0,
265
+ "main_flows": [],
266
+ "layers": ["app"],
267
+ "entry_points_count": 1,
268
+ "truncated": false,
269
+ "limitations": []
264
270
  }
265
271
  }
266
272
  ```
267
273
 
274
+ `--graph-modules` is now tiered for LLM workflows:
275
+
276
+ - `high`: summarized graph, modules only, imports only, directory collapsing when useful.
277
+ - `medium`: balanced graph with key functions and selected call edges.
278
+ - `full`: full-fidelity graph, equivalent to the previous exhaustive behavior.
279
+
268
280
  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
281
 
270
282
  Detailed reference: [docs/schema.md](/Users/user/Documents/workspace/atlas/atlas-cli/docs/schema.md).
@@ -1,11 +1,11 @@
1
- sourcecode/__init__.py,sha256=iBHGXEsmbN21QO7wvQ3ZZhBy96xjvWrfacPsjTSGcds,99
1
+ sourcecode/__init__.py,sha256=-dwdCqjcUfCpWcJs6T79s4xcr84uaCkx1dIhLHsWVGE,99
2
2
  sourcecode/classifier.py,sha256=7ubGTn5vfw9z-9eosLIYDtQUu7d8dvB4XKKDxysUVSM,6910
3
- sourcecode/cli.py,sha256=NysuO95KIxpBEpRiwor93_WFvdMvjk0ArDRv8KPX440,10136
3
+ sourcecode/cli.py,sha256=zpnh4NB46fWkBuP3el5vtgBH1CSmxzRmjRtR0NymR_U,12193
4
4
  sourcecode/dependency_analyzer.py,sha256=c8tSbUpug-2avKNWCP4-_e2_uhBwWl96LwL1Be8O90M,39067
5
- sourcecode/graph_analyzer.py,sha256=hskmEr-c5z3HjWr2S_2ccaVSMrpikYdMY-ExbAiaK60,29353
5
+ sourcecode/graph_analyzer.py,sha256=S5z8mrGfLwN26Mt2IaCR4dOpQ_APU16cfihSp640eGQ,45241
6
6
  sourcecode/redactor.py,sha256=abSf5jH_IzYzpjF3lEN6hCwdXxvmyHs-3DHz9fd8Mic,2883
7
7
  sourcecode/scanner.py,sha256=TvcuD77m_NF8OlTj3XH1b_jO0qrto8c90fND96ecqb0,6182
8
- sourcecode/schema.py,sha256=h5uemXKCrPVpEslO2iM_Kw3ax-4UmpOpkfK50HgTNo4,4517
8
+ sourcecode/schema.py,sha256=YaO1kWgWwH1iA8dUEQPJOLz2G-YRMrUEVXfkJo01xWE,4976
9
9
  sourcecode/serializer.py,sha256=hezTYOAAvZ2yPn7Te7Eu42ajbMhVCsUkuUZtqx4wjAs,3085
10
10
  sourcecode/tree_utils.py,sha256=5WHQc2L-AGqxKFfYYcQftE6hETxgUxP9MWDuaThcAUw,991
11
11
  sourcecode/workspace.py,sha256=1L4xH38gU89fLeMYSuF3ft7lSdCyTfMfJ9NQt5q4Ric,6904
@@ -28,7 +28,7 @@ sourcecode/detectors/rust.py,sha256=iUz9lE4HfBy8CBaDwGhfh6kLsNIovrH2ay2v3y6qNDc,
28
28
  sourcecode/detectors/systems.py,sha256=qf5M9tio1H6XDorSstTWIxKe0xVLR_QGTJpFGyhDz4A,1690
29
29
  sourcecode/detectors/terraform.py,sha256=5EOKQtTViWo7u3tFK0h45YD8GQPwhrww0cOicxGPPn4,1732
30
30
  sourcecode/detectors/tooling.py,sha256=wPvudLCJZ8_cr9GIx44C8jkLAlEN9uTUZcJsP7eVYnQ,1937
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,,
31
+ sourcecode-0.6.0.dist-info/METADATA,sha256=K_cnNQLdTNFBu8rXanEI2bIA2LrL0PUUGGJZr6RzugY,7976
32
+ sourcecode-0.6.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
33
+ sourcecode-0.6.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
34
+ sourcecode-0.6.0.dist-info/RECORD,,