sourcecode 0.41.0__tar.gz → 0.42.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 (147) hide show
  1. {sourcecode-0.41.0 → sourcecode-0.42.0}/PKG-INFO +1 -1
  2. {sourcecode-0.41.0 → sourcecode-0.42.0}/pyproject.toml +1 -1
  3. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/__init__.py +1 -1
  4. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/architecture_analyzer.py +94 -8
  5. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/contract_pipeline.py +9 -6
  6. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/doc_analyzer.py +22 -0
  7. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/env_analyzer.py +110 -22
  8. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/git_analyzer.py +13 -2
  9. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/prepare_context.py +6 -2
  10. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/schema.py +29 -0
  11. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/semantic_analyzer.py +64 -0
  12. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/serializer.py +15 -3
  13. sourcecode-0.42.0/tests/test_block1_reliability.py +474 -0
  14. sourcecode-0.42.0/tests/test_block2_coverage.py +449 -0
  15. {sourcecode-0.41.0 → sourcecode-0.42.0}/.agents/skills/source-command-gsd-join-discord/SKILL.md +0 -0
  16. {sourcecode-0.41.0 → sourcecode-0.42.0}/.agents/skills/source-command-gsd-review-backlog/SKILL.md +0 -0
  17. {sourcecode-0.41.0 → sourcecode-0.42.0}/.agents/skills/source-command-gsd-workstreams/SKILL.md +0 -0
  18. {sourcecode-0.41.0 → sourcecode-0.42.0}/.gitignore +0 -0
  19. {sourcecode-0.41.0 → sourcecode-0.42.0}/.ruff.toml +0 -0
  20. {sourcecode-0.41.0 → sourcecode-0.42.0}/CONTRIBUTING.md +0 -0
  21. {sourcecode-0.41.0 → sourcecode-0.42.0}/LICENSE +0 -0
  22. {sourcecode-0.41.0 → sourcecode-0.42.0}/README.md +0 -0
  23. {sourcecode-0.41.0 → sourcecode-0.42.0}/SECURITY.md +0 -0
  24. {sourcecode-0.41.0 → sourcecode-0.42.0}/docs/privacy.md +0 -0
  25. {sourcecode-0.41.0 → sourcecode-0.42.0}/docs/schema.md +0 -0
  26. {sourcecode-0.41.0 → sourcecode-0.42.0}/raw +0 -0
  27. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/adaptive_scanner.py +0 -0
  28. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/architecture_summary.py +0 -0
  29. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/ast_extractor.py +0 -0
  30. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/classifier.py +0 -0
  31. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/cli.py +0 -0
  32. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  33. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/confidence_analyzer.py +0 -0
  34. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/context_summarizer.py +0 -0
  35. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/contract_model.py +0 -0
  36. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/coverage_parser.py +0 -0
  37. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/dependency_analyzer.py +0 -0
  38. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/__init__.py +0 -0
  39. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/base.py +0 -0
  40. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  41. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/dart.py +0 -0
  42. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/dotnet.py +0 -0
  43. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/elixir.py +0 -0
  44. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/go.py +0 -0
  45. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/heuristic.py +0 -0
  46. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/hybrid.py +0 -0
  47. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/java.py +0 -0
  48. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  49. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/nodejs.py +0 -0
  50. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/parsers.py +0 -0
  51. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/php.py +0 -0
  52. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/project.py +0 -0
  53. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/python.py +0 -0
  54. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/ruby.py +0 -0
  55. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/rust.py +0 -0
  56. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/systems.py +0 -0
  57. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/terraform.py +0 -0
  58. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/detectors/tooling.py +0 -0
  59. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  60. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/file_classifier.py +0 -0
  61. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/graph_analyzer.py +0 -0
  62. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/metrics_analyzer.py +0 -0
  63. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/ranking_engine.py +0 -0
  64. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/redactor.py +0 -0
  65. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/relevance_scorer.py +0 -0
  66. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/repo_classifier.py +0 -0
  67. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/runtime_classifier.py +0 -0
  68. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/scanner.py +0 -0
  69. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/summarizer.py +0 -0
  70. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/__init__.py +0 -0
  71. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/config.py +0 -0
  72. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/consent.py +0 -0
  73. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/events.py +0 -0
  74. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/filters.py +0 -0
  75. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/telemetry/transport.py +0 -0
  76. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/tree_utils.py +0 -0
  77. {sourcecode-0.41.0 → sourcecode-0.42.0}/src/sourcecode/workspace.py +0 -0
  78. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/__init__.py +0 -0
  79. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/conftest.py +0 -0
  80. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/coverage.xml +0 -0
  81. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  82. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  83. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  84. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/go_service/go.mod +0 -0
  85. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/jacoco.xml +0 -0
  86. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/lcov.info +0 -0
  87. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  88. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/nextjs_app/package.json +0 -0
  89. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  90. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  91. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  92. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  93. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  94. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  95. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_architecture_analyzer.py +0 -0
  96. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_architecture_summary.py +0 -0
  97. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_ast_extractor.py +0 -0
  98. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_classifier.py +0 -0
  99. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_cli.py +0 -0
  100. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_code_notes_analyzer.py +0 -0
  101. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_contract_pipeline.py +0 -0
  102. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_coverage_parser.py +0 -0
  103. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_cross_consistency.py +0 -0
  104. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_dependency_analyzer_node_python.py +0 -0
  105. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  106. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_dependency_schema.py +0 -0
  107. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_dotnet.py +0 -0
  108. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_go_rust_java.py +0 -0
  109. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_nodejs.py +0 -0
  110. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_php_ruby_dart.py +0 -0
  111. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_python.py +0 -0
  112. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_universal_managed.py +0 -0
  113. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detector_universal_systems.py +0 -0
  114. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_detectors_base.py +0 -0
  115. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  116. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_doc_analyzer_python.py +0 -0
  117. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  118. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_graph_analyzer_python_node.py +0 -0
  119. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_graph_schema.py +0 -0
  120. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_hybrid_inference.py +0 -0
  121. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration.py +0 -0
  122. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_dependencies.py +0 -0
  123. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_detection.py +0 -0
  124. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_docs.py +0 -0
  125. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_graph_modules.py +0 -0
  126. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_lqn.py +0 -0
  127. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_metrics.py +0 -0
  128. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_multistack.py +0 -0
  129. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_semantics.py +0 -0
  130. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_integration_universal.py +0 -0
  131. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_metrics_analyzer.py +0 -0
  132. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_packaging.py +0 -0
  133. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_phase1_improvements.py +0 -0
  134. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_pipeline_integrity.py +0 -0
  135. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_real_projects.py +0 -0
  136. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_redactor.py +0 -0
  137. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_scanner.py +0 -0
  138. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_schema.py +0 -0
  139. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_schema_normalization.py +0 -0
  140. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_semantic_analyzer_node.py +0 -0
  141. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_semantic_analyzer_python.py +0 -0
  142. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_semantic_import_resolution.py +0 -0
  143. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_semantic_schema.py +0 -0
  144. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_signal_hierarchy.py +0 -0
  145. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_summarizer.py +0 -0
  146. {sourcecode-0.41.0 → sourcecode-0.42.0}/tests/test_telemetry.py +0 -0
  147. {sourcecode-0.41.0 → sourcecode-0.42.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.41.0
3
+ Version: 0.42.0
4
4
  Summary: Deterministic codebase context for AI coding agents
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "0.41.0"
7
+ version = "0.42.0"
8
8
  description = "Deterministic codebase context for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "0.41.0"
3
+ __version__ = "0.42.0"
@@ -172,6 +172,7 @@ class ArchitectureAnalyzer:
172
172
  graph: Optional[ModuleGraph] = None,
173
173
  ) -> ArchitectureAnalysis:
