sourcecode 0.23.0__py3-none-any.whl → 0.25.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.23.0"
3
+ __version__ = "0.25.0"
@@ -92,6 +92,21 @@ DOMAIN_ROLES: dict[str, str] = {
92
92
  }
93
93
 
94
94
  LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
95
+ "cqrs": {
96
+ "commands": ["commands", "command"],
97
+ "queries": ["queries", "query"],
98
+ },
99
+ "clean": {
100
+ "domain": ["domain", "domains"],
101
+ "application": ["application", "usecases", "usecase"],
102
+ "infrastructure": ["infrastructure", "infra", "adapters", "persistence"],
103
+ },
104
+ "onion": {
105
+ "domain": ["domain", "core"],
106
+ "application": ["application", "usecases"],
107
+ "ports": ["ports", "interfaces"],
108
+ "adapters": ["adapters", "secondary"],
109
+ },
95
110
  "mvc": {
96
111
  "controller": ["controller", "controllers", "routes", "views", "handlers"],
97
112
  "model": ["model", "models", "entity", "entities", "domain"],
@@ -108,12 +123,28 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
108
123
  "adapter": ["adapter", "adapters"],
109
124
  "domain": ["domain", "core", "model", "models"],
110
125
  },
126
+ "monorepo": {
127
+ "apps": ["apps", "applications"],
128
+ "packages": ["packages", "libs", "modules"],
129
+ },
111
130
  "fullstack": {
112
131
  "frontend": ["frontend", "client", "web", "ui", "pages", "components", "app"],
113
132
  "backend": ["backend", "server", "api", "services"],
114
133
  },
115
134
  }
116
135
 
136
+ # Higher value = wins when score ties
137
+ _PATTERN_PRIORITY: dict[str, int] = {
138
+ "cqrs": 8,
139
+ "clean": 7,
140
+ "onion": 6,
141
+ "hexagonal": 5,
142
+ "monorepo": 4,
143
+ "mvc": 3,
144
+ "layered": 2,
145
+ "fullstack": 1,
146
+ }
147
+
117
148
 
118
149
  class ArchitectureAnalyzer:
119
150
  """Analiza la arquitectura de un proyecto a partir de su estructura de ficheros y grafo de modulos."""
@@ -245,9 +276,10 @@ class ArchitectureAnalyzer:
245
276
  for part in parts[:-1]:
246
277
  dir_names.add(part.lower())
247
278
 
248
- # 1. Classical keyword-based pattern matching (MVC, layered, hexagonal, fullstack)
279
+ # 1. Classical keyword-based pattern matching
249
280
  best_pattern = ""
250
281
  best_score = 0
282
+ best_priority = -1
251
283
  best_matched: dict[str, list[str]] = {}
252
284
 
253
285
  for pattern_name, layer_keys in LAYER_PATTERNS.items():
@@ -257,8 +289,10 @@ class ArchitectureAnalyzer:
257
289
  if matched_dirs:
258
290
  matched[layer_key] = matched_dirs
259
291
  score = len(matched)
260
- if score > best_score:
292
+ priority = _PATTERN_PRIORITY.get(pattern_name, 0)
293
+ if (score, priority) > (best_score, best_priority):
261
294
  best_score = score
295
+ best_priority = priority
262
296
  best_pattern = pattern_name
263
297
  best_matched = matched
264
298
 
@@ -281,23 +315,61 @@ class ArchitectureAnalyzer:
281
315
  ))
282
316
  return best_pattern, layers
283
317
 
284
- # 2. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
318
+ # 2. Microservices structural detection (before file-naming heuristics)
319
+ microservices_result = self._detect_microservices(source_paths)
320
+ if microservices_result is not None:
321
+ return microservices_result
322
+
323
+ # 3. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
285
324
  func_result = self._detect_layered_functional(source_paths)
286
325
  if func_result is not None:
287
326
  return func_result
288
327
 
289
- # 3. Modular sub-package heuristic: ≥2 distinct named sub-packages
328
+ # 4. Modular sub-package heuristic: ≥2 distinct named sub-packages
290
329
  modular_result = self._detect_modular(source_paths)
