sourcecode 0.12.0__py3-none-any.whl → 0.14.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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.14.0"
@@ -0,0 +1,348 @@
1
+ """Inferencia arquitectonica: domain clustering, layer detection y bounded context inference."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Literal, Optional
6
+
7
+ from sourcecode.schema import (
8
+ ArchitectureAnalysis,
9
+ ArchitectureDomain,
10
+ ArchitectureLayer,
11
+ BoundedContext,
12
+ ModuleGraph,
13
+ SourceMap,
14
+ )
15
+
16
+ _TOOLING_PREFIXES = (
17
+ ".claude/",
18
+ ".vscode/",
19
+ "bin/",
20
+ ".git/",
21
+ "__pycache__/",
22
+ "node_modules/",
23
+ ".venv/",
24
+ "venv/",
25
+ ".mypy_cache/",
26
+ ".pytest_cache/",
27
+ "dist/",
28
+ "build/",
29
+ )
30
+ _SRC_TRANSPARENT = {"src", "lib", "app", "pkg"}
31
+ _CODE_EXTENSIONS = {
32
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
33
+ ".go", ".java", ".kt", ".rs", ".rb",
34
+ }
35
+ _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
36
+
37
+ DOMAIN_ROLES: dict[str, str] = {
38
+ "controllers": "HTTP request handlers",
39
+ "handlers": "HTTP request handlers",
40
+ "routes": "Route definitions",
41
+ "services": "Business logic",
42
+ "usecases": "Business logic",
43
+ "application": "Application layer",
44
+ "repositories": "Data access",
45
+ "repos": "Data access",
46
+ "store": "Data access",
47
+ "models": "Domain models",
48
+ "entities": "Domain entities",
49
+ "domain": "Domain core",
50
+ "infra": "Infrastructure",
51
+ "infrastructure": "Infrastructure",
52
+ "adapters": "Hexagonal adapters",
53
+ "ports": "Hexagonal ports",
54
+ "frontend": "Frontend/UI layer",
55
+ "backend": "Backend/API layer",
56
+ "components": "UI components",
57
+ "pages": "UI pages/views",
58
+ "views": "View layer",
59
+ "templates": "View templates",
60
+ "tests": "Test suite",
61
+ "test": "Test suite",
62
+ }
63
+
64
+ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
65
+ "mvc": {
66
+ "controller": ["controller", "controllers", "routes", "views", "handlers"],
67
+ "model": ["model", "models", "entity", "entities", "domain"],
68
+ "view": ["views", "templates", "pages", "components"],
69
+ },
70
+ "layered": {
71
+ "controller": ["controller", "controllers", "api", "routes", "handlers", "endpoints"],
72
+ "service": ["service", "services", "usecase", "usecases", "application"],
73
+ "repository": ["repository", "repositories", "repo", "repos", "store", "storage", "dao"],
74
+ "infrastructure": ["infra", "infrastructure", "persistence", "db", "database"],
75
+ },
76
+ "hexagonal": {
77
+ "port": ["port", "ports", "interface", "interfaces"],
78
+ "adapter": ["adapter", "adapters"],
79
+ "domain": ["domain", "core", "model", "models"],
80
+ },
81
+ "fullstack": {
82
+ "frontend": ["frontend", "client", "web", "ui", "pages", "components", "app"],
83
+ "backend": ["backend", "server", "api", "services"],
84
+ },
85
+ }
86
+
87
+
88
+ class ArchitectureAnalyzer:
89
+ """Analiza la arquitectura de un proyecto a partir de su estructura de ficheros y grafo de modulos."""
90
+
91
+ def analyze(
92
+ self,
93
+ root: Path,
94
+ sm: SourceMap,
95
+ graph: Optional[ModuleGraph] = None,
96
+ ) -> ArchitectureAnalysis:
97
+ limitations: list[str] = []
98
+
99
+ # Step 1: filter paths
100
+ filtered = self._filter_paths(sm.file_paths)
101
+ if len(filtered) < 2:
102
+ return ArchitectureAnalysis(
103
+ requested=True,
104
+ pattern="unknown",
105
+ limitations=["Arquitectura no inferida: proyecto sin archivos de codigo suficientes"],
106
+ )
107
+
108
+ # Step 2: domain clustering
109
+ domains = self._cluster_domains(filtered)
110
+
111
+ # Step 3: layer detection
112
+ pattern, layers = self._detect_layers(filtered)
113
+ if pattern in (None, "flat", "unknown"):
114
+ if pattern == "flat":
115
+ limitations.append("Patron de capas no detectado: proyecto con estructura plana")
116
+ elif pattern == "unknown":
117
+ limitations.append("Patron de capas no reconocido: estructura de directorios sin senales claras")
118
+
119
+ # Step 4: bounded context inference
120
+ bounded_contexts = self._infer_bounded_contexts(domains, graph)
121
+
122
+ # Overall confidence
123
+ confidence: Literal["high", "medium", "low"]
124
+ if len(domains) >= 3 and pattern not in (None, "unknown"):
125
+ confidence = "high"
126
+ elif len(domains) >= 1:
127
+ confidence = "medium"
128
+ else:
129
+ confidence = "low"
130
+
131
+ method = "graph+heuristic" if graph is not None else "heuristic"
132
+
133
+ return ArchitectureAnalysis(
134
+ requested=True,
135
+ pattern=pattern,
136
+ domains=domains,
137
+ layers=layers,
138
+ bounded_contexts=bounded_contexts,
139
+ confidence=confidence,
140
+ method=method,
141
+ limitations=limitations,
142
+ )
143
+
144
+ # ------------------------------------------------------------------
145
+ # Private helpers
146
+ # ------------------------------------------------------------------
147
+
148
+ def _is_tooling(self, path: str) -> bool:
149
+ norm = path.replace("\\", "/")
150
+ return any(norm.startswith(p) for p in _TOOLING_PREFIXES)
151
+
152
+ def _filter_paths(self, paths: list[str]) -> list[str]:
153
+ result = []
154
+ for p in paths:
155
+ norm = p.replace("\\", "/")
156
+ if self._is_tooling(norm):
157
+ continue
158
+ ext = Path(norm).suffix.lower()
159
+ if ext not in _CODE_EXTENSIONS:
160
+ continue
161
+ result.append(norm)
162
+ return result
163
+
164
+ def _extract_domain_segment(self, path: str) -> str:
165
+ parts = path.replace("\\", "/").split("/")
166
+ if len(parts) == 1:
167
+ return "root"
168
+ first = parts[0]
169
+ if first in _SRC_TRANSPARENT and len(parts) >= 3:
170
+ return parts[1]
171
+ return first
172
+
173
+ def _cluster_domains(self, paths: list[str]) -> list[ArchitectureDomain]:
174
+ groups: dict[str, list[str]] = {}
175
+ for p in paths:
176
+ seg = self._extract_domain_segment(p)
177
+ groups.setdefault(seg, []).append(p)
178
+
179
+ domains: list[ArchitectureDomain] = []
180
+ for name, files in groups.items():
181
+ if len(files) < 2:
182
+ continue
183
+ role = DOMAIN_ROLES.get(name, "")
184
+ domain_confidence: Literal["high", "medium", "low"]
185
+ if name in DOMAIN_ROLES:
186
+ domain_confidence = "high"
187
+ elif len(name) >= 5 and name not in _GENERIC_NAMES:
188
+ domain_confidence = "medium"
189
+ else:
190
+ domain_confidence = "low"
191
+ domains.append(ArchitectureDomain(name=name, files=files, role=role, confidence=domain_confidence))
192
+ return domains
193
+
194
+ def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
195
+ dir_names: set[str] = set()
196
+ for p in paths:
197
+ parts = p.replace("\\", "/").split("/")
198
+ for part in parts[:-1]:
199
+ dir_names.add(part.lower())
200
+
201
+ best_pattern = ""
202
+ best_score = 0
203
+ best_matched: dict[str, list[str]] = {}
204
+
205
+ for pattern_name, layer_keys in LAYER_PATTERNS.items():
206
+ matched: dict[str, list[str]] = {}
207
+ for layer_key, keywords in layer_keys.items():
208
+ matched_dirs = [d for d in dir_names if d in keywords]
209
+ if matched_dirs:
210
+ matched[layer_key] = matched_dirs
211
+ score = len(matched)
212
+ if score > best_score:
213
+ best_score = score
214
+ best_pattern = pattern_name
215
+ best_matched = matched
216
+
217
+ if best_score < 2:
218
+ # Check depth for flat vs unknown
219
+ max_depth = max(
220
+ len(p.replace("\\", "/").split("/")) - 1
221
+ for p in paths
222
+ )
223
+ flat_pattern = "flat" if max_depth <= 2 else "unknown"
224
+ return flat_pattern, []
225
+
226
+ # Build ArchitectureLayer list
227
+ layer_confidence: Literal["high", "medium", "low"] = "high" if best_score >= 3 else "medium"
228
+ layers: list[ArchitectureLayer] = []
229
+ for layer_key, matched_dirs in best_matched.items():
230
+ matched_files = [
231
+ p for p in paths
232
+ if any(
233
+ seg.lower() in matched_dirs
234
+ for seg in p.replace("\\", "/").split("/")[:-1]
235
+ )
236
+ ]
237
+ layers.append(ArchitectureLayer(
238
+ name=layer_key,
239
+ pattern=best_pattern,
240
+ files=matched_files,
241
+ confidence=layer_confidence,
242
+ ))
243
+
244
+ return best_pattern or "unknown", layers
245
+
246
+ def _infer_bounded_contexts(
247
+ self,
248
+ domains: list[ArchitectureDomain],
249
+ graph: Optional[ModuleGraph],
250
+ ) -> list[BoundedContext]:
251
+ # Priority 1: use graph SCCs when available
252
+ if graph is not None:
253
+ sccs = self._find_sccs(graph)
254
+ bc_list: list[BoundedContext] = []
255
+ for scc in sccs:
256
+ if len(scc) < 2:
257
+ continue
258
+ # Derive name from most frequent directory segment among nodes
259
+ seg_counts: dict[str, int] = {}
260
+ for node_id in scc:
261
+ parts = node_id.replace("\\", "/").split("/")
262
+ seg = parts[0] if len(parts) == 1 else parts[-2] if len(parts) >= 2 else parts[0]
263
+ seg_counts[seg] = seg_counts.get(seg, 0) + 1
264
+ name = max(seg_counts, key=lambda k: seg_counts[k])
265
+ if name in _GENERIC_NAMES:
266
+ continue
267
+ bc_conf: Literal["high", "medium", "low"] = "high" if name in DOMAIN_ROLES else "medium"
268
+ bc_list.append(BoundedContext(name=name, modules=scc, confidence=bc_conf))
269
+ if bc_list:
270
+ return bc_list
271
+
272
+ # Priority 2: use domains as candidates
273
+ bc_from_domains: list[BoundedContext] = []
274
+ for d in domains:
275
+ if d.name in _GENERIC_NAMES:
276
+ continue
277
+ if d.confidence == "low":
278
+ continue
279
+ bc_from_domains.append(BoundedContext(
280
+ name=d.name,
281
+ modules=d.files,
282
+ confidence="medium",
283
+ ))
284
+ return bc_from_domains
285
+
286
+ def _find_sccs(self, graph: ModuleGraph) -> list[list[str]]:
287
+ """Kosaraju's algorithm for strongly connected components."""
288
+ import_edges = [
289
+ (e.source, e.target)
290
+ for e in graph.edges
291
+ if e.kind == "imports"
292
+ ]
293
+ node_ids = [n.id for n in graph.nodes]
294
+
295
+ # Build adjacency lists
296
+ adj: dict[str, list[str]] = {n: [] for n in node_ids}
297
+ radj: dict[str, list[str]] = {n: [] for n in node_ids}
298
+ for src, tgt in import_edges:
299
+ if src in adj and tgt in adj:
300
+ adj[src].append(tgt)
301
+ radj[tgt].append(src)
302
+
303
+ visited: set[str] = set()
304
+ order: list[str] = []
305
+
306
+ def dfs1(v: str) -> None:
307
+ stack = [(v, False)]
308
+ while stack:
309
+ node, done = stack.pop()
310
+ if done:
311
+ order.append(node)
312
+ continue
313
+ if node in visited:
314
+ continue
315
+ visited.add(node)
316
+ stack.append((node, True))
317
+ for nb in adj[node]:
318
+ if nb not in visited:
319
+ stack.append((nb, False))
320
+
321
+ for n in node_ids:
322
+ if n not in visited:
323
+ dfs1(n)
324
+
325
+ visited2: set[str] = set()
326
+ sccs: list[list[str]] = []
327
+
328
+ def dfs2(v: str) -> list[str]:
329
+ comp: list[str] = []
330
+ stack = [v]
331
+ while stack:
332
+ node = stack.pop()
333
+ if node in visited2:
334
+ continue
335
+ visited2.add(node)
336
+ comp.append(node)
337
+ for nb in radj[node]:
338
+ if nb not in visited2:
339
+ stack.append(nb)
340
+ return comp
341
+
342
+ for n in reversed(order):
343
+ if n not in visited2:
344
+ comp = dfs2(n)
345
+ if len(comp) >= 2:
346
+ sccs.append(comp)
347
+
348
+ return sccs
sourcecode/cli.py CHANGED
@@ -1,5 +1,4 @@
1
1
  """CLI de sourcecode — interfaz de linea de comandos principal."""