174
174
  limitations: list[str] = []
175
+ evidence: list[dict] = []
175
176
 
176
177
  # Step 1: filter paths
177
178
  filtered = self._filter_paths(sm.file_paths)
@@ -180,6 +181,8 @@ class ArchitectureAnalyzer:
180
181
  requested=True,
181
182
  pattern="unknown",
182
183
  limitations=["Arquitectura no inferida: proyecto sin archivos de codigo suficientes"],
184
+ evidence=[{"type": "none", "paths": [], "reason": "insufficient source files", "confidence": "high"}],
185
+ tentative=False,
183
186
  )
184
187
 
185
188
  # Step 2: domain clustering
@@ -193,17 +196,32 @@ class ArchitectureAnalyzer:
193
196
  elif pattern == "unknown":
194
197
  limitations.append("Patron de capas no reconocido: estructura de directorios sin senales claras")
195
198
 
196
- # Step 3b: monorepo override — workspace config is hard evidence
197
- if self._has_workspace_config(sm.file_paths) and pattern not in (
199
+ # Step 3b: monorepo override — workspace config is hard evidence.
200
+ # Overrides all weak inferred patterns; only truly specialised patterns
201
+ # (cqrs, clean, onion, hexagonal) take precedence over workspace config.
202
+ has_workspace = self._has_workspace_config(sm.file_paths)
203
+ if has_workspace and pattern not in (
198
204
  "monorepo", "cqrs", "clean", "onion", "hexagonal"
199
205
  ):
200
206
  mono_layers = self._detect_monorepo_packages(filtered)
201
- if mono_layers or pattern in (None, "unknown", "flat", "modular", "layered"):
207
+ # Override whenever: monorepo packages detected, OR pattern is any weak/generic type.
208
+ # "fullstack", "layered", "mvc", "microservices", "modular", "flat", "unknown", None
209
+ # all yield to workspace config evidence.
210
+ _WEAK_PATTERNS = {None, "unknown", "flat", "modular", "layered",
211
+ "fullstack", "mvc", "microservices"}
212
+ if mono_layers or pattern in _WEAK_PATTERNS:
202
213
  pattern = "monorepo"
203
214
  layers = mono_layers
204
215
  limitations.append(
205
216
  "Workspace config detectado — arquitectura refleja topologia de paquetes"
206
217
  )
218
+ ws_files = [p for p in sm.file_paths if p.split("/")[-1] in _WORKSPACE_CONFIG_FILES]
219
+ evidence.append({
220
+ "type": "workspace_config",
221
+ "paths": ws_files[:4],
222
+ "reason": "Monorepo workspace config file(s) detected — hard evidence for monorepo topology",
223
+ "confidence": "high",
224
+ })
207
225
 
208
226
  # Step 4: bounded context inference
209
227
  bounded_contexts = self._infer_bounded_contexts(domains, graph)
@@ -212,25 +230,91 @@ class ArchitectureAnalyzer:
212
230
  confidence: Literal["high", "medium", "low"]
213
231
  strong_domains = [d for d in domains if d.confidence in ("high", "medium")]
214
232
  all_layers_weak = layers and all(l.confidence == "low" for l in layers)
233
+
234
+ method = "graph+structure" if graph is not None else "filesystem_inference"
235
+ # High-confidence evidence (workspace config) makes pattern non-tentative.
236
+ tentative = not any(e.get("confidence") == "high" for e in evidence)
237
+
238
+ # _hard_evidence: high-confidence evidence was already set (e.g. workspace_config).
239
+ # When True, tentative must stay False and confidence must stay at least "medium".
240
+ _hard_evidence = not tentative # tentative=False iff high-conf evidence present
241
+
215
242
  if pattern not in (None, "unknown", "flat"):
216
- if all_layers_weak:
243
+ if graph is not None:
244
+ # Import graph provided — structural validation available
245
+ confidence = "medium" if len(strong_domains) >= 3 else "low"
246
+ evidence.append({
247
+ "type": "import_graph",
248
+ "paths": [n.id for n in graph.nodes[:6]],
249
+ "reason": f"Module import graph with {len(graph.nodes)} nodes used for pattern validation",
250
+ "confidence": "medium",
251
+ })
252
+ elif all_layers_weak:
217
253
  # Layers came from file-naming heuristic only, not directory structure
218
254
  confidence = "low"
255
+ if not _hard_evidence:
256
+ tentative = True
219
257
  limitations.append(
220
258
  "Low confidence inference: pattern inferred from filenames only, without import graph confirmation"
221
259
  )
260
+ evidence.append({
261
+ "type": "filesystem_naming",
262
+ "paths": [l.files[0] for l in layers if l.files][:6],
263
+ "reason": (
264
+ f"Pattern '{pattern}' inferred from file stem naming conventions only "
265
+ "(e.g. *_controller.py, *_service.py). "
266
+ "No directory structure or import graph confirmation."
267
+ ),
268
+ "confidence": "low",
269
+ })
222
270
  else:
223
- confidence = "medium" if len(strong_domains) >= 3 else "low"
224
- if graph is None:
271
+ # Directory structure match (or monorepo/workspace override with no layers)
272
+ confidence = "medium" if (_hard_evidence or len(strong_domains) >= 3) else "low"
273
+ if confidence == "low" and not _hard_evidence:
274
+ tentative = True
275
+ if not _hard_evidence:
225
276
  limitations.append(
226
277
  "Pattern not confirmed by module import graph; run with --graph-modules for structural validation"
227
278
  )
279
+ if not _hard_evidence:
280
+ matched_dirs = sorted({
281
+ p.replace("\\", "/").split("/")[0]
282
+ for layer in layers for p in layer.files
283
+ })
284
+ evidence.append({
285
+ "type": "filesystem_naming",
286
+ "paths": matched_dirs[:8],
287
+ "reason": (
288
+ f"Pattern '{pattern}' inferred from directory names matching layer keywords. "
289
+ "Import graph not available — structural direction of dependencies unverified."
290
+ ),
291
+ "confidence": "low" if confidence == "low" else "medium",
292
+ })
228
293
  elif len(strong_domains) >= 1:
229
294
  confidence = "medium"
295
+ if not _hard_evidence:
296
+ tentative = True
297
+ evidence.append({
298
+ "type": "filesystem_naming",
299
+ "paths": [d.name for d in strong_domains[:6]],
300
+ "reason": "Domain clustering from directory names; no layer pattern confirmed",
301
+ "confidence": "low",
302
+ })
230
303
  else:
231
304
  confidence = "low"
232
-
233
- method = "graph+structure" if graph is not None else "filesystem_inference"
305
+ if not _hard_evidence:
306
+ tentative = True
307
+ if not evidence:
308
+ limitations.append(
309
+ "insufficient_evidence: no recognizable architectural signals found; "
310
+ "filesystem structure does not match known patterns"
311
+ )
312
+ evidence.append({
313
+ "type": "filesystem_naming",
314
+ "paths": filtered[:6],
315
+ "reason": "Only filesystem paths available; no pattern matched",
316
+ "confidence": "low",
317
+ })
234
318
 
235
319
  return ArchitectureAnalysis(
236
320
  requested=True,
@@ -241,6 +325,8 @@ class ArchitectureAnalyzer:
241
325
  confidence=confidence,
242
326
  method=method,
243
327
  limitations=limitations,
328
+ evidence=evidence,
329
+ tentative=tentative,
244
330
  )
245
331
 
246
332
  # ------------------------------------------------------------------
@@ -45,9 +45,10 @@ def _get_changed_files(root: Path) -> set[str]:
45
45
  ]:
