sourcecode 0.9.0__py3-none-any.whl → 0.10.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.9.0"
3
+ __version__ = "0.10.0"
@@ -0,0 +1,248 @@
1
+ """Resumen arquitectonico estatico y compacto para consumo LLM."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from sourcecode.schema import EntryPoint, SourceMap, StackDetection
10
+ from sourcecode.tree_utils import flatten_file_tree
11
+
12
+ _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
13
+ _PYTHON_EXTENSIONS = {".py"}
14
+ _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
15
+ _GO_EXTENSIONS = {".go"}
16
+
17
+
18
+ class ArchitectureSummarizer:
19
+ """Construye un resumen arquitectonico estatico de 3-5 lineas."""
20
+
21
+ def __init__(self, root: Path) -> None:
22
+ self.root = root
23
+
24
+ def generate(self, sm: SourceMap) -> str | None:
25
+ try:
26
+ return self._build_summary(sm)
27
+ except Exception:
28
+ return None
29
+
30
+ def _build_summary(self, sm: SourceMap) -> str | None:
31
+ file_paths = [
32
+ path for path in flatten_file_tree(sm.file_tree)
33
+ if not self._is_tooling_path(path)
34
+ ]
35
+ if not file_paths:
36
+ return None
37
+
38
+ entry_points = [
39
+ entry for entry in sm.entry_points
40
+ if not self._is_tooling_path(entry.path)
41
+ ]
42
+ if not entry_points:
43
+ fallback = self._infer_fallback_entry_points(file_paths, sm.stacks)
44
+ entry_points = fallback[:1]
45
+
46
+ if not entry_points:
47
+ return "Arquitectura no inferida con suficiente evidencia estatica."
48
+
49
+ entry_point = entry_points[0]
50
+ content = self._read_file(entry_point.path)
51
+ if content is None:
52
+ return f"Entry point principal: {entry_point.path}. Arquitectura no inferida con suficiente evidencia estatica."
53
+
54
+ lines = [self._describe_entry_point(entry_point, sm.project_type)]
55
+ suffix = Path(entry_point.path).suffix
56
+ if suffix in _PYTHON_EXTENSIONS:
57
+ lines.extend(self._summarize_python_entry(entry_point.path, content))
58
+ elif suffix in _NODE_EXTENSIONS:
59
+ lines.extend(self._summarize_node_entry(entry_point.path, content))
60
+ elif suffix in _GO_EXTENSIONS:
61
+ lines.extend(self._summarize_go_entry(entry_point.path, content))
62
+ else:
63
+ lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
64
+
65
+ unique_lines: list[str] = []
66
+ seen: set[str] = set()
67
+ for line in lines:
68
+ line = line.strip()
69
+ if not line or line in seen:
70
+ continue
71
+ seen.add(line)
72
+ unique_lines.append(line)
73
+ return "\n".join(unique_lines[:5]) if unique_lines else None
74
+
75
+ def _summarize_python_entry(self, path: str, content: str) -> list[str]:
76
+ try:
77
+ tree = ast.parse(content)
78
+ except SyntaxError:
79
+ return ["Orquesta modulos Python, pero el entry point no pudo parsearse por completo."]
80
+
81
+ package_prefix = self._python_package_prefix(path)
82
+ imported_modules: list[str] = []
83
+ optional_analyzers: list[str] = []
84
+ uses_serializer = False
85
+ uses_redactor = False
86
+
87
+ for node in ast.walk(tree):
88
+ if isinstance(node, ast.ImportFrom) and isinstance(node.module, str):
89
+ if package_prefix and node.module.startswith(package_prefix):
90
+ imported_modules.append(node.module.rsplit(".", 1)[-1])
91
+ if node.module == "sourcecode.serializer":
92
+ uses_serializer = True
93
+ if node.module == "sourcecode.redactor":
94
+ uses_redactor = True
95
+ elif isinstance(node, ast.Assign):
96
+ optional = self._extract_optional_analyzer(node)
97
+ if optional:
98
+ optional_analyzers.append(optional)
99
+
100
+ lines: list[str] = []
101
+ core_modules = self._format_module_list(imported_modules)
102
+ if core_modules:
103
+ lines.append(f"Orquesta modulos internos: {core_modules}.")
104
+ if optional_analyzers:
105
+ lines.append(f"Activa analisis opcionales: {', '.join(optional_analyzers)}.")
106
+ if uses_serializer:
107
+ output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta."
108
+ if uses_redactor:
109
+ output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta con redaccion de secretos."
110
+ lines.append(output_line)
111
+ elif not lines:
112
+ lines.append("Orquesta modulos Python internos sin un patron de salida claramente inferible.")
113
+ return lines
114
+
115
+ def _summarize_node_entry(self, path: str, content: str) -> list[str]:
116
+ imports = re.findall(r"""from\s+['"](\.?\.?/[^'"]+)['"]|require\(['"](\.?\.?/[^'"]+)['"]\)""", content)
117
+ modules = [item for pair in imports for item in pair if item]
118
+ lines: list[str] = []
119
+ if modules:
120
+ formatted = self._format_module_list([self._module_label(module) for module in modules])
121
+ if formatted:
122
+ lines.append(f"Orquesta modulos internos: {formatted}.")
123
+ lines.append("Produce la salida principal del entry point JavaScript/TypeScript detectado.")
124
+ return lines
125
+
126
+ def _summarize_go_entry(self, path: str, content: str) -> list[str]:
127
+ imports = re.findall(r'"([^"]+)"', content)
128
+ internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
129
+ lines: list[str] = []
130
+ if internal:
131
+ formatted = self._format_module_list([self._module_label(module) for module in internal])
132
+ if formatted:
133
+ lines.append(f"Orquesta paquetes internos: {formatted}.")
134
+ lines.append("Produce la salida principal del binario Go detectado.")
135
+ return lines
136
+
137
+ def _describe_entry_point(self, entry_point: EntryPoint, project_type: str | None) -> str:
138
+ if entry_point.kind == "cli" or entry_point.path.endswith("cli.py"):
139
+ return f"Entry point principal: {entry_point.path} expone la CLI del proyecto."
140
+ if entry_point.kind == "web":
141
+ return f"Entry point principal: {entry_point.path} arranca la interfaz web."
142
+ if project_type == "api" or entry_point.kind == "server":
143
+ return f"Entry point principal: {entry_point.path} arranca el servicio principal."
144
+ if entry_point.kind == "binary":
145
+ return f"Entry point principal: {entry_point.path} arranca el binario principal."
146
+ return f"Entry point principal: {entry_point.path} coordina el flujo principal del proyecto."
147
+
148
+ def _extract_optional_analyzer(self, node: ast.Assign) -> str | None:
149
+ value = node.value
150
+ if not isinstance(value, ast.IfExp):
151
+ return None
152
+ if not isinstance(value.test, ast.Name):
153
+ return None
154
+ if not isinstance(value.body, ast.Call):
155
+ return None
156
+ if not isinstance(value.body.func, ast.Name):
157
+ return None
158
+ analyzer_name = value.body.func.id
159
+ if not analyzer_name.endswith("Analyzer"):
160
+ return None
161
+ return f"{analyzer_name} (--{value.test.id.replace('_', '-')})"
162
+
163
+ def _infer_fallback_entry_points(
164
+ self, file_paths: list[str], stacks: list[StackDetection]
165
+ ) -> list[EntryPoint]:
166
+ candidates: list[EntryPoint] = []
167
+ stack_name = stacks[0].stack if stacks else "unknown"
168
+ ordered_paths = sorted(file_paths, key=self._fallback_priority)
169
+ for path in ordered_paths:
170
+ if path.endswith(("cli.py", "__main__.py", "main.py")):
171
+ candidates.append(
172
+ EntryPoint(
173
+ path=path,
174
+ stack=stack_name,
175
+ kind="cli",
176
+ source="convention",
177
+ confidence="medium",
178
+ )
179
+ )
180
+ elif path.endswith(("app/page.tsx", "pages/index.js", "server.js")):
181
+ candidates.append(
182
+ EntryPoint(
183
+ path=path,
184
+ stack=stack_name,
185
+ kind="web" if "page" in path or "pages/" in path else "server",
186
+ source="convention",
187
+ confidence="medium",
188
+ )
189
+ )
190
+ elif path.endswith("main.go"):
191
+ candidates.append(
192
+ EntryPoint(
193
+ path=path,
194
+ stack=stack_name,
195
+ kind="binary",
196
+ source="convention",
197
+ confidence="medium",
198
+ )
199
+ )
200
+ return candidates
201
+
202
+ def _fallback_priority(self, path: str) -> tuple[int, int, str]:
203
+ return (
204
+ 0 if "/cli.py" in path or path.endswith("cli.py") else 1,
205
+ 0 if path.startswith("src/") else 1,
206
+ path,
207
+ )
208
+
209
+ def _format_module_list(self, modules: list[str]) -> str:
210
+ normalized = [self._module_label(module) for module in modules]
211
+ filtered = [module for module in normalized if module and not self._is_tooling_path(module)]
212
+ if not filtered:
213
+ return ""
214
+ ordered: list[str] = []
215
+ seen: set[str] = set()
216
+ for module in filtered:
217
+ if module not in seen:
218
+ seen.add(module)
219
+ ordered.append(module)
220
+ return ", ".join(ordered[:8])
221
+
222
+ def _module_label(self, module: str) -> str:
223
+ cleaned = module.strip().strip("./")
224
+ if "/" in cleaned:
225
+ return cleaned.split("/")[-1]
226
+ if "." in cleaned:
227
+ return cleaned.rsplit(".", 1)[-1]
228
+ return cleaned
229
+
230
+ def _python_package_prefix(self, path: str) -> str:
231
+ parts = Path(path).parts
232
+ if len(parts) >= 3 and parts[0] == "src":
233
+ return f"{parts[1]}."
234
+ if len(parts) >= 2:
235
+ return f"{parts[0]}."
236
+ return ""
237
+
238
+ def _read_file(self, relative_path: str) -> str | None:
239
+ try:
240
+ return (self.root / relative_path).read_text(encoding="utf-8", errors="replace")
241
+ except OSError:
242
+ return None
243
+
244
+ def _is_tooling_path(self, path: str | None) -> bool:
245
+ if not path:
246
+ return False
247
+ normalized = path.strip().lstrip("/")
248
+ return normalized.startswith(_TOOLING_PREFIXES)
sourcecode/cli.py CHANGED
@@ -473,9 +473,10 @@ def main(
473
473
  semantic_summary=sem_sum,
474
474
  )
475
475
 
476
- # Phase 9: LLM Output Quality — poblar campos derivados
477
- from sourcecode.summarizer import ProjectSummarizer
478
- from sourcecode.tree_utils import flatten_file_tree
476
+ # Phase 9: LLM Output Quality — poblar campos derivados
477
+ from sourcecode.architecture_summary import ArchitectureSummarizer
478
+ from sourcecode.summarizer import ProjectSummarizer
479
+ from sourcecode.tree_utils import flatten_file_tree
479
480
 
480
481
  # LQN-01: lista plana de paths del file_tree con separador forward-slash
481
482
  sm.file_paths = [
@@ -495,8 +496,9 @@ def main(
495
496
 
496
497
  sm.key_dependencies = sorted(direct_deps, key=_dep_sort_key)[:15]
497
498
 
498
- # LQN-02: resumen NL deterministico
499
- sm.project_summary = ProjectSummarizer().generate(sm)
499
+ # LQN-02: resumen NL deterministico
500
+ sm.project_summary = ProjectSummarizer(target).generate(sm)
501
+ sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
500
502
 
501
503
  # 4. Serializar (con o sin modo compact)
502
504
  if compact:
@@ -39,7 +39,13 @@ class GoDetector(AbstractDetector):
39
39
  ]
40
40
  preferred = [path for path in entry_candidates if path.startswith("cmd/")] or entry_candidates
41
41
  entry_points = [
42
- EntryPoint(path=path, stack="go", kind="binary", source="go.mod")
42
+ EntryPoint(
43
+ path=path,
44
+ stack="go",
45
+ kind="binary",
46
+ source="convention",
47
+ confidence="medium",
48
+ )
43
49
  for path in unique_strings(preferred)
44
50
  ]
45
51
  stack = StackDetection(
@@ -77,20 +77,33 @@ class NodejsDetector(AbstractDetector):
77
77
  def _collect_entry_points(
78
78
  self, context: DetectionContext, package_json: dict[str, Any]
79
79
  ) -> list[EntryPoint]:
80
- candidate_paths: list[str] = []
80
+ entry_points: list[EntryPoint] = []
81
+ seen: set[str] = set()
81
82
  main = package_json.get("main")
82
83
  if isinstance(main, str) and main.strip():
83
- candidate_paths.append(main.strip())
84
+ path = main.strip()
85
+ if path_exists_in_tree(context.file_tree, path):
86
+ seen.add(path)
87
+ entry_points.append(
88
+ EntryPoint(
89
+ path=path,
90
+ stack="nodejs",
91
+ kind="server",
92
+ source="package.json",
93
+ confidence="high",
94
+ )
95
+ )
84
96
 
97
+ convention_candidates: list[str] = []
85
98
  bin_field = package_json.get("bin")
86
99
  if isinstance(bin_field, str) and bin_field.strip():
87
- candidate_paths.append(bin_field.strip())
100
+ convention_candidates.append(bin_field.strip())
88
101
  elif isinstance(bin_field, dict):
89
- candidate_paths.extend(
102
+ convention_candidates.extend(
90
103
  str(value).strip() for value in bin_field.values() if isinstance(value, str) and value.strip()
91
104
  )
92
105
 
93
- candidate_paths.extend(
106
+ convention_candidates.extend(
94
107
  [
95
108
  "server.js",
96
109
  "src/index.js",
@@ -103,11 +116,17 @@ class NodejsDetector(AbstractDetector):
103
116
  ]
104
117
  )
105
118
 
106
- entry_points: list[EntryPoint] = []
107
- for path in unique_strings(candidate_paths):
108
- if path_exists_in_tree(context.file_tree, path):
109
- kind = "web" if path.startswith(("app/", "pages/")) else "server"
110
- entry_points.append(
111
- EntryPoint(path=path, stack="nodejs", kind=kind, source="package.json")
119
+ for path in unique_strings(convention_candidates):
120
+ if path in seen or not path_exists_in_tree(context.file_tree, path):
121
+ continue
122
+ kind = "web" if path.startswith(("app/", "pages/")) else "server"
123
+ entry_points.append(
124
+ EntryPoint(
125
+ path=path,
126
+ stack="nodejs",
127
+ kind=kind,
128
+ source="convention",
129
+ confidence="medium",
112
130
  )
131
+ )
113
132
  return entry_points
@@ -132,7 +132,7 @@ class PythonDetector(AbstractDetector):
132
132
  return {match.lower() for match in matches}
133
133
 
134
134
  def _collect_entry_points(self, context: DetectionContext) -> list[EntryPoint]:
135
- candidates: list[str] = []
135
+ declared_candidates: list[str] = []
136
136
  pyproject = load_toml_file(context.root / "pyproject.toml")
137
137
  if pyproject:
138
138
  project = pyproject.get("project", {})
@@ -142,16 +142,38 @@ class PythonDetector(AbstractDetector):
142
142
  for value in scripts.values():
143
143
  if isinstance(value, str) and ":" in value:
144
144
  module, _callable = value.split(":", 1)
145
- candidates.append(module.replace(".", "/") + ".py")
145
+ declared_candidates.append(module.replace(".", "/") + ".py")
146
146
 
147
- candidates.extend(["__main__.py", "main.py", "app.py", "manage.py", "src/main.py"])
148
147
  entry_points: list[EntryPoint] = []
149
- for path in unique_strings(candidates):
148
+ declared = set()
149
+ for path in unique_strings(declared_candidates):
150
150
  if path_exists_in_tree(context.file_tree, path):
151
- kind = "cli" if path.endswith(("__main__.py", "main.py")) else "app"
151
+ declared.add(path)
152
+ kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
152
153
  entry_points.append(
153
- EntryPoint(path=path, stack="python", kind=kind, source="manifest")
154
+ EntryPoint(
155
+ path=path,
156
+ stack="python",
157
+ kind=kind,
158
+ source="pyproject.toml",
159
+ confidence="high",
160
+ )
154
161
  )
162
+
163
+ convention_candidates = ["cli.py", "__main__.py", "main.py", "app.py", "manage.py", "src/main.py"]
164
+ for path in unique_strings(convention_candidates):
165
+ if path in declared or not path_exists_in_tree(context.file_tree, path):
166
+ continue
167
+ kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
168
+ entry_points.append(
169
+ EntryPoint(
170
+ path=path,
171
+ stack="python",
172
+ kind=kind,
173
+ source="convention",
174
+ confidence="medium",
175
+ )
176
+ )
155
177
  return entry_points
156
178
 
157
179
  def _detect_package_manager(
sourcecode/schema.py CHANGED
@@ -66,6 +66,7 @@ class EntryPoint:
66
66
  stack: str
67
67
  kind: str = "entry"
68
68
  source: str = "manifest"
69
+ confidence: Literal["high", "medium", "low"] = "high"
69
70
 
70
71
 
71
72
  @dataclass
@@ -322,6 +323,7 @@ class SourceMap:
322
323
  # Phase 9: LLM Output Quality
323
324
  file_paths: list[str] = field(default_factory=list)
324
325
  project_summary: Optional[str] = None
326
+ architecture_summary: Optional[str] = None
325
327
  key_dependencies: list[DependencyRecord] = field(default_factory=list)
326
328
  # Phase 10: Code Quality Metrics
327
329
  file_metrics: list[FileMetrics] = field(default_factory=list)
sourcecode/serializer.py CHANGED
@@ -52,7 +52,7 @@ def compact_view(sm: SourceMap) -> dict[str, Any]:
52
52
  """Proyeccion compacta del SourceMap (~500-700 tokens).