291
330
  if modular_result is not None:
292
331
  return modular_result
293
332
 
294
- # 4. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
333
+ # 5. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
295
334
  max_depth = max(
296
335
  (len(p.replace("\\", "/").split("/")) - 1 for p in source_paths),
297
336
  default=0,
298
337
  )
299
338
  return ("flat" if max_depth <= 2 else "unknown"), []
300
339
 
340
+ def _detect_microservices(
341
+ self, paths: list[str]
342
+ ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
343
+ """Detect microservices from multiple sibling service directories or services/* pattern."""
344
+ # Pattern 1: explicit services/* subdirectories
345
+ service_subdirs: dict[str, list[str]] = {}
346
+ for p in paths:
347
+ parts = p.replace("\\", "/").split("/")
348
+ if len(parts) >= 3 and parts[0].lower() == "services":
349
+ service_subdirs.setdefault(parts[1], []).append(p)
350
+ if len(service_subdirs) >= 3:
351
+ return "microservices", [
352
+ ArchitectureLayer(name=k, pattern="microservices", files=v, confidence="medium")
353
+ for k, v in list(service_subdirs.items())[:8]
354
+ ]
355
+
356
+ # Pattern 2: multiple top-level dirs each containing a canonical entry file
357
+ _ENTRY_FILES = {"main.go", "main.py", "server.js", "server.ts", "main.ts", "app.py"}
358
+ entry_dirs: dict[str, list[str]] = {}
359
+ for p in paths:
360
+ parts = p.replace("\\", "/").split("/")
361
+ if len(parts) >= 2 and parts[-1].lower() in _ENTRY_FILES:
362
+ top = parts[0]
363
+ if top.lower() not in _SRC_TRANSPARENT and top.lower() not in _TEST_DIRS:
364
+ entry_dirs.setdefault(top, []).append(p)
365
+ if len(entry_dirs) >= 4:
366
+ return "microservices", [
367
+ ArchitectureLayer(name=k, pattern="microservices", files=v, confidence="low")
368
+ for k, v in list(entry_dirs.items())[:8]
369
+ ]
370
+
371
+ return None
372
+
301
373
  def _detect_layered_functional(
302
374
  self, paths: list[str]
303
375
  ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
@@ -49,6 +49,10 @@ class ArchitectureSummarizer:
49
49
  if not file_paths:
50
50
  return None
51
51
 
52
+ # Rich path: use all available signals (stacks, arch analysis, graph)
53
+ rich_lines = self._build_rich_lines(sm)
54
+
55
+ # Stack-specific entry point analysis (existing logic)
52
56
  entry_points = [
53
57
  entry for entry in sm.entry_points
54
58
  if not self._is_tooling_path(entry.path)
@@ -57,33 +61,32 @@ class ArchitectureSummarizer:
57
61
  fallback = self._infer_fallback_entry_points(file_paths, sm.stacks)
58
62
  entry_points = fallback[:1]
59
63
 
60
- if not entry_points:
61
- return "Arquitectura no inferida con suficiente evidencia estatica."
62
-
63
- entry_point = entry_points[0]
64
- content = self._read_file(entry_point.path)
65
- if content is None:
66
- return f"Entry point principal: {entry_point.path}. Arquitectura no inferida con suficiente evidencia estatica."
67
-
68
- suffix = Path(entry_point.path).suffix
69
- if suffix in _PYTHON_EXTENSIONS:
70
- lang_lines = self._summarize_python_entry(entry_point.path, content)
71
- elif suffix in _NODE_EXTENSIONS:
72
- lang_lines = self._summarize_node_entry(entry_point.path, content)
73
- elif suffix in _GO_EXTENSIONS:
74
- lang_lines = self._summarize_go_entry(entry_point.path, content)
75
- elif suffix in _JAVA_EXTENSIONS:
76
- lang_lines = self._summarize_java_entry(entry_point.path, content, sm.stacks)
77
- else:
78
- lang_lines = []
79
-
80
- if lang_lines:
81
- # Product-level description available — no need for internal "Entry point: ..." header
82
- lines = lang_lines
83
- else:
64
+ lang_lines: list[str] = []
65
+ if entry_points:
66
+ entry_point = entry_points[0]
67
+ content = self._read_file(entry_point.path)
68
+ if content:
69
+ suffix = Path(entry_point.path).suffix
70
+ if suffix in _PYTHON_EXTENSIONS:
71
+ lang_lines = self._summarize_python_entry(entry_point.path, content)
72
+ elif suffix in _NODE_EXTENSIONS:
73
+ lang_lines = self._summarize_node_entry(entry_point.path, content)
74
+ elif suffix in _GO_EXTENSIONS:
75
+ lang_lines = self._summarize_go_entry(entry_point.path, content)
76
+ elif suffix in _JAVA_EXTENSIONS:
77
+ lang_lines = self._summarize_java_entry(entry_point.path, content, sm.stacks)
78
+ elif suffix in {".cs", ".fs", ".vb"}:
79
+ lang_lines = self._summarize_dotnet_entry(sm.stacks)
80
+
81
+ # Merge: rich lines first, stack-specific details appended (deduped)
82
+ lines = rich_lines + [l for l in lang_lines if l not in rich_lines]
83
+
84
+ if not lines and entry_points:
85
+ entry_point = entry_points[0]
84
86
  lines = [self._describe_entry_point(entry_point, sm.project_type)]
85
- if not lang_lines:
86
- lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
87
+
88
+ if not lines:
89
+ return "Arquitectura no inferida con suficiente evidencia estatica."
87
90
 
88
91
  unique_lines: list[str] = []
89
92
  seen: set[str] = set()
@@ -93,7 +96,82 @@ class ArchitectureSummarizer:
93
96
  continue
94
97
  seen.add(line)
95
98
  unique_lines.append(line)
96
- return "\n".join(unique_lines[:5]) if unique_lines else None
99
+ return "\n".join(unique_lines[:6]) if unique_lines else None
100
+
101
+ def _build_rich_lines(self, sm: SourceMap) -> list[str]:
102
+ """Generate architect-quality summary lines from stacks, arch analysis, and graph."""
103
+ if not sm.stacks:
104
+ return []
105
+
106
+ # Compute arch analysis inline if needed
107
+ arch = sm.architecture
108
+ if arch is None and sm.file_paths:
109
+ try:
110
+ from sourcecode.architecture_analyzer import ArchitectureAnalyzer
111
+ arch = ArchitectureAnalyzer().analyze(self.root, sm)
112
+ except Exception:
113
+ arch = None
114
+
115
+ lines: list[str] = []
116
+
117
+ # Line 1: project description (type + stack + frameworks)
118
+ project_line = self._describe_project_type(sm)
119
+ if project_line:
120
+ lines.append(project_line)
121
+
122
+ # Line 2: architecture pattern + layers
123
+ if arch and arch.pattern not in (None, "unknown", "flat"):
124
+ arch_line = self._describe_arch_pattern(arch)
125
+ if arch_line:
126
+ lines.append(arch_line)
127
+
128
+ # Line 3: coupling notes (if graph analytics present)
129
+ if sm.module_graph_summary:
130
+ mgr = sm.module_graph_summary
131
+ coupling_parts: list[str] = []
132
+ if mgr.cycle_count > 0:
133
+ s = "s" if mgr.cycle_count > 1 else ""
134
+ coupling_parts.append(f"{mgr.cycle_count} import cycle{s}")
135
+ if mgr.hubs:
136
+ hub_names = [h.removeprefix("module:").split("/")[-1] for h in mgr.hubs[:2]]
137
+ coupling_parts.append(f"hub modules: {', '.join(hub_names)}")
138
+ if coupling_parts:
139
+ lines.append(f"Coupling: {'; '.join(coupling_parts)}.")
140
+
141
+ return lines
142
+
143
+ def _describe_project_type(self, sm: SourceMap) -> str:
144
+ from sourcecode.context_summarizer import _STACK_LABELS, _TYPE_LABELS
145
+ runtime = _TYPE_LABELS.get(sm.project_type or "", "")
146
+ primary = next((s for s in sm.stacks if s.primary), sm.stacks[0] if sm.stacks else None)
147
+ if primary is None:
148
+ return ""
149
+ stack_label = _STACK_LABELS.get(primary.stack, primary.stack)
150
+ fw_names = [f.name for s in sm.stacks for f in s.frameworks[:2]][:3]
151
+ fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
152
+ if runtime:
153
+ return f"{stack_label} {runtime.lower()}{fw_str}."
154
+ return f"{stack_label} project{fw_str}."
155
+
156
+ def _describe_arch_pattern(self, arch: Any) -> str:
157
+ pattern_labels = {
158
+ "clean": "Clean Architecture",
159
+ "onion": "Onion Architecture",
160
+ "hexagonal": "Hexagonal Architecture",
161
+ "layered": "Layered Architecture",
162
+ "mvc": "MVC pattern",
163
+ "cqrs": "CQRS pattern",
164
+ "microservices": "Microservices",
165
+ "monorepo": "Monorepo workspace",
166
+ "modular": "Modular architecture",
167
+ }
168
+ label = pattern_labels.get(arch.pattern, arch.pattern.replace("_", " ").title() if arch.pattern else "")
169
+ if not label:
170
+ return ""
171
+ if arch.layers:
172
+ layer_names = [l.name for l in arch.layers[:4]]
173
+ return f"{label} with {', '.join(layer_names)} layers."
174
+ return f"{label} pattern detected."
97
175
 
98
176
  def _summarize_python_entry(self, path: str, content: str) -> list[str]:
99
177
  try:
@@ -189,6 +267,44 @@ class ArchitectureSummarizer:
189
267
  lines.append("Orquesta el arranque de la aplicacion JVM.")
190
268
  return lines
191
269
 
270
+ def _summarize_dotnet_entry(self, stacks: list[StackDetection]) -> list[str]:
271
+ dotnet_stacks = [s for s in stacks if s.stack == "dotnet"]
272
+ if not dotnet_stacks:
273
+ return []
274
+ lines: list[str] = []
275
+ signals = [sig for s in dotnet_stacks for sig in s.signals]
276
+
277
+ for sig in signals:
278
+ if "project" in sig and "detected" in sig:
279
+ lines.append(sig[0].upper() + sig[1:] + ".")
280
+ break
281
+
282
+ for sig in signals:
283
+ if sig.startswith("project types:"):
284
+ types = sig.removeprefix("project types:").strip()
285
+ lines.append(f"Stack: {types}.")
286
+ break
287
+
288
+ for sig in signals:
289
+ if sig.startswith("target frameworks:"):
290
+ fws = sig.removeprefix("target frameworks:").strip()
291
+ lines.append(f"Target: {fws}.")
292
+ break
293
+
294
+ for sig in signals:
295
+ if sig.startswith("architecture:"):
296
+ pattern = sig.removeprefix("architecture:").strip()
297
+ lines.append(f"Patrón detectado: {pattern}.")
298
+ break
299
+
300
+ framework_names = [f.name for s in dotnet_stacks for f in s.frameworks]
301
+ if framework_names:
302
+ lines.append(f"Frameworks: {', '.join(framework_names)}.")
303
+
304
+ if not lines:
305
+ lines.append("Solución .NET detectada.")
306
+ return lines
307
+
192
308
  def _summarize_go_entry(self, path: str, content: str) -> list[str]:
193
309
  imports = re.findall(r'"([^"]+)"', content)
194
310
  internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
@@ -273,6 +389,16 @@ class ArchitectureSummarizer:
273
389
  confidence="medium",
274
390
  )
275
391
  )
392
+ elif path.endswith("Program.cs") or path.endswith("Program.fs"):
393
+ candidates.append(
394
+ EntryPoint(
395
+ path=path,
396
+ stack="dotnet",
397
+ kind="cli",
398
+ source="convention",
399
+ confidence="medium",
400
+ )
401
+ )
276
402
  return candidates
277
403
 
278
404
  def _fallback_priority(self, path: str) -> tuple[int, int, str]:
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ """Generates a compact, high-signal ContextSummary for AI agent consumption.
4
+
5
+ Uses all available analysis: stacks, architecture, graph analytics, entry points.
6
+ Fast path: O(1) from pre-computed data. Falls back to inline ArchitectureAnalyzer if needed.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from sourcecode.schema import ContextSummary, SourceMap
13
+
14
+ _STACK_LABELS: dict[str, str] = {
15
+ "python": "Python",
16
+ "nodejs": "Node.js/TypeScript",
17
+ "go": "Go",
18
+ "rust": "Rust",
19
+ "dotnet": "C#/.NET",
20
+ "java": "Java",
21
+ "kotlin": "Kotlin",
22
+ "scala": "Scala",
23
+ "ruby": "Ruby",
24
+ "php": "PHP",
25
+ "dart": "Dart/Flutter",
26
+ "elixir": "Elixir",
27
+ }
28
+
29
+ _TYPE_LABELS: dict[str, str] = {
30
+ "api": "REST API",
31
+ "webapp": "Web app",
32
+ "fullstack": "Full-stack app",
33
+ "cli": "CLI tool",
34
+ "worker": "Background worker / service",
35
+ "library": "Library / package",
36
+ "unknown": "Application",
37
+ }
38
+
39
+ _LAYER_HINTS: dict[str, str] = {
40
+ "domain": "Core domain logic and entities",
41
+ "application": "Application / use-case orchestration",
42
+ "infrastructure": "External integrations (DB, messaging, APIs)",
43
+ "controller": "HTTP handlers and route definitions",
44
+ "service": "Business logic services",
45
+ "repository": "Data access / persistence",
46
+ "commands": "CQRS write side (commands)",
47
+ "queries": "CQRS read side (queries)",
48
+ "ports": "Hexagonal ports (interfaces)",
49
+ "adapters": "Hexagonal adapters (implementations)",
50
+ "processing": "Data processing / analysis",
51
+ "orchestration": "Orchestration / coordination",
52
+ "data": "Data models / schemas",
53
+ "apps": "Application packages (monorepo apps)",
54
+ "packages": "Shared packages (monorepo libs)",
55
+ }
56
+
57
+
58
+ class ContextSummarizer:
59
+ """Generates ContextSummary from a fully-populated SourceMap."""
60
+
61
+ def __init__(self, root: Path) -> None:
62
+ self.root = root
63
+
64
+ def generate(self, sm: SourceMap) -> Optional[ContextSummary]:
65
+ try:
66
+ return self._build(sm)
67
+ except Exception:
68
+ return None
69
+
70
+ def _build(self, sm: SourceMap) -> ContextSummary:
71
+ arch = sm.architecture
72
+ if arch is None and sm.file_paths:
73
+ from sourcecode.architecture_analyzer import ArchitectureAnalyzer
74
+ arch = ArchitectureAnalyzer().analyze(self.root, sm)
75
+
76
+ return ContextSummary(
77
+ runtime_shape=self._infer_runtime_shape(sm),
78
+ dominant_pattern=self._dominant_pattern(arch),
79
+ critical_modules=self._collect_critical_modules(sm),
80
+ layer_map=self._build_layer_map(arch),
81
+ edit_hints=self._generate_edit_hints(arch, sm),
82
+ coupling_notes=self._coupling_notes(sm),
83
+ )
84
+
85
+ def _infer_runtime_shape(self, sm: SourceMap) -> str:
86
+ runtime = _TYPE_LABELS.get(sm.project_type or "", "Application")
87
+
88
+ # Gather framework names from all stacks (deduplicated, max 3)
89
+ fw_names: list[str] = []
90
+ seen: set[str] = set()
91
+ for stack in sm.stacks:
92
+ for fw in stack.frameworks:
93
+ if fw.name not in seen:
94
+ seen.add(fw.name)
95
+ fw_names.append(fw.name)
96
+ fw_names = fw_names[:3]
97
+
98
+ if fw_names:
99
+ return f"{runtime} — {', '.join(fw_names)}"
100
+
101
+ # Fallback: stack label
102
+ primary = next((s for s in sm.stacks if s.primary), sm.stacks[0] if sm.stacks else None)
103
+ if primary:
104
+ label = _STACK_LABELS.get(primary.stack, primary.stack)
105
+ return f"{runtime} — {label}"
106
+
107
+ return runtime
108
+
109
+ def _dominant_pattern(self, arch: Any) -> Optional[str]:
110
+ if arch is None:
111
+ return None
112
+ pattern = arch.pattern
113
+ if pattern in (None, "unknown", "flat"):
114
+ return None
115
+ return pattern
116
+
117
+ def _collect_critical_modules(self, sm: SourceMap) -> list[str]:
118
+ critical: list[str] = []
119
+ seen: set[str] = set()
120
+
121
+ for ep in sm.entry_points[:3]:
122
+ if ep.path and ep.path not in seen:
123
+ seen.add(ep.path)
124
+ critical.append(ep.path)
125
+
126
+ mgr = sm.module_graph_summary
127
+ if mgr:
128
+ for hub in mgr.hubs[:4]:
129
+ path = hub.removeprefix("module:")
130
+ if path and path not in seen:
131
+ seen.add(path)
132
+ critical.append(path)
133
+
134
+ return critical[:6]
135
+
136
+ def _build_layer_map(self, arch: Any) -> dict[str, list[str]]:
137
+ if arch is None or not arch.layers:
138
+ return {}
139
+ result: dict[str, list[str]] = {}
140
+ for layer in arch.layers[:6]:
141
+ dirs: set[str] = set()
142
+ for f in layer.files[:8]:
143
+ parts = f.replace("\\", "/").split("/")
144
+ if len(parts) >= 2:
145
+ dirs.add("/".join(parts[:-1]) + "/")
146
+ else:
147
+ dirs.add(f)
148
+ if dirs:
149
+ result[layer.name] = sorted(dirs)[:3]
150
+ return result
151
+
152
+ def _generate_edit_hints(self, arch: Any, sm: SourceMap) -> list[str]:
153
+ hints: list[str] = []
154
+ if arch is None or not arch.layers:
155
+ return hints
156
+
157
+ for layer in arch.layers[:5]:
158
+ hint_label = _LAYER_HINTS.get(layer.name, layer.name)
159
+ dirs: list[str] = []
160
+ seen_dirs: set[str] = set()
161
+ for f in layer.files[:6]:
162
+ parts = f.replace("\\", "/").split("/")
163
+ d = "/".join(parts[:-1]) + "/" if len(parts) >= 2 else f
164
+ if d not in seen_dirs:
165
+ seen_dirs.add(d)
166
+ dirs.append(d)
167
+ if dirs:
168
+ dir_str = ", ".join(dirs[:2])
169
+ hints.append(f"{hint_label} → {dir_str}")
170
+
171
+ return hints[:4]
172
+
173
+ def _coupling_notes(self, sm: SourceMap) -> list[str]:
174
+ notes: list[str] = []
175
+ mgr = sm.module_graph_summary
176
+ if mgr is None:
177
+ return notes
178
+
179
+ if mgr.cycle_count > 0:
180
+ s = "s" if mgr.cycle_count > 1 else ""
181
+ notes.append(f"{mgr.cycle_count} circular import cycle{s} detected")
182
+
183
+ if mgr.hubs:
184
+ names = [h.removeprefix("module:").split("/")[-1] for h in mgr.hubs[:3]]
185
+ notes.append(f"High-coupling hubs: {', '.join(names)}")
186
+
187
+ orphan_count = len(mgr.orphans)
188
+ if orphan_count >= 3:
189
+ notes.append(f"{orphan_count} orphan modules (unreferenced)")
190
+
191
+ return notes