2
- from sourcecode.prepare_context import prepare_context as build_llm_context
3
2
  from __future__ import annotations
4
3
 
5
4
  import json
@@ -48,7 +47,7 @@ def main(
48
47
  compact: bool = typer.Option(
49
48
  False,
50
49
  "--compact",
51
- help="Output reducido (~500 tokens): tipo de proyecto, stacks, entradas y arbol nivel 1",
50
+ help="Output reducido (~500-700 tokens): tipo, stacks, entradas, arbol nivel 1 y summaries de flags opcionales activos",
52
51
  ),
53
52
  dependencies: bool = typer.Option(
54
53
  False,
@@ -118,6 +117,41 @@ def main(
118
117
  "--semantics",
119
118
  help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
120
119
  ),
120
+ architecture: bool = typer.Option(
121
+ False,
122
+ "--architecture",
123
+ help="Inferencia arquitectonica: dominios funcionales, capas (MVC/layered/hexagonal) y bounded contexts aproximados",
124
+ ),
125
+ git_context: bool = typer.Option(
126
+ False,
127
+ "--git-context",
128
+ "-g",
129
+ help="Incluir contexto git: commits recientes, ficheros mas activos y cambios pendientes",
130
+ ),
131
+ git_depth: int = typer.Option(
132
+ 20,
133
+ "--git-depth",
134
+ help="Numero de commits recientes a incluir con --git-context (default: 20)",
135
+ min=1,
136
+ max=100,
137
+ ),
138
+ git_days: int = typer.Option(
139
+ 90,
140
+ "--git-days",
141
+ help="Ventana temporal en dias para detectar ficheros mas activos con --git-context (default: 90)",
142
+ min=1,
143
+ max=3650,
144
+ ),
145
+ env_map: bool = typer.Option(
146
+ False,
147
+ "--env-map",
148
+ help="Incluir mapa de variables de entorno: claves, tipos, categorias y ficheros que las referencian",
149
+ ),
150
+ code_notes: bool = typer.Option(
151
+ False,
152
+ "--code-notes",
153
+ help="Extraer anotaciones TODO/FIXME/HACK/NOTE/DEPRECATED/WARNING/BUG/XXX/OPTIMIZE con ubicacion y simbolo envolvente, y detectar ADRs en docs/decisions/, docs/adr/ y similares",
154
+ ),
121
155
  ) -> None:
122
156
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
123
157
  # Validar formato
@@ -159,6 +193,7 @@ def main(
159
193
  from sourcecode.metrics_analyzer import MetricsAnalyzer
160
194
  from sourcecode.redactor import SecretRedactor, redact_dict
161
195
  from sourcecode.scanner import FileScanner
196
+ from sourcecode.semantic_analyzer import SemanticAnalyzer
162
197
  from sourcecode.schema import (
163
198
  AnalysisMetadata,
164
199
  DocRecord,
@@ -234,7 +269,6 @@ def main(
234
269
  doc_analyzer = DocAnalyzer() if docs else None
235
270
  metrics_analyzer = MetricsAnalyzer() if full_metrics else None
236
271
 
237
- from sourcecode.semantic_analyzer import SemanticAnalyzer
238
272
  semantic_analyzer = SemanticAnalyzer() if semantics else None
239
273
 
240
274
  root_manifests = [
@@ -293,7 +327,7 @@ def main(
293
327
  doc_records.extend(root_doc_records)
294
328
  doc_summaries.append(root_doc_summary)
295
329
 
296
- file_metrics_records: list = []
330
+ file_metrics_records: list[Any] = []
297
331
  metrics_summaries = []
298
332
  if metrics_analyzer is not None:
299
333
  root_metrics_tree = (
@@ -501,6 +535,29 @@ def main(
501
535
  sm.project_summary = ProjectSummarizer(target).generate(sm)
502
536
  sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
503
537
 
538
+ # Phase 13 Plan 04: Architectural Inference (--architecture flag)
539
+ if architecture:
540
+ from sourcecode.architecture_analyzer import ArchitectureAnalyzer
541
+ arch_graph = module_graph # None si --graph-modules no fue pasado
542
+ sm.architecture = ArchitectureAnalyzer().analyze(target, sm, arch_graph)
543
+
544
+ # Git Context (--git-context flag)
545
+ if git_context:
546
+ from sourcecode.git_analyzer import GitAnalyzer
547
+ sm.git_context = GitAnalyzer().analyze(target, depth=git_depth, days=git_days)
548
+
549
+ # Env Map (--env-map flag)
550
+ if env_map:
551
+ from sourcecode.env_analyzer import EnvAnalyzer
552
+ env_records, env_summary = EnvAnalyzer().analyze(target, file_tree)
553
+ sm = replace(sm, env_map=env_records, env_summary=env_summary)
554
+
555
+ # Code Notes (--code-notes flag)
556
+ if code_notes:
557
+ from sourcecode.code_notes_analyzer import CodeNotesAnalyzer
558
+ cn_notes, cn_adrs, cn_summary = CodeNotesAnalyzer().analyze(target)
559
+ sm = replace(sm, code_notes=cn_notes, code_adrs=cn_adrs, code_notes_summary=cn_summary)
560
+
504
561
  # 4. Serializar (con o sin modo compact)
505
562
  if compact:
506
563
  data = compact_view(sm)
@@ -537,33 +594,34 @@ def main(
537
594
  # 5. Escribir output (CLI-04)
538
595
  write_output(content, output=output)
539
596
 
597
+
598
+ @app.command("prepare-context")
599
+ def prepare_context_cmd(
600
+ task: str = typer.Argument(..., help="Descripción de la tarea"),
601
+ path: Path = typer.Option(".", "--path", "-p", help="Directorio del proyecto"),
602
+ ) -> None:
603
+ """Prepara contexto mínimo optimizado para que un LLM modifique el código."""
604
+ from dataclasses import asdict
605
+
540
606
  from sourcecode.prepare_context import ContextBuilder
541
607
 
542
- @app.command("prepare-context")
543
- def prepare_context_cmd(
544
- task: str = typer.Argument(..., help="Descripción de la tarea"),
545
- path: Path = typer.Option(".", help="Directorio del proyecto"),
546
- ):
547
- """
548
- Prepara contexto mínimo optimizado para que un LLM modifique el código.
549
- """
550
- target = path.resolve()
551
-
552
- if not target.exists() or not target.is_dir():
553
- typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
554
- raise typer.Exit(code=1)
608
+ target = path.resolve()
609
+
610
+ if not target.exists() or not target.is_dir():
611
+ typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
612
+ raise typer.Exit(code=1)
555
613
 
556
- builder = ContextBuilder(target)
557
- result = builder.prepare(task)
614
+ builder = ContextBuilder(target)
615
+ result = builder.prepare(task)
558
616
 
559
- output = {
560
- "task": result.task,
561
- "entry_points": result.entry_points,
562
- "relevant_files": [f.__dict__ for f in result.relevant_files],
563
- "call_flow": result.call_flow,
564
- "snippets": [s.__dict__ for s in result.snippets],
565
- "tests": result.tests,
566
- "notes": result.notes,
567
- }
617
+ out = {
618
+ "task": result.task,
619
+ "entry_points": result.entry_points,
620
+ "relevant_files": [asdict(f) for f in result.relevant_files],
621
+ "call_flow": result.call_flow,
622
+ "snippets": [asdict(s) for s in result.snippets],
623
+ "tests": result.tests,
624
+ "notes": result.notes,
625
+ }
568
626
 
569
- typer.echo(json.dumps(output, indent=2, ensure_ascii=False))
627
+ typer.echo(json.dumps(out, indent=2, ensure_ascii=False))