53
53
 
54
54
  Incluye: schema_version, project_type, stacks, entry_points,
55
- project_summary (siempre), file_paths (siempre),
55
+ project_summary (siempre), architecture_summary (siempre),
56
56
  dependency_summary (cuando requested=True),
57
57
  file_tree_depth1 (backward compat).
58
58
 
@@ -73,9 +73,9 @@ def compact_view(sm: SourceMap) -> dict[str, Any]:
73
73
  "schema_version": sm.metadata.schema_version,
74
74
  "project_type": sm.project_type,
75
75
  "project_summary": sm.project_summary,
76
+ "architecture_summary": sm.architecture_summary,
76
77
  "stacks": [asdict(stack) for stack in sm.stacks],
77
78
  "entry_points": [asdict(entry_point) for entry_point in sm.entry_points],
78
- "file_paths": sm.file_paths,
79
79
  "file_tree_depth1": depth1,
80
80
  "dependency_summary": dep_summary_dict,
81
81
  }
sourcecode/summarizer.py CHANGED
@@ -4,12 +4,20 @@ Sin llamadas a API — solo templates aplicados sobre SourceMap.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
+ from pathlib import Path
8
+
9
+ from sourcecode.detectors.parsers import load_json_file, load_toml_file
7
10
  from sourcecode.schema import SourceMap
