sourcecode 0.13.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.13.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
@@ -47,7 +47,7 @@ def main(
47
47
  compact: bool = typer.Option(
48
48
  False,
49
49
  "--compact",
50
- 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",
51
51
  ),
52
52
  dependencies: bool = typer.Option(
53
53
  False,
@@ -117,6 +117,41 @@ def main(
117
117
  "--semantics",
118
118
  help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
119
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
+ ),
120
155
  ) -> None:
121
156
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
122
157
  # Validar formato
@@ -158,6 +193,7 @@ def main(
158
193
  from sourcecode.metrics_analyzer import MetricsAnalyzer
159
194
  from sourcecode.redactor import SecretRedactor, redact_dict
160
195
  from sourcecode.scanner import FileScanner
196
+ from sourcecode.semantic_analyzer import SemanticAnalyzer
161
197
  from sourcecode.schema import (
162
198
  AnalysisMetadata,
163
199
  DocRecord,
@@ -233,7 +269,6 @@ def main(
233
269
  doc_analyzer = DocAnalyzer() if docs else None
234
270
  metrics_analyzer = MetricsAnalyzer() if full_metrics else None
235
271
 
236
- from sourcecode.semantic_analyzer import SemanticAnalyzer
237
272
  semantic_analyzer = SemanticAnalyzer() if semantics else None
238
273
 
239
274
  root_manifests = [
@@ -292,7 +327,7 @@ def main(
292
327
  doc_records.extend(root_doc_records)
293
328
  doc_summaries.append(root_doc_summary)
294
329
 
295
- file_metrics_records: list = []
330
+ file_metrics_records: list[Any] = []
296
331
  metrics_summaries = []
297
332
  if metrics_analyzer is not None:
298
333
  root_metrics_tree = (
@@ -500,6 +535,29 @@ def main(
500
535
  sm.project_summary = ProjectSummarizer(target).generate(sm)
501
536
  sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
502
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
+
503
561
  # 4. Serializar (con o sin modo compact)
504
562
  if compact:
505
563
  data = compact_view(sm)
@@ -536,33 +594,34 @@ def main(
536
594
  # 5. Escribir output (CLI-04)
537
595
  write_output(content, output=output)
538
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
+
539
606
  from sourcecode.prepare_context import ContextBuilder
540
607
 
541
- @app.command("prepare-context")
542
- def prepare_context_cmd(
543
- task: str = typer.Argument(..., help="Descripción de la tarea"),
544
- path: Path = typer.Option(".", help="Directorio del proyecto"),
545
- ):
546
- """
547
- Prepara contexto mínimo optimizado para que un LLM modifique el código.
548
- """
549
- target = path.resolve()
550
-
551
- if not target.exists() or not target.is_dir():
552
- typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
553
- 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)
554
613
 
555
- builder = ContextBuilder(target)
556
- result = builder.prepare(task)
614
+ builder = ContextBuilder(target)
615
+ result = builder.prepare(task)
557
616
 
558
- output = {
559
- "task": result.task,
560
- "entry_points": result.entry_points,
561
- "relevant_files": [f.__dict__ for f in result.relevant_files],
562
- "call_flow": result.call_flow,
563
- "snippets": [s.__dict__ for s in result.snippets],
564
- "tests": result.tests,
565
- "notes": result.notes,
566
- }
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
+ }
567
626
 
568
- typer.echo(json.dumps(output, indent=2, ensure_ascii=False))
627
+ typer.echo(json.dumps(out, indent=2, ensure_ascii=False))