sourcecode 0.15.1__py3-none-any.whl → 0.17.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.15.1"
3
+ __version__ = "0.17.0"
@@ -33,6 +33,37 @@ _CODE_EXTENSIONS = {
33
33
  }
34
34
  _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
35
35
 
36
+ _TEST_DIRS: frozenset[str] = frozenset({"tests", "test", "spec", "specs", "__tests__", "e2e"})
37
+
38
+ # Exact file stems that signal a specific architectural layer
39
+ _LAYER_STEM_EXACT: dict[str, str] = {
40
+ "cli": "orchestration",
41
+ "main": "orchestration",
42
+ "app": "orchestration",
43
+ "server": "orchestration",
44
+ "schema": "data",
45
+ "model": "data",
46
+ "models": "data",
47
+ "config": "data",
48
+ "settings": "data",
49
+ "store": "data",
50
+ }
51
+
52
+ # Suffix patterns (stem == suffix OR stem ends with "_" + suffix) → layer
53
+ _LAYER_STEM_SUFFIXES: list[tuple[str, str]] = [
54
+ ("analyzer", "processing"),
55
+ ("processor", "processing"),
56
+ ("parser", "processing"),
57
+ ("detector", "processing"),
58
+ ("scanner", "processing"),
59
+ ("handler", "orchestration"),
60
+ ("controller", "orchestration"),
61
+ ("repository", "data"),
62
+ ("repo", "data"),
63
+ ("serializer", "data"),
64
+ ("formatter", "data"),
65
+ ]
66
+
36
67
  DOMAIN_ROLES: dict[str, str] = {
37
68
  "controllers": "HTTP request handlers",
38
69
  "handlers": "HTTP request handlers",
@@ -120,8 +151,9 @@ class ArchitectureAnalyzer:
120
151
 
121
152
  # Overall confidence
122
153
  confidence: Literal["high", "medium", "low"]
123
- if len(domains) >= 3 and pattern not in (None, "unknown"):
124
- confidence = "high"
154
+ if pattern not in (None, "unknown", "flat"):
155
+ # A recognised pattern was detected — at least medium confidence
156
+ confidence = "high" if len(domains) >= 3 else "medium"
125
157
  elif len(domains) >= 1:
126
158
  confidence = "medium"
127
159
  else:
@@ -165,7 +197,13 @@ class ArchitectureAnalyzer:
165
197
  if len(parts) == 1:
166
198
  return "root"
167
199
  first = parts[0]
168
- if first in _SRC_TRANSPARENT and len(parts) >= 3:
200
+ if first in _SRC_TRANSPARENT:
201
+ if len(parts) == 2:
202
+ return "root"
203
+ if len(parts) >= 4:
204
+ # src/pkg/subpkg/file.py → subpkg is the meaningful module
205
+ return parts[2]
206
+ # src/pkg/file.py → pkg
169
207
  return parts[1]
170
208
  return first
171
209
 
@@ -173,6 +211,8 @@ class ArchitectureAnalyzer:
173
211
  groups: dict[str, list[str]] = {}
174
212
  for p in paths:
175
213
  seg = self._extract_domain_segment(p)
214
+ if seg.lower() in _TEST_DIRS:
215
+ continue # tests are infrastructure, not architecture domains
176
216
  groups.setdefault(seg, []).append(p)
177
217
 
178
218
  domains: list[ArchitectureDomain] = []
@@ -191,12 +231,21 @@ class ArchitectureAnalyzer:
191
231
  return domains
192
232
 
193
233
  def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
234
+ # Exclude test paths so test directories don't skew layer scoring
235
+ source_paths = [
236
+ p for p in paths
237
+ if not any(part.lower() in _TEST_DIRS for part in p.replace("\\", "/").split("/"))
238
+ ]
239
+ if not source_paths:
240
+ return "unknown", []
241
+
194
242
  dir_names: set[str] = set()
195
- for p in paths:
243
+ for p in source_paths:
196
244
  parts = p.replace("\\", "/").split("/")
197
245
  for part in parts[:-1]:
198
246
  dir_names.add(part.lower())
199
247
 
248
+ # 1. Classical keyword-based pattern matching (MVC, layered, hexagonal, fullstack)
200
249
  best_pattern = ""
201
250
  best_score = 0
202
251
  best_matched: dict[str, list[str]] = {}
@@ -213,34 +262,96 @@ class ArchitectureAnalyzer:
213
262
  best_pattern = pattern_name
214
263
  best_matched = matched
215
264
 
216
- if best_score < 2:
217
- # Check depth for flat vs unknown
218
- max_depth = max(
219
- len(p.replace("\\", "/").split("/")) - 1
220
- for p in paths
221
- )
222
- flat_pattern = "flat" if max_depth <= 2 else "unknown"
223
- return flat_pattern, []
224
-
225
- # Build ArchitectureLayer list
226
- layer_confidence: Literal["high", "medium", "low"] = "high" if best_score >= 3 else "medium"
227
- layers: list[ArchitectureLayer] = []
228
- for layer_key, matched_dirs in best_matched.items():
229
- matched_files = [
230
- p for p in paths
231
- if any(
232
- seg.lower() in matched_dirs
233
- for seg in p.replace("\\", "/").split("/")[:-1]
234
- )
265
+ if best_score >= 2:
266
+ layer_confidence: Literal["high", "medium", "low"] = "high" if best_score >= 3 else "medium"
267
+ layers: list[ArchitectureLayer] = []
268
+ for layer_key, matched_dirs in best_matched.items():
269
+ matched_files = [
270
+ p for p in source_paths
271
+ if any(
272
+ seg.lower() in matched_dirs
273
+ for seg in p.replace("\\", "/").split("/")[:-1]
274
+ )
275
+ ]
276
+ layers.append(ArchitectureLayer(
277
+ name=layer_key,
278
+ pattern=best_pattern,
279
+ files=matched_files,
280
+ confidence=layer_confidence,
281
+ ))
282
+ return best_pattern, layers
283
+
284
+ # 2. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
285
+ func_result = self._detect_layered_functional(source_paths)
286
+ if func_result is not None:
287
+ return func_result
288
+
289
+ # 3. Modular sub-package heuristic: ≥2 distinct named sub-packages
290
+ modular_result = self._detect_modular(source_paths)
291
+ if modular_result is not None:
292
+ return modular_result
293
+
294
+ # 4. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
295
+ max_depth = max(
296
+ (len(p.replace("\\", "/").split("/")) - 1 for p in source_paths),
297
+ default=0,
298
+ )
299
+ return ("flat" if max_depth <= 2 else "unknown"), []
300
+
301
+ def _detect_layered_functional(
302
+ self, paths: list[str]
303
+ ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
304
+ """Detect layered architecture from file-naming conventions.
305
+
306
+ Recognises three logical layers without requiring classical directory names:
307
+ - orchestration: cli.py, main.py, *_handler.py, *_controller.py
308
+ - processing: *_analyzer.py, *_processor.py, *_parser.py, *_detector.py, *_scanner.py
309
+ - data: schema.py, model.py, *_repository.py, *_serializer.py, config.py, …
310
+ """
311
+ layer_files: dict[str, list[str]] = {"orchestration": [], "processing": [], "data": []}
312
+ for p in paths:
313
+ stem = Path(p.replace("\\", "/").split("/")[-1]).stem.lower()
314
+ if stem in _LAYER_STEM_EXACT:
315
+ layer_files[_LAYER_STEM_EXACT[stem]].append(p)
316
+ continue
317
+ for suffix, layer in _LAYER_STEM_SUFFIXES:
318
+ if stem == suffix or stem.endswith("_" + suffix):
319
+ layer_files[layer].append(p)
320
+ break
321
+
322
+ non_empty = {k: v for k, v in layer_files.items() if v}
323
+ if len(non_empty) >= 2:
324
+ return "layered", [
325
+ ArchitectureLayer(name=k, pattern="layered", files=v, confidence="medium")
326
+ for k, v in non_empty.items()
235
327
  ]
236
- layers.append(ArchitectureLayer(
237
- name=layer_key,
238
- pattern=best_pattern,
239
- files=matched_files,
240
- confidence=layer_confidence,
241
- ))
328
+ return None
329
+
330
+ def _detect_modular(
331
+ self, paths: list[str]
332
+ ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
333
+ """Detect modular architecture from ≥2 distinct named sub-packages.
242
334
 
243
- return best_pattern or "unknown", layers
335
+ Each top-level (or first non-transparent) directory that is not a test or
336
+ generic name is treated as a self-contained module.
337
+ """
338
+ module_files: dict[str, list[str]] = {}
339
+ for p in paths:
340
+ parts = p.replace("\\", "/").split("/")
341
+ for part in parts[:-1]:
342
+ if (part not in _SRC_TRANSPARENT
343
+ and part.lower() not in _TEST_DIRS
344
+ and part.lower() not in _GENERIC_NAMES):
345
+ module_files.setdefault(part, []).append(p)
346
+ break
347
+
348
+ meaningful = {k: v for k, v in module_files.items() if len(v) >= 2}
349
+ if len(meaningful) >= 2:
350
+ return "modular", [
351
+ ArchitectureLayer(name=k, pattern="modular", files=v, confidence="medium")
352
+ for k, v in meaningful.items()
353
+ ]
354
+ return None
244
355
 
245
356
  def _infer_bounded_contexts(
246
357
  self,
@@ -12,6 +12,7 @@ _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
12
12
  _PYTHON_EXTENSIONS = {".py"}
13
13
  _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
14
14
  _GO_EXTENSIONS = {".go"}
15
+ _JAVA_EXTENSIONS = {".java", ".kt", ".scala"}
15
16
 
16
17
 
17
18
  class ArchitectureSummarizer:
@@ -58,6 +59,8 @@ class ArchitectureSummarizer:
58
59
  lines.extend(self._summarize_node_entry(entry_point.path, content))
59
60
  elif suffix in _GO_EXTENSIONS:
60
61
  lines.extend(self._summarize_go_entry(entry_point.path, content))
62
+ elif suffix in _JAVA_EXTENSIONS:
63
+ lines.extend(self._summarize_java_entry(entry_point.path, content, sm.stacks))
61
64
  else:
62
65
  lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
63
66
 
@@ -122,6 +125,21 @@ class ArchitectureSummarizer:
122
125
  lines.append("Produce la salida principal del entry point JavaScript/TypeScript detectado.")
123
126
  return lines
124
127
 
128
+ def _summarize_java_entry(self, path: str, content: str, stacks: list[StackDetection]) -> list[str]:
129
+ lines: list[str] = []
130
+ frameworks = [f.name for stack in stacks for f in stack.frameworks]
131
+ if frameworks:
132
+ lines.append(f"Frameworks detectados: {', '.join(frameworks)}.")
133
+ annotations = re.findall(r"@(SpringBootApplication|QuarkusMain|MicronautApplication|Application)\b", content)
134
+ if annotations:
135
+ lines.append(f"Anotacion de arranque: @{annotations[0]}.")
136
+ # Detect Spring Boot profile hints
137
+ if "@SpringBootApplication" in content:
138
+ lines.append("Arranca el contexto de Spring con auto-configuracion y component scan.")
139
+ elif not lines:
140
+ lines.append("Orquesta el arranque de la aplicacion JVM.")
141
+ return lines
142
+
125
143
  def _summarize_go_entry(self, path: str, content: str) -> list[str]:
126
144
  imports = re.findall(r'"([^"]+)"', content)
127
145
  internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
@@ -196,6 +214,16 @@ class ArchitectureSummarizer:
196
214
  confidence="medium",
197
215
  )
198
216
  )
217
+ elif path.endswith("Application.java") or path.endswith("Main.java"):
218
+ candidates.append(
219
+ EntryPoint(
220
+ path=path,
221
+ stack="java",
222
+ kind="application",
223
+ source="convention",
224
+ confidence="medium",
225
+ )
226
+ )
199
227
  return candidates
200
228
 
201
229
  def _fallback_priority(self, path: str) -> tuple[int, int, str]:
sourcecode/cli.py CHANGED
@@ -202,12 +202,26 @@ def main(
202
202
  SourceMap,
203
203
  StackDetection,
204
204
  )
205
- from sourcecode.serializer import compact_view, write_output
205
+ from sourcecode.serializer import compact_view, normalize_source_map, validate_cross_analyzer_consistency, validate_source_map, write_output
206
206
  from sourcecode.workspace import WorkspaceAnalyzer
207
207
 
208
208
  # 1. Escanear el directorio (SCAN-01 a SCAN-05)
209
209
  redactor = SecretRedactor(enabled=not no_redact)
210
- scanner = FileScanner(target, max_depth=depth)
210
+
211
+ # Detectar manifests antes del scan para ajustar depth.
212
+ # find_manifests() solo mira profundidad 0-1, no necesita el arbol.
213
+ _pre_scanner = FileScanner(target, max_depth=1)
214
+ manifests = _pre_scanner.find_manifests()
215
+
216
+ # Maven usa src/main/java/<groupId>/<artifactId>/<module>/ (profundidad 7+).
217
+ # Con depth=4 los ficheros .java son invisibles y todos los analizadores fallan.
218
+ # Necesitamos al menos 8: src(1)+main(2)+java(3)+com(4)+co(5)+app(6)+module(7)+file.
219
+ _java_manifest_names = {"pom.xml", "build.gradle", "build.gradle.kts"}
220
+ _is_java = any(Path(m).name in _java_manifest_names for m in manifests)
221
+ _java_min_depth = 8
222
+ effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
223
+
224
+ scanner = FileScanner(target, max_depth=effective_depth)
211
225
  raw_tree = scanner.scan_tree()
212
226
 
213
227
  # 2. Filtrar del arbol las entradas de .env y *.secret (SEC-02, todos los niveles)
@@ -244,7 +258,6 @@ def main(
244
258
  return pruned
245
259
 
246
260
  file_tree = filter_sensitive_files(raw_tree)
247
- manifests = scanner.find_manifests()
248
261
  detector = ProjectDetector(build_default_detectors())
249
262
  workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
250
263
  dependency_analyzer = DependencyAnalyzer() if dependencies else None
@@ -557,6 +570,20 @@ def main(
557
570
  cn_notes, cn_adrs, cn_summary = CodeNotesAnalyzer().analyze(target)
558
571
  sm = replace(sm, code_notes=cn_notes, code_adrs=cn_adrs, code_notes_summary=cn_summary)
559
572
 
573
+ # Normalize optional analyzer outputs → validate schema contracts.
574
+ # normalize_source_map fills None fields with typed empty defaults so that
575
+ # consumers never need to null-check architecture or module_graph.
576
+ # validate_source_map then asserts the contracts hold; it raises here
577
+ # (pre-serialization) rather than silently producing invalid JSON.
578
+ sm = normalize_source_map(sm)
579
+ validate_source_map(sm)
580
+
581
+ # Cross-analyzer semantic consistency (non-blocking: warnings to stderr).
582
+ # strict=False so a mismatched dependency or orphan semantic link never
583
+ # aborts a run — findings are informational until the team decides to harden.
584
+ for _finding in validate_cross_analyzer_consistency(sm, strict=False):
585
+ typer.echo(f"[consistency] {_finding}", err=True)
586
+
560
587
  # 4. Serializar (con o sin modo compact)
561
588
  if compact:
562
589
  data = compact_view(sm)
@@ -5,6 +5,11 @@ from collections import Counter
5
5
  from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
+ # Schema imports are hoisted here (not lazily inside _scan_source_file) so that
9
+ # a stale .pyc / editable-install mismatch is caught at import time rather than
10
+ # silently dropping notes mid-scan on the first failed late-import.
11
+ from sourcecode.schema import AdrRecord, CodeNote, CodeNotesSummary
12
+
8
13
  _MAX_NOTES = 500
9
14
  _MAX_NOTES_PER_FILE = 30
10
15
  _MAX_ADRS = 50
@@ -103,7 +108,6 @@ def _scan_source_file(
103
108
  line_num = content.count("\n", 0, m.start()) + 1
104
109
  symbol = _find_nearby_symbol(lines, line_num - 1)
105
110
 
106
- from sourcecode.schema import CodeNote
107
111
  notes.append(CodeNote(
108
112
  kind=kind,
109
113
  path=rel_path,
@@ -121,8 +125,6 @@ def _parse_adr(path: Path, rel_path: str) -> Optional[object]:
121
125
  except OSError:
122
126
  return None
123
127
 
124
- from sourcecode.schema import AdrRecord
125
-
126
128
  title: Optional[str] = None
127
129
  status: Optional[str] = None
128
130
  summary_lines: list[str] = []
@@ -167,15 +169,13 @@ def _parse_adr(path: Path, rel_path: str) -> Optional[object]:
167
169
  if not title:
168
170
  title = path.stem
169
171
 
170
- return AdrRecord(path=rel_path, title=title, status=status, summary=summary)
172
+ return AdrRecord(path=rel_path, title=title, status=status, summary=summary) # noqa: RET504
171
173
 
172
174
 
173
175
  class CodeNotesAnalyzer:
174
176
  """Extrae notas de código (TODO/FIXME/HACK/etc.) y ADRs del proyecto."""
175
177
 
176
178
  def analyze(self, root: Path) -> tuple[list, list, object]:
177
- from sourcecode.schema import CodeNotesSummary
178
-
179
179
  notes: list = []
180
180
  adrs: list = []
181
181
  limitations: list[str] = []
@@ -186,9 +186,19 @@ class CodeNotesAnalyzer:
186
186
  if total_count[0] >= _MAX_NOTES:
187
187
  limitations.append(f"notes_truncated_at:{_MAX_NOTES}")
188
188
 
189
+ # Explicit canonical sort guarantees identical output for identical input
190
+ # regardless of filesystem traversal order or OS/platform differences.
191
+ # Without this, output order depended on sorted(Path.iterdir()) which
192
+ # varies across Python versions, APFS vs ext4, and future walk refactors.
193
+ notes.sort(key=lambda n: (n.path, n.line))
194
+ adrs.sort(key=lambda a: a.path)
195
+
189
196
  kind_counts: Counter = Counter(n.kind for n in notes)
190
197
  file_counts: Counter = Counter(n.path for n in notes)
191
- top_files = [f for f, _ in file_counts.most_common(5)]
198
+ # Secondary sort by path makes top_files stable when counts are tied.
199
+ top_files = [
200
+ f for f, _ in sorted(file_counts.items(), key=lambda x: (-x[1], x[0]))[:5]
201
+ ]
192
202
 
193
203
  summary = CodeNotesSummary(
194
204
  requested=True,
@@ -211,7 +221,10 @@ class CodeNotesAnalyzer:
211
221
  ) -> None:
212
222
  try:
213
223
  entries = sorted(current.iterdir())
214
- except PermissionError:
224
+ except OSError:
225
+ # Catches PermissionError, EMFILE (too many open files), and other
226
+ # OS-level errors that previously aborted the entire walk mid-scan,
227
+ # returning an empty or partial list depending on when the error hit.
215
228
  return
216
229
 
217
230
  for entry in entries:
@@ -237,5 +250,8 @@ class CodeNotesAnalyzer:
237
250
  adrs.append(adr)
238
251
  continue
239
252
 
240
- if suffix in _CODE_EXTENSIONS and total_count[0] < _MAX_NOTES:
253
+ # The quota check lives inside _scan_source_file; the pre-check
254
+ # here was redundant and caused files to be silently skipped when
255
+ # traversal order varied (different files filled the quota first).
256
+ if suffix in _CODE_EXTENSIONS:
241
257
  _scan_source_file(entry, rel, notes, total_count)
@@ -33,6 +33,7 @@ class DependencyAnalyzer:
33
33
  self._analyze_rust,
34
34
  self._analyze_go,
35
35
  self._analyze_dotnet,
36
+ self._analyze_java,
36
37
  ):
37
38
  handler_records, handler_limitations = handler(root)
38
39
  records.extend(replace(record, workspace=workspace) for record in handler_records)
@@ -920,6 +921,50 @@ class DependencyAnalyzer:
920
921
  result.append(resolved)
921
922
  return self._dedupe(result)
922
923
 
924
+ def _analyze_java(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
925
+ pom = root / "pom.xml"
926
+ if not pom.exists():
927
+ return [], []
928
+ try:
929
+ tree = ET.parse(pom)
930
+ except (ET.ParseError, OSError):
931
+ return [], ["java: error al parsear pom.xml"]
932
+
933
+ root_elem = tree.getroot()
934
+ ns_match = re.match(r"\{[^}]+\}", root_elem.tag)
935
+ ns = ns_match.group(0) if ns_match else ""
936
+
937
+ records: list[DependencyRecord] = []
938
+ deps_elem = root_elem.find(f"{ns}dependencies")
939
+ if deps_elem is None:
940
+ return [], ["java: pom.xml sin bloque <dependencies>"]
941
+
942
+ for dep in deps_elem.findall(f"{ns}dependency"):
943
+ group_id = (dep.findtext(f"{ns}groupId") or "").strip()
944
+ artifact_id = (dep.findtext(f"{ns}artifactId") or "").strip()
945
+ if not group_id or not artifact_id:
946
+ continue
947
+ version_raw = (dep.findtext(f"{ns}version") or "").strip() or None
948
+ # Versiones interpoladas con propiedades Maven (${...}) no son resolvibles estáticamente
949
+ declared = version_raw if version_raw and not version_raw.startswith("${") else None
950
+ scope_text = (dep.findtext(f"{ns}scope") or "compile").strip().lower()
951
+ scope = "dev" if scope_text == "test" else "direct"
952
+ records.append(
953
+ DependencyRecord(
954
+ name=f"{group_id}:{artifact_id}",
955
+ ecosystem="java",
956
+ scope=scope,
957
+ declared_version=declared,
958
+ source="manifest",
959
+ manifest_path="pom.xml",
960
+ )
961
+ )
962
+
963
+ limitations: list[str] = []
964
+ if not records:
965
+ limitations.append("java: pom.xml sin dependencias parseables (puede usar BOM o propiedades)")
966
+ return records, limitations
967
+
923
968
  def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
924
969
  if not path.exists():
925
970
  return None
sourcecode/scanner.py CHANGED
@@ -128,6 +128,10 @@ class FileScanner:
128
128
 
129
129
  # Agregar ficheros al nodo (null = fichero segun D-02)
130
130
  for fname in filenames:
131
+ # Skip flag-shaped names (e.g. "-o", "--format") — shell redirect artifacts.
132
+ # No legitimate source file starts with "-".
133
+ if fname.startswith("-"):
134
+ continue
131
135
  fpath = current / fname
132
136
  # SCAN-03: no incluir symlinks de fichero
133
137
  if fpath.is_symlink():
sourcecode/serializer.py CHANGED
@@ -10,12 +10,17 @@ Patrones criticos:
10
10
 
11
11
  import json
12
12
  import sys
13
- from dataclasses import asdict, is_dataclass
13
+ from dataclasses import asdict, dataclass, is_dataclass, replace
14
14
  from io import StringIO
15
15
  from pathlib import Path
16
16
  from typing import Any, Optional
17
17
 
18
- from sourcecode.schema import SourceMap
18
+ from sourcecode.schema import (
19
+ ArchitectureAnalysis,
20
+ ModuleGraph,
21
+ ModuleGraphSummary,
22
+ SourceMap,
23
+ )
19
24
 
20
25
 
21
26
  def to_json(sm: SourceMap | dict[str, Any], indent: int = 2) -> str:
@@ -92,6 +97,246 @@ def compact_view(sm: SourceMap) -> dict[str, Any]:
92
97
  }
93
98
 
94
99
 
100
+ def normalize_source_map(sm: SourceMap) -> SourceMap:
101
+ """Fill in typed empty defaults for optional analyzer fields.
102
+
103
+ Fields controlled by flags (--architecture, --graph-modules) are None when
104
+ the flag is absent. Downstream consumers and tests then need null-checks
105
+ everywhere. This layer converts None → a well-typed default so the output
106
+ schema is always structurally complete.
107
+
108
+ The ``requested=False`` sentinel on each default tells consumers the
109
+ analysis was not requested, without forcing them to branch on None.
110
+ """
111
+ changes: dict[str, Any] = {}
112
+
113
+ # architecture: always an ArchitectureAnalysis, never None
114
+ if sm.architecture is None:
115
+ changes["architecture"] = ArchitectureAnalysis(requested=False)
116
+
117
+ # module_graph: always a ModuleGraph (possibly empty), never None.
118
+ # module_graph_summary is kept in sync as a convenience field.
119
+ if sm.module_graph is None:
120
+ empty_graph = ModuleGraph(summary=ModuleGraphSummary(requested=False))
121
+ changes["module_graph"] = empty_graph
122
+ if sm.module_graph_summary is None:
123
+ changes["module_graph_summary"] = empty_graph.summary
124
+ elif sm.module_graph_summary is None:
125
+ # graph exists but summary was never set — sync it
126
+ changes["module_graph_summary"] = sm.module_graph.summary
127
+
128
+ # dependencies is already list[DependencyRecord] by default_factory, but
129
+ # guard against any future refactor that could accidentally set it to None
130
+ if sm.dependencies is None: # type: ignore[comparison-overlap]
131
+ changes["dependencies"] = []
132
+
133
+ return replace(sm, **changes) if changes else sm
134
+
135
+
136
+ def validate_source_map(sm: SourceMap) -> None:
137
+ """Assert structural schema contracts on a (already normalised) SourceMap.
138
+
139
+ Call this *after* normalize_source_map() so that the checks below catch
140
+ bugs in the normaliser itself or in code that bypasses it.
141
+
142
+ Raises:
143
+ ValueError: listing every violated contract, never just the first.
144
+ """
145
+ errors: list[str] = []
146
+
147
+ # --- architecture ---
148
+ if sm.architecture is None:
149
+ errors.append("architecture must not be null (call normalize_source_map first)")
150
+ else:
151
+ if not isinstance(sm.architecture.domains, list):
152
+ errors.append(
153
+ f"architecture.domains must be list, got {type(sm.architecture.domains).__name__}"
154
+ )
155
+ if sm.architecture.confidence not in ("high", "medium", "low"):
156
+ errors.append(
157
+ f"architecture.confidence must be high|medium|low, "
158
+ f"got {sm.architecture.confidence!r}"
159
+ )
160
+
161
+ # --- module_graph ---
162
+ if sm.module_graph is None:
163
+ errors.append("module_graph must not be null (call normalize_source_map first)")
164
+ else:
165
+ if not isinstance(sm.module_graph.nodes, list):
166
+ errors.append(
167
+ f"module_graph.nodes must be list, got {type(sm.module_graph.nodes).__name__}"
168
+ )
169
+ if not isinstance(sm.module_graph.edges, list):
170
+ errors.append(
171
+ f"module_graph.edges must be list, got {type(sm.module_graph.edges).__name__}"
172
+ )
173
+
174
+ # --- dependencies ---
175
+ if not isinstance(sm.dependencies, list):
176
+ errors.append(
177
+ f"dependencies must be list, got {type(sm.dependencies).__name__}"
178
+ )
179
+
180
+ if errors:
181
+ bullet = "\n - "
182
+ raise ValueError(
183
+ f"SourceMap schema violations ({len(errors)}):{bullet}"
184
+ + bullet.join(errors)
185
+ )
186
+
187
+
188
+ _GRAPH_CODE_EXTENSIONS: frozenset[str] = frozenset({
189
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
190
+ ".go", ".rs", ".java", ".kt", ".rb",
191
+ })
192
+
193
+
194
+ def _rule_dependency_graph(
195
+ sm: SourceMap,
196
+ known_paths: set[str],
197
+ findings: list[str],
198
+ ) -> None:
199
+ """Rule 1 — dependency/graph consistency.
200
+
201
+ Sub-rule 1a: every GraphNode whose path ends with a code extension must
202
+ exist in file_tree. A mismatch means the graph references phantom files
203
+ the scanner never found.
204
+
205
+ Sub-rule 1b: if the graph has *external*-looking edge targets (package
206
+ names without path separators), every non-transitive dependency should
207
+ appear in those targets. This rule is intentionally skipped when the
208
+ graph only tracks internal module-to-module edges, avoiding false
209
+ positives for projects whose graph_analyzer does not emit external imports.
210
+ """
211
+ if not sm.module_graph.summary.requested:
212
+ return
213
+
214
+ # 1a — graph node paths must be in file_tree
215
+ for node in sm.module_graph.nodes:
216
+ p = node.path
217
+ if Path(p).suffix.lower() in _GRAPH_CODE_EXTENSIONS and p not in known_paths:
218
+ findings.append(
219
+ f"[dependency_graph] graph node '{node.id}' path '{p}' "
220
+ f"not found in file_tree"
221
+ )
222
+
223
+ # 1b — dep names should appear in external-facing edge targets
224
+ if sm.dependency_summary is None or not sm.dependency_summary.requested:
225
+ return
226
+ if not sm.module_graph.edges:
227
+ return
228
+
229
+ # Only apply when the graph contains at least one package-like target
230
+ # (no path separator, no code extension) — signals external import tracking.
231
+ pkg_targets = {
232
+ e.target.lower()
233
+ for e in sm.module_graph.edges
234
+ if "/" not in e.target
235
+ and not Path(e.target).suffix.lower() in _GRAPH_CODE_EXTENSIONS
236
+ }
237
+ if not pkg_targets:
238
+ return # graph has only internal file edges; dep↔edge check not applicable
239
+
240
+ for dep in sm.dependencies:
241
+ if dep.scope == "transitive":
242
+ continue
243
+ name = dep.name.lower().replace("-", "_")
244
+ if not any(name in t.replace("-", "_") for t in pkg_targets):
245
+ sample = ", ".join(sorted(pkg_targets)[:3])
246
+ findings.append(
247
+ f"[dependency_graph] dependency '{dep.name}' declared in manifest "
248
+ f"but absent from module_graph external edges (visible: {sample})"
249
+ )
250
+
251
+
252
+ def _rule_semantic_file_tree(
253
+ sm: SourceMap,
254
+ known_paths: set[str],
255
+ findings: list[str],
256
+ ) -> None:
257
+ """Rule 2 — semantic_links paths must exist in file_tree.
258
+
259
+ Both the importer and the source (when not external) must be files the
260
+ scanner actually found. An orphan path means the semantic_analyzer
261
+ resolved a symbol to a file that does not belong to the project.
262
+ """
263
+ for link in sm.semantic_links:
264
+ if link.importer_path not in known_paths:
265
+ findings.append(
266
+ f"[semantic_file_tree] semantic_link importer "
267
+ f"'{link.importer_path}' not in file_tree"
268
+ )
269
+ if link.source_path is not None and not link.is_external:
270
+ if link.source_path not in known_paths:
271
+ findings.append(
272
+ f"[semantic_file_tree] semantic_link source "
273
+ f"'{link.source_path}' not in file_tree (is_external=False)"
274
+ )
275
+
276
+
277
+ def _rule_architecture_graph(
278
+ sm: SourceMap,
279
+ known_paths: set[str],
280
+ findings: list[str],
281
+ ) -> None:
282
+ """Rule 3 — architecture domain files must be a subset of file_tree.
283
+
284
+ The architecture_analyzer clusters files into domains. Every file it
285
+ assigns to a domain should be a file the scanner found. A mismatch
286
+ means the architecture_analyzer is referencing phantom paths, likely
287
+ from a stale file_paths list or a mis-configured root.
288
+ """
289
+ if not sm.architecture.requested:
290
+ return
291
+ for domain in sm.architecture.domains:
292
+ for path in domain.files:
293
+ if path not in known_paths:
294
+ findings.append(
295
+ f"[architecture_graph] domain '{domain.name}' "
296
+ f"references '{path}' not in file_tree"
297
+ )
298
+
299
+
300
+ def validate_cross_analyzer_consistency(
301
+ sm: SourceMap,
302
+ *,
303
+ strict: bool = False,
304
+ ) -> list[str]:
305
+ """Check semantic alignment across analyzer outputs.
306
+
307
+ Applies three rules (see helpers above):
308
+ Rule 1 — dependency/graph: graph node paths and external edge targets
309
+ must be consistent with declared dependencies and file_tree.
310
+ Rule 2 — semantic/file_tree: SymbolLink paths must exist in file_tree.
311
+ Rule 3 — architecture/graph: domain files must exist in file_tree.
312
+
313
+ Args:
314
+ sm: A SourceMap that has already been normalised and structurally
315
+ validated (call normalize_source_map + validate_source_map first).
316
+ strict: If True, raises ValueError listing all findings.
317
+ If False (default), returns the findings list so the caller
318
+ can log warnings without aborting the pipeline.
319
+
320
+ Returns:
321
+ List of human-readable finding strings (empty when all rules pass).
322
+ """
323
+ findings: list[str] = []
324
+ known = set(sm.file_paths)
325
+
326
+ _rule_dependency_graph(sm, known, findings)
327
+ _rule_semantic_file_tree(sm, known, findings)
328
+ _rule_architecture_graph(sm, known, findings)
329
+
330
+ if strict and findings:
331
+ bullet = "\n - "
332
+ raise ValueError(
333
+ f"Cross-analyzer consistency violations ({len(findings)}):{bullet}"
334
+ + bullet.join(findings)
335
+ )
336
+
337
+ return findings
338
+
339
+
95
340
  def write_output(content: str, output: Optional[Path]) -> None:
96
341
  """Escribe el contenido a stdout o a un fichero.
97
342
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.15.1
3
+ Version: 0.17.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
@@ -1,11 +1,11 @@
1
- sourcecode/__init__.py,sha256=n-wyn6TNs_1x2XIHKzIOJuR_1TOoKl4bOn-vGS_HZV0,100
2
- sourcecode/architecture_analyzer.py,sha256=tGOoOpgYnjLuaMg3wobjhs_wvTphYhGoJwQ88rlj-qY,12171
3
- sourcecode/architecture_summary.py,sha256=wq7y0RKm77_bt0n1qJaIivOQ_V0itllr5e6363df0XE,10304
1
+ sourcecode/__init__.py,sha256=kGV-7biOu1jEQzo70FiCy-DkYFbItVMEkqxy37z-zJ8,100
2
+ sourcecode/architecture_analyzer.py,sha256=zxgqeqn2FhIUDn06X-whjGKiXbrHFkRlzZcr2zKd0Gw,16874
3
+ sourcecode/architecture_summary.py,sha256=r9IciS9z-7h8xpiME7DKBLhLV36xzXtSzbZ1cRDWklo,11709
4
4
  sourcecode/classifier.py,sha256=fcp2wIq198wq3Rsh_bDmZIK_W5UI6FEL5oqm1RrQfpA,6846
5
- sourcecode/cli.py,sha256=TOyyOGfjVDhMS7zoOoU_NvpAdAdRQKNg3h1ucZlOIvk,23535
6
- sourcecode/code_notes_analyzer.py,sha256=RUGWBJPHwz-ZuN4p2zWyYFkfdsObqf-0WH3FaWuUyL0,8068
5
+ sourcecode/cli.py,sha256=_ihMuJjjwTr50M77kMafOVX0kY9hqrdvl8NUQ_h0cbc,25178
6
+ sourcecode/code_notes_analyzer.py,sha256=rRd8bFYV0krjlxxQV0wenwE9K7pVpUQSR7KvSvUQKw4,9226
7
7
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
8
- sourcecode/dependency_analyzer.py,sha256=Mv86FBto4KNiQBK5p9GZEN2lpe8FPJBeIJlrD9PRKDg,39004
8
+ sourcecode/dependency_analyzer.py,sha256=9BEAyLoBAS2R0twYkprJj_e0c83pzz9QPMRWKfinNn4,40948
9
9
  sourcecode/doc_analyzer.py,sha256=Ec3orx6vBKsh5cNM3-F4y2Got2KuKx8w3dErwtdtM-A,19891
10
10
  sourcecode/env_analyzer.py,sha256=_UTkAzHNxDgJVpSMviWcn9e2V5o-SAV_oF6xsLHDxfI,12206
11
11
  sourcecode/git_analyzer.py,sha256=S9PGt8RDasBQYQUsZh9-H_KWVlPvzwR4jgM7TKN9ZCk,7643
@@ -13,10 +13,10 @@ sourcecode/graph_analyzer.py,sha256=cxl7Xb88aGxIVOCsatZJAE1CtgXGzIbJfqytV-9kkFs,
13
13
  sourcecode/metrics_analyzer.py,sha256=4uh11v-Q0gdrN87BOxuFWUym3N3AOkOuy21K5N8peB8,20126
14
14
  sourcecode/prepare_context.py,sha256=X43dEPe2cb7uMwlpQwVJMe0rQIZ0juaJ7H3F37sPbNc,5371
15
15
  sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
16
- sourcecode/scanner.py,sha256=BA53UAxbNXsBYQI0SrZkj_HycItpPoHznt1nSugf874,6183
16
+ sourcecode/scanner.py,sha256=Ga4jDRAOhkcwlRmlE2ko6cE39qKy0aI4iE1lGBWmAlk,6410
17
17
  sourcecode/schema.py,sha256=93IrVRLAbzksVfFzr6_FhFSPpKJVwgVoTBcov-jTVo8,15669
18
18
  sourcecode/semantic_analyzer.py,sha256=SRQSGJzEi4ZsGvb7U3qvnRiKHabz4hvIzCqiG92x9K8,79349
19
- sourcecode/serializer.py,sha256=25OyUXKkvVR0tns-cCSuik4cdK7V-xqHSu0kJbC0tMU,3839
19
+ sourcecode/serializer.py,sha256=IMU4lmr0FTDSTocws47abpJL01HH1cBK2UMGSUv00yo,13404
20
20
  sourcecode/summarizer.py,sha256=2Yz5xJ1bhtJLlrUpKcd74wijypRRo9GJmGT8u971EW0,11572
21
21
  sourcecode/tree_utils.py,sha256=xla45Whq24j6U9kEgTztJXkMpmOEDieAulKxP3nl89s,935
22
22
  sourcecode/workspace.py,sha256=fQlVoNx8S-fSHpKoJ0JBvEHCFkxszH0KZVJed1i3TRk,6845
@@ -39,7 +39,7 @@ sourcecode/detectors/rust.py,sha256=V23NO6Y8NsrhrUlGC2feUEcNK63hf3gc1y3jI4tes1Q,
39
39
  sourcecode/detectors/systems.py,sha256=nYaKbGDFu0EOXFcd_1doWFT3tTUdkbxc2DjHUF5TcqQ,1627
40
40
  sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG65hZ4,1693
41
41
  sourcecode/detectors/tooling.py,sha256=hIvop80No22pqyGVJ32NKliSdjkHRePQkIRroqG01bY,1875
42
- sourcecode-0.15.1.dist-info/METADATA,sha256=uNHGPWoTdXnkpo2dmI0R_fLZJcABcHxQUY4rYGpRf0E,25900
43
- sourcecode-0.15.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
44
- sourcecode-0.15.1.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
45
- sourcecode-0.15.1.dist-info/RECORD,,
42
+ sourcecode-0.17.0.dist-info/METADATA,sha256=sbwLpqrcDu_SkhDmPOqsq5YIT4wPn8V00GF1DRRohoI,25900
43
+ sourcecode-0.17.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
44
+ sourcecode-0.17.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
45
+ sourcecode-0.17.0.dist-info/RECORD,,