8
11
 
12
+ _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
13
+
9
14
 
10
15
  class ProjectSummarizer:
11
16
  """Genera project_summary: string NL deterministica desde SourceMap."""
12
17
 
18
+ def __init__(self, root: Path | None = None) -> None:
19
+ self.root = root
20
+
13
21
  def generate(self, sm: SourceMap) -> str:
14
22
  """Retorna descripcion NL del proyecto. Nunca lanza excepcion."""
15
23
  try:
@@ -18,9 +26,14 @@ class ProjectSummarizer:
18
26
  return "Proyecto analizado."
19
27
 
20
28
  def _build_summary(self, sm: SourceMap) -> str:
29
+ description = self._read_project_description()
30
+ if description:
31
+ return self._merge_description_with_structure(description, sm)
32
+
21
33
  # Determinar stack primario
22
- primary_stacks = [s for s in sm.stacks if s.primary]
23
- all_stacks = primary_stacks if primary_stacks else sm.stacks
34
+ non_tooling_stacks = self._filter_non_tooling_stacks(sm)
35
+ primary_stacks = [s for s in non_tooling_stacks if s.primary]
36
+ all_stacks = primary_stacks if primary_stacks else non_tooling_stacks
24
37
 
25
38
  if not all_stacks:
26
39
  return "Proyecto sin stack detectado."