46
46
  try:
47
47
  result = subprocess.run(
48
- cmd, cwd=root, capture_output=True, text=True, timeout=10
48
+ cmd, cwd=root, capture_output=True, text=True,
49
+ encoding="utf-8", errors="replace", timeout=10,
49
50
  )
50
- for line in result.stdout.splitlines():
51
+ for line in (result.stdout or "").splitlines():
51
52
  line = line.strip()
52
53
  if line:
53
54
  changed.add(line.replace("\\", "/"))
@@ -56,9 +57,10 @@ def _get_changed_files(root: Path) -> set[str]:
56
57
  try:
57
58
  result = subprocess.run(
58
59
  ["git", "status", "--porcelain"],
59
- cwd=root, capture_output=True, text=True, timeout=10
60
+ cwd=root, capture_output=True, text=True,
61
+ encoding="utf-8", errors="replace", timeout=10,
60
62
  )
61
- for line in result.stdout.splitlines():
63
+ for line in (result.stdout or "").splitlines():
62
64
  if len(line) > 3:
63
65
  changed.add(line[3:].strip().replace("\\", "/"))
64
66
  except Exception:
@@ -129,11 +131,12 @@ def _get_git_churn(root: Path, file_paths: list[str]) -> dict[str, int]:
129
131
  try:
130
132
  result = subprocess.run(
131
133
  ["git", "log", "--name-only", "--format=", "--since=90.days.ago"],
132
- cwd=root, capture_output=True, text=True, timeout=15,
134
+ cwd=root, capture_output=True, text=True,
135
+ encoding="utf-8", errors="replace", timeout=15,
133
136
  )
134
137
  path_set = set(file_paths)
135
138
  counter: Counter[str] = Counter()
136
- for line in result.stdout.splitlines():
139
+ for line in (result.stdout or "").splitlines():
137
140
  line = line.strip().replace("\\", "/")
138
141
  if line in path_set:
139
142
  counter[line] += 1
@@ -132,6 +132,8 @@ class DocAnalyzer:
132
132
  records: list[DocRecord] = []
133
133
  limitations: list[str] = list(limitations_pre)
134
134
  languages: set[str] = set()
135
+ # Track per-language support status for honest reporting
136
+ unsupported_langs: set[str] = set()
135
137
 
136
138
  for relative_path in file_paths:
137
139
  abs_path = root / relative_path
@@ -176,8 +178,18 @@ class DocAnalyzer:
176
178
  # Unsupported language — D-04: no emitir DocRecord, solo registrar limitation
177
179
  limitations.append(f"docs_unavailable:{norm_path}:language={lang}")
178
180
  languages.add(lang)
181
+ unsupported_langs.add(lang)
179
182
  # NO records.append() here
180
183
 
