sourcecode 0.15.1__tar.gz → 0.17.0__tar.gz

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.
Files changed (111) hide show
  1. {sourcecode-0.15.1 → sourcecode-0.17.0}/PKG-INFO +1 -1
  2. {sourcecode-0.15.1 → sourcecode-0.17.0}/docs/schema.md +2 -2
  3. {sourcecode-0.15.1 → sourcecode-0.17.0}/pyproject.toml +1 -1
  4. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/architecture_analyzer.py +141 -30
  6. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/architecture_summary.py +28 -0
  7. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/cli.py +30 -3
  8. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/code_notes_analyzer.py +25 -9
  9. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/dependency_analyzer.py +45 -0
  10. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/scanner.py +4 -0
  11. sourcecode-0.17.0/src/sourcecode/serializer.py +352 -0
  12. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_architecture_analyzer.py +67 -0
  13. sourcecode-0.17.0/tests/test_code_notes_analyzer.py +137 -0
  14. sourcecode-0.17.0/tests/test_cross_consistency.py +365 -0
  15. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_analyzer_node_python.py +2 -2
  16. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_dependencies.py +1 -1
  17. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_graph_modules.py +8 -2
  18. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_lqn.py +6 -3
  19. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_scanner.py +18 -0
  20. sourcecode-0.17.0/tests/test_schema_normalization.py +245 -0
  21. sourcecode-0.15.1/-o +0 -296
  22. sourcecode-0.15.1/src/sourcecode/serializer.py +0 -107
  23. {sourcecode-0.15.1 → sourcecode-0.17.0}/.gitignore +0 -0
  24. {sourcecode-0.15.1 → sourcecode-0.17.0}/.ruff.toml +0 -0
  25. {sourcecode-0.15.1 → sourcecode-0.17.0}/README.md +0 -0
  26. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/classifier.py +0 -0
  27. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/coverage_parser.py +0 -0
  28. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/__init__.py +0 -0
  29. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/base.py +0 -0
  30. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/dart.py +0 -0
  31. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/dotnet.py +0 -0
  32. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/elixir.py +0 -0
  33. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/go.py +0 -0
  34. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/heuristic.py +0 -0
  35. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/java.py +0 -0
  36. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  37. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/nodejs.py +0 -0
  38. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/parsers.py +0 -0
  39. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/php.py +0 -0
  40. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/project.py +0 -0
  41. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/python.py +0 -0
  42. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/ruby.py +0 -0
  43. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/rust.py +0 -0
  44. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/systems.py +0 -0
  45. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/terraform.py +0 -0
  46. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/tooling.py +0 -0
  47. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/doc_analyzer.py +0 -0
  48. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/env_analyzer.py +0 -0
  49. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/git_analyzer.py +0 -0
  50. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/graph_analyzer.py +0 -0
  51. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/metrics_analyzer.py +0 -0
  52. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/prepare_context.py +0 -0
  53. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/redactor.py +0 -0
  54. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/schema.py +0 -0
  55. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/semantic_analyzer.py +0 -0
  56. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/summarizer.py +0 -0
  57. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/tree_utils.py +0 -0
  58. {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/workspace.py +0 -0
  59. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/__init__.py +0 -0
  60. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/conftest.py +0 -0
  61. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/coverage.xml +0 -0
  62. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  63. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  64. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  65. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/go_service/go.mod +0 -0
  66. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/jacoco.xml +0 -0
  67. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/lcov.info +0 -0
  68. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  69. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/package.json +0 -0
  70. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  71. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  72. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  73. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  74. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  75. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  76. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_architecture_summary.py +0 -0
  77. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_classifier.py +0 -0
  78. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_cli.py +0 -0
  79. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_coverage_parser.py +0 -0
  80. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  81. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_schema.py +0 -0
  82. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_go_rust_java.py +0 -0
  83. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_nodejs.py +0 -0
  84. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_php_ruby_dart.py +0 -0
  85. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_python.py +0 -0
  86. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_universal_managed.py +0 -0
  87. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_universal_systems.py +0 -0
  88. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detectors_base.py +0 -0
  89. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  90. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_doc_analyzer_python.py +0 -0
  91. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  92. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_analyzer_python_node.py +0 -0
  93. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_schema.py +0 -0
  94. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration.py +0 -0
  95. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_detection.py +0 -0
  96. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_docs.py +0 -0
  97. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_metrics.py +0 -0
  98. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_multistack.py +0 -0
  99. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_semantics.py +0 -0
  100. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_universal.py +0 -0
  101. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_metrics_analyzer.py +0 -0
  102. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_packaging.py +0 -0
  103. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_real_projects.py +0 -0
  104. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_redactor.py +0 -0
  105. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_schema.py +0 -0
  106. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_analyzer_node.py +0 -0
  107. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_analyzer_python.py +0 -0
  108. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_import_resolution.py +0 -0
  109. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_schema.py +0 -0
  110. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_summarizer.py +0 -0
  111. {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_workspace_analyzer.py +0 -0
@@ -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
@@ -81,7 +81,7 @@ Campos:
81
81
  {
82
82
  "schema_version": "1.0",
83
83
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
84
- "sourcecode_version": "0.15.1",
84
+ "sourcecode_version": "0.17.0",
85
85
  "analyzed_path": "/abs/path/to/project"
86
86
  }
87
87
  ```
@@ -645,7 +645,7 @@ Ejemplo de salida para un monorepo con web Node.js y API Python con `--dependenc
645
645
  "metadata": {
646
646
  "schema_version": "1.0",
647
647
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
648
- "sourcecode_version": "0.15.1",
648
+ "sourcecode_version": "0.17.0",
649
649
  "analyzed_path": "/abs/path/to/project"
650
650
  },
651
651
  "file_tree": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "0.15.1"
7
+ version = "0.17.0"
8
8
  description = "Genera un mapa de contexto estructurado de proyectos de software para agentes IA"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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]:
@@ -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
@@ -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():