@@ -44,7 +57,7 @@ class ProjectSummarizer:
44
57
  fw_part = f" ({', '.join(frameworks[:3])})" if frameworks else ""
45
58
 
46
59
  # Entry points (max 3)
47
- ep_paths = [ep.path for ep in sm.entry_points[:3]]
60
+ ep_paths = [ep.path for ep in sm.entry_points if not self._is_tooling_path(ep.path)][:3]
48
61
  ep_part = f" Entry points: {', '.join(ep_paths)}." if ep_paths else ""
49
62
 
50
63
  # Dependencias
@@ -60,9 +73,105 @@ class ProjectSummarizer:
60
73
 
61
74
  # Monorepo: variante especial
62
75
  if project_type == "monorepo":
63
- stacks_desc = ", ".join(sorted({s.stack.capitalize() for s in sm.stacks}))
64
- n_ws = len({s.workspace for s in sm.stacks if s.workspace})
76
+ stacks_desc = ", ".join(sorted({s.stack.capitalize() for s in non_tooling_stacks}))
77
+ n_ws = len({s.workspace for s in non_tooling_stacks if s.workspace})
65
78
  ws_part = f" con {n_ws} workspaces" if n_ws > 0 else ""
66
79
  return f"Monorepo{ws_part} en {stacks_desc}.{ep_part}{dep_part}"