184
+ # Build language_coverage: explicit per-language support status
185
+ _SUPPORTED_LANGS = {"python", "javascript", "typescript"}
186
+ lang_coverage: dict[str, str] = {}
187
+ for lang in languages:
188
+ if lang in _SUPPORTED_LANGS:
189
+ lang_coverage[lang] = "supported"
190
+ else:
191
+ lang_coverage[lang] = "unsupported"
192
+
181
193
  # Build summary
182
194
  symbol_count = sum(1 for r in records if r.kind != "module")
183
195
  total_count = len(records)
@@ -192,6 +204,15 @@ class DocAnalyzer:
192
204
  "no docstrings or JSDoc comments found"
193
205
  )
194
206
 
207
+ # Warn explicitly when unsupported languages are present — agents must not
208
+ # assume full coverage when Java/Go/Rust files are in scope but not analyzed.
209
+ if unsupported_langs:
210
+ sorted_unsupported = sorted(unsupported_langs)
211
+ limitations.append(
212
+ f"docs_not_extracted: language(s) {sorted_unsupported} present but not supported; "
213
+ "only Python and JS/TS docstrings are extracted"
214
+ )
215
+
195
216
  summary = DocSummary(
196
217
  requested=True,
197
218
  total_count=total_count,
@@ -200,6 +221,7 @@ class DocAnalyzer:
200
221
  depth=depth,
201
222
  truncated=truncated,
202
223
  limitations=limitations,
224
+ language_coverage=lang_coverage,
203
225
  )
204
226
  return records, summary
205
227
 
