sourcecode 0.8.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.8.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
@@ -112,6 +112,11 @@ def main(
112
112
  "--full-metrics",
113
113
  help="Incluir metricas de calidad: LOC, simbolos, complejidad, tests y cobertura por fichero",
114
114
  ),
115
+ semantics: bool = typer.Option(
116
+ False,
117
+ "--semantics",
118
+ help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
119
+ ),
115
120
  ) -> None:
116
121
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
117
122
  # Validar formato
@@ -228,6 +233,9 @@ def main(
228
233
  doc_analyzer = DocAnalyzer() if docs else None
229
234
  metrics_analyzer = MetricsAnalyzer() if full_metrics else None
230
235
 
236
+ from sourcecode.semantic_analyzer import SemanticAnalyzer
237
+ semantic_analyzer = SemanticAnalyzer() if semantics else None
238
+
231
239
  root_manifests = [
232
240
  manifest
233
241
  for manifest in manifests
@@ -424,9 +432,51 @@ def main(
424
432
  metrics_summary=metrics_summary,
425
433
  )
426
434
 
427
- # Phase 9: LLM Output Quality — poblar campos derivados
428
- from sourcecode.summarizer import ProjectSummarizer
429
- from sourcecode.tree_utils import flatten_file_tree
435
+ # Semantic analysis (--semantics flag)
436
+ if semantic_analyzer is not None:
437
+ if workspace_analysis.workspaces:
438
+ all_sem_calls: list[Any] = []
439
+ all_sem_symbols: list[Any] = []
440
+ all_sem_links: list[Any] = []
441
+ all_sem_summaries: list[Any] = []
442
+ for ws in workspace_analysis.workspaces:
443
+ ws_calls, ws_syms, ws_links, ws_sum = semantic_analyzer.analyze(
444
+ target / ws.path,
445
+ (
446
+ filter_sensitive_files(
447
+ FileScanner(target / ws.path, max_depth=depth).scan_tree()
448
+ )
449
+ ),
450
+ workspace=ws.path,
451
+ )
452
+ all_sem_calls.extend(ws_calls)
453
+ all_sem_symbols.extend(ws_syms)
454
+ all_sem_links.extend(ws_links)
455
+ all_sem_summaries.append(ws_sum)
456
+ merged_sem = semantic_analyzer.merge_summaries(all_sem_summaries)
457
+ sm = replace(
458
+ sm,
459
+ semantic_calls=all_sem_calls,
460
+ semantic_symbols=all_sem_symbols,
461
+ semantic_links=all_sem_links,
462
+ semantic_summary=merged_sem,
463
+ )
464
+ else:
465
+ sem_calls, sem_syms, sem_links, sem_sum = semantic_analyzer.analyze(
466
+ target, file_tree
467
+ )
468
+ sm = replace(
469
+ sm,
470
+ semantic_calls=sem_calls,
471
+ semantic_symbols=sem_syms,
472
+ semantic_links=sem_links,
473
+ semantic_summary=sem_sum,
474
+ )
475
+
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
430
480
 
431
481
  # LQN-01: lista plana de paths del file_tree con separador forward-slash
432
482
  sm.file_paths = [
@@ -446,8 +496,9 @@ def main(
446
496
 
447
497
  sm.key_dependencies = sorted(direct_deps, key=_dep_sort_key)[:15]
448
498
 
449
- # LQN-02: resumen NL deterministico
450
- 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)
451
502
 
452
503
  # 4. Serializar (con o sin modo compact)
453
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
@@ -236,6 +237,66 @@ class DocSummary:
236
237
  limitations: list[str] = field(default_factory=list)
237
238
 
238
239
 
240
+ @dataclass
241
+ class SymbolRecord:
242
+ """Symbol definition found in a source file."""
243
+
244
+ symbol: str # local name: "MyClass" or "my_func"
245
+ kind: str # "function" | "class" | "constant" | "method"
246
+ language: str
247
+ path: str # relative path to defining file
248
+ line: Optional[int] = None
249
+ qualified_name: Optional[str] = None # "pkg.module.MyClass"
250
+ exported: bool = True # False if name starts with _ and no __all__ override
251
+ workspace: Optional[str] = None
252
+
253
+
254
+ @dataclass
255
+ class CallRecord:
256
+ """A resolved call from one symbol to another, possibly across files."""
257
+
258
+ caller_path: str
259
+ caller_symbol: str
260
+ callee_path: str
261
+ callee_symbol: str
262
+ call_line: Optional[int] = None
263
+ confidence: Literal["high", "medium", "low"] = "medium"
264
+ method: Literal["ast", "heuristic", "unresolved"] = "heuristic"
265
+ args: list[str] = field(default_factory=list)
266
+ kwargs: dict[str, str] = field(default_factory=dict)
267
+ workspace: Optional[str] = None
268
+
269
+
270
+ @dataclass
271
+ class SymbolLink:
272
+ """A symbol imported in one file, resolved to its definition in another."""
273
+
274
+ importer_path: str
275
+ symbol: str
276
+ source_path: Optional[str] = None
277
+ source_line: Optional[int] = None
278
+ is_external: bool = False
279
+ confidence: Literal["high", "medium", "low"] = "high"
280
+ method: Literal["ast", "heuristic", "unresolved"] = "ast"
281
+ workspace: Optional[str] = None
282
+
283
+
284
+ @dataclass
285
+ class SemanticSummary:
286
+ """Summary of the --semantics analysis."""
287
+
288
+ requested: bool = False
289
+ call_count: int = 0
290
+ symbol_count: int = 0
291
+ link_count: int = 0
292
+ languages: list[str] = field(default_factory=list)
293
+ language_coverage: dict[str, str] = field(default_factory=dict)
294
+ files_analyzed: int = 0
295
+ files_skipped: int = 0
296
+ truncated: bool = False
297
+ limitations: list[str] = field(default_factory=list)
298
+
299
+
239
300
  @dataclass
240
301
  class SourceMap:
241
302
  """Schema completo del output v1.0.
@@ -262,7 +323,13 @@ class SourceMap:
262
323
  # Phase 9: LLM Output Quality
263
324
  file_paths: list[str] = field(default_factory=list)
264
325
  project_summary: Optional[str] = None
326
+ architecture_summary: Optional[str] = None
265
327
  key_dependencies: list[DependencyRecord] = field(default_factory=list)
266
328
  # Phase 10: Code Quality Metrics
267
329
  file_metrics: list[FileMetrics] = field(default_factory=list)
268
330
  metrics_summary: Optional[MetricsSummary] = None
331
+ # Phase 12: Static Semantics
332
+ semantic_calls: list[CallRecord] = field(default_factory=list)
333
+ semantic_symbols: list[SymbolRecord] = field(default_factory=list)
334
+ semantic_links: list[SymbolLink] = field(default_factory=list)
335
+ semantic_summary: Optional[SemanticSummary] = None