67
80
 
68
81
  return f"{type_label} en {stack_name}{fw_part}.{ep_part}{dep_part}"
82
+
83
+ def _read_project_description(self) -> str | None:
84
+ if self.root is None:
85
+ return None
86
+ pyproject = load_toml_file(self.root / "pyproject.toml")
87
+ if pyproject:
88
+ project = pyproject.get("project", {})
89
+ if isinstance(project, dict):
90
+ description = project.get("description")
91
+ if isinstance(description, str) and description.strip():
92
+ return description.strip()
93
+
94
+ package_json = load_json_file(self.root / "package.json")
95
+ if package_json:
96
+ description = package_json.get("description")
97
+ if isinstance(description, str) and description.strip():
98
+ return description.strip()
99
+
100
+ return self._read_readme_paragraph()
101
+
102
+ def _read_readme_paragraph(self) -> str | None:
103
+ if self.root is None:
104
+ return None
105
+ for name in ("README.md", "README.rst", "README.txt"):
106
+ path = self.root / name
107
+ try:
108
+ content = path.read_text(encoding="utf-8", errors="replace")
109
+ except OSError:
110
+ continue
111
+ paragraph = self._extract_first_useful_paragraph(content)
112
+ if paragraph:
113
+ return paragraph
114
+ return None
115
+
116
+ def _extract_first_useful_paragraph(self, content: str) -> str | None:
117
+ lines: list[str] = []
118
+ in_code_block = False
119
+ for raw_line in content.splitlines():
120
+ line = raw_line.strip()
121
+ if line.startswith("```"):
122
+ in_code_block = not in_code_block
123
+ continue
124
+ if in_code_block or not line or line.startswith(("#", "<!--")):
125
+ if lines:
126
+ break
127
+ continue
128
+ lines.append(line)
129
+ if not lines:
130
+ return None
131
+ return " ".join(lines).strip()
132
+
133
+ def _merge_description_with_structure(self, description: str, sm: SourceMap) -> str:
134
+ parts = [description.rstrip(".")]
135
+ entry_points = [ep.path for ep in sm.entry_points if not self._is_tooling_path(ep.path)][:2]
136
+ if entry_points:
137
+ parts.append(f"Entry points: {', '.join(entry_points)}")
138
+
139
+ non_tooling_stacks = self._filter_non_tooling_stacks(sm)
140
+ if non_tooling_stacks:
141
+ primary = self._select_summary_primary_stack(non_tooling_stacks)
142
+ frameworks = [framework.name for framework in primary.frameworks[:2]]
143
+ stack_label = primary.stack.capitalize()
144
+ if frameworks:
145
+ parts.append(f"Stack principal: {stack_label} ({', '.join(frameworks)})")
146
+ else:
147
+ parts.append(f"Stack principal: {stack_label}")
148
+
149
+ return ". ".join(parts) + "."
150
+
151
+ def _filter_non_tooling_stacks(self, sm: SourceMap) -> list:
152
+ filtered = [
153
+ stack for stack in sm.stacks
154
+ if not self._is_tooling_path(stack.root) and not self._is_tooling_path(stack.workspace)
155
+ ]
156
+ return filtered or sm.stacks
157
+
158
+ def _select_summary_primary_stack(self, stacks: list) -> Any:
159
+ def score(stack: Any) -> tuple[int, int, int, int]:
160
+ root_manifest_hits = 0
161
+ if self.root is not None:
162
+ root_manifest_hits = sum(
163
+ 1 for manifest in stack.manifests
164
+ if (self.root / manifest).is_file()
165
+ )
166
+ framework_hits = len(stack.frameworks)
167
+ primary_hint = 1 if stack.primary else 0
168
+ confidence = {"low": 0, "medium": 1, "high": 2}.get(stack.confidence, 0)
169
+ return (root_manifest_hits, framework_hits, primary_hint, confidence)
170
+
171
+ return max(stacks, key=score)
172
+
173
+ def _is_tooling_path(self, path: str | None) -> bool:
174
+ if not path:
175
+ return False
176
+ normalized = path.strip().lstrip("/")
177
+ return normalized.startswith(_TOOLING_PREFIXES)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -94,7 +94,7 @@ sourcecode --version
94
94
  | `PATH` | `.` | Directory to analyze. |