@@ -27,9 +27,13 @@ _ENV_EXAMPLE_NAMES = {
27
27
 
28
28
  # Spring Boot application.properties / application.yml and their profile variants
29
29
  _SPRING_CONF_BASE = {"application.properties", "application.yml", "application.yaml"}
30
- _SPRING_CONF_PROFILE_RE = re.compile(r'^application-[a-z0-9_-]+\.(properties|ya?ml)$', re.IGNORECASE)
31
- # Matches ${ENV_VAR} or ${ENV_VAR:default} where ENV_VAR is UPPER_SNAKE_CASE
32
- _SPRING_ENV_REF_RE = re.compile(r'\$\{([A-Z][A-Z0-9_]*)(?::[^}]*)?\}')
30
+ _SPRING_CONF_PROFILE_RE = re.compile(r'^application-([a-z0-9_-]+)\.(properties|ya?ml)$', re.IGNORECASE)
31
+ # Matches ${ENV_VAR} or ${ENV_VAR:default} where ENV_VAR is UPPER_SNAKE_CASE.
32
+ # Group 1 = key, Group 2 = default (may be empty string, absent = no default).
33
+ _SPRING_ENV_VAR_RE = re.compile(r'\$\{([A-Z][A-Z0-9_]*)(?::([^}]*))?\}')
34
+ # Matches ${spring.dotted.key} or ${spring.dotted.key:default} — Spring property references.
35
+ # These are internal property cross-references, not OS env vars, but still config signals.
36
+ _SPRING_PROP_REF_RE = re.compile(r'\$\{([a-z][a-z0-9]*(?:\.[a-z][a-z0-9_-]*)*)(?::([^}]*))?\}')
33
37
 
34
38
  # Patterns where absence of the variable causes a hard runtime error (not just None/null).
35
39
  # py_environ_bracket → os.environ["KEY"] raises KeyError
@@ -140,9 +144,9 @@ def _infer_type_hint(key: str) -> str:
140
144
  def _scan_file(
141
145
  path: Path,
142
146
  rel_path: str,
143
- findings: dict[str, list[tuple[str, Optional[str], bool]]],
147
+ findings: dict[str, list[tuple[str, Optional[str], bool, Optional[str]]]],
144
148
  ) -> None:
145
- """Escanea un fichero y acumula hallazgos en findings[key] = [(file_ref, default, is_hard)]."""
149
+ """Escanea un fichero y acumula hallazgos en findings[key] = [(file_ref, default, is_hard, profile)]."""
146
150
  try:
147
151
  size = path.stat().st_size
148
152
  if size > _MAX_FILE_SIZE:
@@ -168,7 +172,7 @@ def _scan_file(
168
172
 
169
173
  line_num = content.count("\n", 0, m.start()) + 1
170
174
  file_ref = f"{rel_path}:{line_num}"
171
- findings[key].append((file_ref, default, is_hard))
175
+ findings[key].append((file_ref, default, is_hard, None))
172
176
 
173
177
 
174
178
  def _parse_env_example(
@@ -204,22 +208,66 @@ def _parse_env_example(
204
208
  return results
205
209
 
206
210
 
211
+ def _extract_spring_profile(filename: str) -> Optional[str]:
212
+ """Extract Spring profile from filename.
213
+
214
+ application.yml / application.properties → 'default'
215
+ application-m3dev.yml → 'm3dev'
216
+ """
217
+ name_lower = filename.lower()
218
+ if name_lower in _SPRING_CONF_BASE:
219
+ return "default"
220
+ m = _SPRING_CONF_PROFILE_RE.match(name_lower)
221
+ if m:
222
+ return m.group(1)
223
+ return None
224
+
225
+
207
226
  def _parse_spring_config(
208
227
  path: Path,
209
228
  rel_path: str,
210
229
  findings: dict,
211
- ) -> None:
212
- """Parse application.properties / application.yml looking for ${ENV_VAR} refs."""
230
+ profile: Optional[str] = None,
231
+ ) -> int:
232
+ """Parse application.properties / application.yml for ${ENV_VAR} refs.
233
+
234
+ Returns the total number of ${...} placeholders found (candidates).
235
+ Captures default values from ${VAR:default} syntax.
236
+ Marks vars without defaults as hard-required (Spring fails to start if missing).
237
+ """
213
238
  try:
214
239
  content = path.read_text(encoding="utf-8", errors="replace")
215
240
  except OSError:
216
- return
241
+ return 0
217
242
 
218
- for m in _SPRING_ENV_REF_RE.finditer(content):
243
+ candidates = 0
244
+
245
+ # 1. UPPER_SNAKE_CASE env var references: ${DB_HOST} or ${DB_HOST:localhost}
246
+ for m in _SPRING_ENV_VAR_RE.finditer(content):
247
+ key = m.group(1)
248
+ raw_default = m.group(2) # None if no colon, "" if colon with empty default
249
+ # A colon means a default was specified (even if empty string)
250
+ has_default = raw_default is not None
251
+ default: Optional[str] = raw_default if (raw_default and raw_default.strip()) else None
252
+ line_num = content.count("\n", 0, m.start()) + 1
253
+ # Hard required only when no default is provided
254
+ is_hard = not has_default
255
+ findings[key].append((f"{rel_path}:{line_num}", default, is_hard, profile))
256
+ candidates += 1
257
+
258
+ # 2. lowercase.dotted Spring property refs: ${spring.datasource.url:default}
259
+ # These are internal property cross-references; store with a special prefix so
260
+ # callers can distinguish them from OS env vars. We do NOT mark them hard-required
261
+ # because they reference Spring's own property resolution chain.
262
+ for m in _SPRING_PROP_REF_RE.finditer(content):
219
263
  key = m.group(1)
264
+ raw_default = m.group(2)
265
+ default = raw_default if (raw_default and raw_default.strip()) else None
220
266
  line_num = content.count("\n", 0, m.start()) + 1
221
- # Spring fails to start if a referenced env var has no default hard required
222
- findings[key].append((f"{rel_path}:{line_num}", None, True))
267
+ findings[key].append((f"{rel_path}:{line_num}", default, False, profile))
268
+ candidates += 1
269
+
270
+ return candidates
223
271
 
224
272
 
225
273
  class EnvAnalyzer:
@@ -232,13 +280,18 @@ class EnvAnalyzer:
232
280
  ) -> tuple[list, object]:
233
281
  from sourcecode.schema import EnvSummary, EnvVarRecord
234
282
 
235
- # findings[key] = list of (file_ref, default_or_None, is_hard_required)
236
- findings: dict[str, list[tuple[str, Optional[str], bool]]] = defaultdict(list)
283
+ # findings[key] = list of (file_ref, default_or_None, is_hard_required, profile_or_None)
284
+ findings: dict[str, list[tuple[str, Optional[str], bool, Optional[str]]]] = defaultdict(list)
237
285
  example_entries: list[tuple[str, Optional[str], Optional[str]]] = []
238
286
  example_files_found: list[str] = []
239
287
  limitations: list[str] = []
288
+ profiles_scanned: list[str] = []
289
+ spring_candidates: int = 0
240
290
 
241
- self._walk(root, root, findings, example_entries, example_files_found, limitations)
291
+ spring_candidates = self._walk(
292
+ root, root, findings, example_entries, example_files_found,
293
+ limitations, profiles_scanned,
294
+ )
242
295
 
243
296
  # Merge findings into EnvVarRecord per key
244
297
  records: dict[str, EnvVarRecord] = {}
@@ -248,19 +301,23 @@ class EnvAnalyzer:
248
301
  if len(records) >= _MAX_KEYS:
249
302
  limitations.append(f"key_limit_reached:{_MAX_KEYS}")
250
303
  break
251
- defaults = [d for _, d, _ in refs if d is not None]
304
+ defaults = [d for _, d, _, _ in refs if d is not None]
252
305
  # required only when access pattern causes a hard runtime error if missing:
253
306
  # os.environ["KEY"] (KeyError) or Spring @Value/${KEY} without default.
254
307
  # os.getenv("KEY") / os.environ.get("KEY") return None — not hard required.
255
- has_hard_access = any(is_hard for _, _, is_hard in refs)
308
+ has_hard_access = any(is_hard for _, _, is_hard, _ in refs)
256
309
  required = has_hard_access and not defaults
257
310
  default_val = defaults[0] if defaults else None
258
311
  unique_files: list[str] = []
259
312
  seen: set[str] = set()
260
- for file_ref, _, _ in refs:
313
+ # Collect first profile seen for this key (from Spring config files)
314
+ first_profile: Optional[str] = None
315
+ for file_ref, _, _, prof in refs:
261
316
  if file_ref not in seen:
262
317
  seen.add(file_ref)
263
318
  unique_files.append(file_ref)
319
+ if first_profile is None and prof is not None:
320
+ first_profile = prof
264
321
  if len(unique_files) >= _MAX_FILES_PER_KEY:
265
322
  break
266
323
  records[key] = EnvVarRecord(
@@ -270,6 +327,7 @@ class EnvAnalyzer:
270
327
  type_hint=_infer_type_hint(key),
271
328
  category=_infer_category(key),
272
329
  files=unique_files,
330
+ profile=first_profile,
273
331
  )
274
332
 
275
333
  # 2. Supplement with .env.example entries (fill description + add missing keys)
@@ -300,6 +358,20 @@ class EnvAnalyzer:
300
358
  # Build summary
301
359
  categories = sorted({r.category for r in sorted_records if r.category})
302
360
  required_count = sum(1 for r in sorted_records if r.required)
361
+
362
+ # Coverage note: warn if Spring config was scanned but coverage seems partial
363
+ coverage_note: Optional[str] = None
364
+ if profiles_scanned and spring_candidates > 0:
365
+ spring_key_count = sum(
366
+ 1 for r in sorted_records if r.profile is not None
367
+ )
368
+ if spring_key_count < spring_candidates:
369
+ coverage_note = (
370
+ f"{spring_candidates} Spring ${{VAR}} placeholder(s) found across "
371
+ f"{len(profiles_scanned)} profile(s); {spring_key_count} unique key(s) "
372
+ "extracted. Duplicates across profiles collapsed."
373
+ )
374
+
303
375
  summary = EnvSummary(
304
376
  requested=True,
305
377
  total=len(sorted_records),
@@ -308,6 +380,9 @@ class EnvAnalyzer:
308
380
  categories=categories,
309
381
  example_files_found=example_files_found,
310
382
  limitations=limitations,
383
+ profiles_scanned=sorted(set(profiles_scanned)),
384
+ spring_candidates=spring_candidates,
385
+ coverage_note=coverage_note,
311
386
  )
312
387
 
313
388
  return sorted_records, summary
@@ -320,11 +395,15 @@ class EnvAnalyzer:
320
395
  example_entries: list,
321
396
  example_files_found: list,
322
397
  limitations: list,
323
- ) -> None:
398
+ profiles_scanned: list,
399
+ ) -> int:
400
+ """Walk the directory tree accumulating env var findings. Returns spring_candidates count."""
324
401
  try:
325
402
  entries = sorted(current.iterdir())
326
403
  except PermissionError:
327
- return
404
+ return 0
405
+
406
+ total_spring_candidates = 0
328
407
 
329
408
  for entry in entries:
330
409
  name = entry.name
@@ -333,7 +412,10 @@ class EnvAnalyzer:
333
412
  if entry.is_dir():
334
413
  if name in _SKIP_DIRS:
335
414
  continue
336
- self._walk(root, entry, findings, example_entries, example_files_found, limitations)
415
+ total_spring_candidates += self._walk(
416
+ root, entry, findings, example_entries, example_files_found,
417
+ limitations, profiles_scanned,
418
+ )
337
419
  elif entry.is_file():
338
420
  rel = entry.relative_to(root).as_posix()
339
421
  name_lower = name.lower()
@@ -344,13 +426,19 @@ class EnvAnalyzer:
344
426
  continue
345
427
  # Spring Boot application.properties / application.yml (incl. profiles)
346
428
  if name_lower in _SPRING_CONF_BASE or _SPRING_CONF_PROFILE_RE.match(name_lower):
347
- _parse_spring_config(entry, rel, findings)
429
+ profile = _extract_spring_profile(name)
430
+ if profile and profile not in profiles_scanned:
431
+ profiles_scanned.append(profile)
432
+ count = _parse_spring_config(entry, rel, findings, profile)
433
+ total_spring_candidates += count
348
434
  continue