95
95
  | `--format json\|yaml` | `json` | Output format. |
96
96
  | `--output PATH` | stdout | Write to a file instead of stdout. |
97
- | `--compact` | off | Reduced output (~500-700 tokens): `schema_version`, `project_type`, `project_summary`, `stacks`, `entry_points`, `file_paths`, `file_tree_depth1`, and `dependency_summary` when available. |
97
+ | `--compact` | off | Reduced output (~500-700 tokens): `schema_version`, `project_type`, `project_summary`, `architecture_summary`, `stacks`, `entry_points`, `file_tree_depth1`, and `dependency_summary` when available. |
98
98
  | `--dependencies` | off | Include direct dependencies, resolved versions, and transitive relationships when lockfiles make that possible. Also populates `key_dependencies`. |
99
99
  | `--graph-modules` | off | Include a structural module graph with imports and simple relations. |
100
100
  | `--graph-detail high\|medium\|full` | `high` | Graph detail level: summarized (high), balanced (medium), or full-fidelity (full). |
@@ -118,6 +118,7 @@ The full schema (`SourceMap`) includes the following fields:
118
118
  | `file_tree` | object | Repository tree where `null` represents a file and `{}` represents a directory. |
119
119
  | `file_paths` | array | Flat list of all project paths derived from `file_tree`, with forward-slash separators. Always present; respects `--depth`. |
120
120
  | `project_summary` | string\|null | Deterministic natural-language description of the project generated from detected stacks, entry points, and dependencies. Present when stacks are detected. |
121
+ | `architecture_summary` | string\|null | Static summary of the main execution flow, orchestrated modules, and output produced by the project. Present when enough structural evidence is available. |
121
122
  | `stacks` | array | Stack detections with confidence, frameworks, manifests, `primary`, `root`, `workspace`, and `signals`. |
122
123
  | `project_type` | string\|null | Overall project classification. |
123
124
  | `entry_points` | array | Detected entry points by stack. |
@@ -155,6 +156,7 @@ Real output from a Python FastAPI project:
155
156
  "schema_version": "1.0",
156
157
  "project_type": "api",
157
158
  "project_summary": "API en Python (FastAPI). Entry points: src/main.py. 12 dependencias (python).",
159
+ "architecture_summary": null,
158
160
  "stacks": [
159
161
  {
160
162
  "stack": "python",
@@ -179,7 +181,6 @@ Real output from a Python FastAPI project:
179
181
  "source": "manifest"
180
182
  }
181
183
  ],
182
- "file_paths": ["pyproject.toml", "src/main.py", "src/routes.py", "tests/test_main.py"],
183
184
  "file_tree_depth1": {
184
185
  "pyproject.toml": null,
185
186
  "src": {},
@@ -371,7 +372,7 @@ Different modes optimize for different tradeoffs between context size and depth
371
372
 
372
373
  Best for: initial orientation, deciding what to explore next, fast handoffs between agents.
373
374
 
374
- Includes: `project_summary` (instant project description), `stacks`, `entry_points`, `file_paths` (flat path list for easy grep/reasoning), `file_tree_depth1`, and `dependency_summary` when `--dependencies` was also requested.
375
+ Includes: `project_summary` (instant project description), `architecture_summary` (execution-oriented static summary), `stacks`, `entry_points`, `file_tree_depth1`, and `dependency_summary` when `--dependencies` was also requested.
375
376
 
376
377
  ```bash
377
378
  sourcecode --compact .
@@ -404,9 +405,9 @@ sourcecode --docs --docs-depth full . # include methods
404
405
  - `"Aplicacion web en Node.js (Next.js, React). Entry points: app/page.tsx."`
405
406
  - `"Monorepo con 2 workspaces en Node.js, Python."`
406
407
 
407
- **`file_paths` field**
408
+ **`architecture_summary` field**
408
409
 
409
- `file_paths` is a flat list of all project paths (forward-slash separated). It is easier for LLMs to reason about than the nested `file_tree` dict grep it, count by extension, or identify modules by path pattern without recursive traversal.
410
+ `architecture_summary` is a static 3-5 line summary oriented to execution flow. It answers what the main entry point does, which modules it orchestrates, and what the project produces. In compact mode it replaces the low-signal value that `file_paths` used to occupy.
410
411
 
411
412
  **`key_dependencies` field**
412
413
 
@@ -1,6 +1,7 @@
1
- sourcecode/__init__.py,sha256=Zm9JrFOztIKNR75snzMqgJrHWngMW7fSSQKrDzr4KJ4,99
1
+ sourcecode/__init__.py,sha256=fi6ngxtFGN3xBcGhKYcwR2ZILxi4G0VeAOwlM8oXok0,100
2
+ sourcecode/architecture_summary.py,sha256=6FCTEHmbESxx4lmc4A61x5DPvI57oJAUnaJIZ-GjRkc,10371
2
3
  sourcecode/classifier.py,sha256=7ubGTn5vfw9z-9eosLIYDtQUu7d8dvB4XKKDxysUVSM,6910
3
- sourcecode/cli.py,sha256=kUB3u7iXnjPpmVAqMjlBrF9sOFPHfnP8XhdsH2nZIlw,19918
4
+ sourcecode/cli.py,sha256=2NWnQ7SoheABop6XduOCxa6eRCyRf0aZrE1viQg9soc,20064
4
5
  sourcecode/coverage_parser.py,sha256=0IfE-0BNF1NxTjRnAuHnn1U0VzcB_aGBQMITR6Qsy3E,19701
5
6
  sourcecode/dependency_analyzer.py,sha256=c8tSbUpug-2avKNWCP4-_e2_uhBwWl96LwL1Be8O90M,39067
6
7
  sourcecode/doc_analyzer.py,sha256=nnTOsO1tfZvaDPy5PKjRLlg6EQZ-2ONRbXlxDeogH8o,19891
@@ -8,10 +9,10 @@ sourcecode/graph_analyzer.py,sha256=S5z8mrGfLwN26Mt2IaCR4dOpQ_APU16cfihSp640eGQ,
8
9
  sourcecode/metrics_analyzer.py,sha256=zQrIPKDrGIUq4iptXbckflOj2V9897e_6vGZKEwa4b4,20124
9
10
  sourcecode/redactor.py,sha256=abSf5jH_IzYzpjF3lEN6hCwdXxvmyHs-3DHz9fd8Mic,2883
10
11
  sourcecode/scanner.py,sha256=TvcuD77m_NF8OlTj3XH1b_jO0qrto8c90fND96ecqb0,6182
11
- sourcecode/schema.py,sha256=2tGbFDMvQrsIA7ZWZvUNflkPBLWhEQjAqNtHgTkJHZ0,10112
12
+ sourcecode/schema.py,sha256=PAXaAGhJOseyjSYi24vwqOQr52ugjFfMuTe-pP6lnXs,10217
12
13
  sourcecode/semantic_analyzer.py,sha256=8loG-y_4fN7wUKmGhy9z1yAfn-xV-6yjYklFr3wxCW8,79347
13
- sourcecode/serializer.py,sha256=x_F2qIEGpKXrzgBHwkcN9O94It8R_QV02VfMnWdlKD0,3274
14
- sourcecode/summarizer.py,sha256=mNZL4uIZ_jnOYJt3_XVCqEFn2JuLR4E8LlySB1TsTxc,2567
14
+ sourcecode/serializer.py,sha256=5_hzf-imtFgYRGjJ_ZMXuK76ZnDbgJ_RGYgVlewhMz0,3304
15
+ sourcecode/summarizer.py,sha256=wCtD4sQ3b81qd7mnqiODJJRgeSk_AMchkaXuxJ_WRd8,7011
15
16
  sourcecode/tree_utils.py,sha256=5WHQc2L-AGqxKFfYYcQftE6hETxgUxP9MWDuaThcAUw,991
16
17
  sourcecode/workspace.py,sha256=1L4xH38gU89fLeMYSuF3ft7lSdCyTfMfJ9NQt5q4Ric,6904
17
18
  sourcecode/detectors/__init__.py,sha256=A0AACJFF6HWf_RgatNtWu3PUzstcKtIGM9f1PoFcJug,1987
@@ -19,21 +20,21 @@ sourcecode/detectors/base.py,sha256=mLqDNil8VkFRNbzwO2wPbmw_aaVg3WR_WzzRjU58xbU,
19
20
  sourcecode/detectors/dart.py,sha256=77KDgBY-xkbzLjs0Emw6c0NuQY-tQZR5PCHa9r0t2OE,1476
20
21
  sourcecode/detectors/dotnet.py,sha256=BofpMPVOJmiUpCFpkd0y7zSzzBj4jeXZkZR5AksBg8w,2017
21
22
  sourcecode/detectors/elixir.py,sha256=Aurq99BvJvZ1k9lNBiI-RGOJzi8e7maQd225IG128PE,1759
22
- sourcecode/detectors/go.py,sha256=oifvbih6i4yrUO2zQs3kVXEdirr6jVriiV1fJ_-NF2I,1748
23
+ sourcecode/detectors/go.py,sha256=UAOmAX3YbpwuXSw2UBNYYDR6Mj5zJeLYK3vyNU9yb5I,1868
23
24
  sourcecode/detectors/heuristic.py,sha256=Phimturkvi5ZwNdt005TizYTrllZk1pqHpfXOkFZJbc,1943
24
25
  sourcecode/detectors/java.py,sha256=XdONBlcBb0Vo-2hvG77Fw00X41d2KlHFCPXJCuzwAl0,3547
25
26
  sourcecode/detectors/jvm_ext.py,sha256=ViHKwUzlKXjpjNNB6_wK0XptR29VUsdLobW2U-mS5BM,2665
26
- sourcecode/detectors/nodejs.py,sha256=rfUG2oT5SsV5nMCuXY4v99IrAWF0lL5i47m9RHOM2mM,3919
27
+ sourcecode/detectors/nodejs.py,sha256=Epkz8vHdTscjQzCCZHuhOehMu0MrSPNXzfrQU3fHkMA,4535
27
28
  sourcecode/detectors/parsers.py,sha256=CQhhFGk8lSXruIfLe571-1HOId3W0ssnY2ioHMGtOrg,2338
28
29
  sourcecode/detectors/php.py,sha256=6l5pFlUuKrrC6XlWPSgYTEnnPm3hoFxMM4DRAnjL4nM,1920
29
30
  sourcecode/detectors/project.py,sha256=QOTt2p55Wt6FNUSo0_1P7iojM_CsVevvQqHQmLqVxYk,6022
30
- sourcecode/detectors/python.py,sha256=UVB5M_B8t-zQkSrIqmNQR5KD7RqDHzT8C3CcazgxEi8,6900
31
+ sourcecode/detectors/python.py,sha256=cib7EmEpQSF8X1sI3oAaGxHqP1rg2g0RkmJIuR8VHsM,7714
31
32
  sourcecode/detectors/ruby.py,sha256=3_Hd5YqlErpTtyTRcUTPnMp1TF92hT-TnYYeclLbF1c,1625
32
33
  sourcecode/detectors/rust.py,sha256=iUz9lE4HfBy8CBaDwGhfh6kLsNIovrH2ay2v3y6qNDc,2298
33
34
  sourcecode/detectors/systems.py,sha256=qf5M9tio1H6XDorSstTWIxKe0xVLR_QGTJpFGyhDz4A,1690
34
35
  sourcecode/detectors/terraform.py,sha256=5EOKQtTViWo7u3tFK0h45YD8GQPwhrww0cOicxGPPn4,1732
35
36
  sourcecode/detectors/tooling.py,sha256=wPvudLCJZ8_cr9GIx44C8jkLAlEN9uTUZcJsP7eVYnQ,1937
36
- sourcecode-0.9.0.dist-info/METADATA,sha256=-DmATf2XsAgHJnw3Wg6S1xOjMqKVt_yr5XunJIR2Uu8,14903
37
- sourcecode-0.9.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
38
- sourcecode-0.9.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
39
- sourcecode-0.9.0.dist-info/RECORD,,
37
+ sourcecode-0.10.0.dist-info/METADATA,sha256=fsy8ZP-sKE3o61g6ITicvaufEDGH-vLmbSnsIWNuSec,15093
38
+ sourcecode-0.10.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
39
+ sourcecode-0.10.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
40
+ sourcecode-0.10.0.dist-info/RECORD,,