349
435
  # Source code files
350
436
  suffix = entry.suffix.lower()
351
437
  if suffix in _CODE_EXTENSIONS:
352
438
  _scan_file(entry, rel, findings)
353
439
 
440
+ return total_spring_candidates
441
+
354
442
 
355
443
  def _replace_description(record, description: str):
356
444
  from dataclasses import replace
@@ -60,9 +60,13 @@ def _run_git(args: list[str], cwd: Path, timeout: int = 15) -> tuple[str, int]:
60
60
  ["git", "-C", str(cwd)] + args,
61
61
  capture_output=True,
62
62
  text=True,
63
+ encoding="utf-8",
64
+ errors="replace",
63
65
  timeout=timeout,
64
66
  )
65
- return result.stdout, result.returncode
67
+ # `result.stdout` is typed Optional[str]; guard against None on edge-case
68
+ # platforms (Windows subprocess encoding failures, detached processes, etc.)
69
+ return result.stdout or "", result.returncode
66
70
 
67
71
 
68
72
  class GitAnalyzer:
@@ -80,6 +84,7 @@ class GitAnalyzer:
80
84
  branch: Optional[str] = None
81
85
  recent_commits: list[CommitRecord] = []
82
86
  change_hotspots: list[ChangeHotspot] = []
87
+ hotspots_status: str = "ok"
83
88
  uncommitted: Optional[UncommittedChanges] = None
84
89
  contributors: list[str] = []
85
90
 
@@ -137,8 +142,10 @@ class GitAnalyzer:
137
142
  change_hotspots = _parse_hotspots(stdout)
138
143
  except subprocess.TimeoutExpired:
139
144
  limitations.append("hotspots_timeout")
145
+ hotspots_status = "failed"
140
146
  except Exception as exc:
141
147
  limitations.append(f"hotspots_error:{exc}")
148
+ hotspots_status = "failed"
142
149
 
143
150
  try:
144
151
  stdout, _ = _run_git(["status", "--porcelain"], path, timeout=10)
@@ -166,6 +173,7 @@ class GitAnalyzer:
166
173
  branch=branch,
167
174
  recent_commits=recent_commits,
168
175
  change_hotspots=change_hotspots,
176
+ hotspots_status=hotspots_status,
169
177
  uncommitted_changes=uncommitted,
170
178
  contributors=contributors,
171
179
  git_summary=git_summary,
@@ -228,9 +236,12 @@ def _is_hotspot_admin(path: str) -> bool:
228
236
  return False
229
237
 
230
238
 
231
- def _parse_hotspots(output: str) -> list:
239
+ def _parse_hotspots(output: str | None) -> list:
232
240
  from sourcecode.schema import ChangeHotspot
233
241
 
242
+ if not output:
243
+ return []
244
+
234
245
  file_counts: Counter = Counter()
235
246
  file_last_date: dict[str, str] = {}
236
247
  current_date = ""
@@ -728,11 +728,13 @@ class TaskContextBuilder:
728
728
  cwd=str(self.root),
729
729
  capture_output=True,
730
730
  text=True,
731
+ encoding="utf-8",
732
+ errors="replace",
731
733
  timeout=10,
732
734
  )
733
735
  if result.returncode == 0:
734
736
  return [
735
- line.strip() for line in result.stdout.splitlines()
737
+ line.strip() for line in (result.stdout or "").splitlines()
736
738
  if line.strip()
737
739
  ]
738
740
  except (subprocess.TimeoutExpired, FileNotFoundError):
@@ -744,10 +746,12 @@ class TaskContextBuilder:
744
746
  cwd=str(self.root),
745
747
  capture_output=True,
746
748
  text=True,
749
+ encoding="utf-8",
750
+ errors="replace",
747
751
  timeout=10,
748
752
  )
749
753
  if result.returncode == 0:
750
- return [line.strip() for line in result.stdout.splitlines() if line.strip()]
754
+ return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
751
755
  except (subprocess.TimeoutExpired, FileNotFoundError):
752
756
  pass
753
757
  return []