sourcecode 3.3.0__tar.gz → 3.4.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 (189) hide show
  1. {sourcecode-3.3.0 → sourcecode-3.4.0}/CHANGELOG.md +92 -0
  2. {sourcecode-3.3.0 → sourcecode-3.4.0}/PKG-INFO +8 -5
  3. {sourcecode-3.3.0 → sourcecode-3.4.0}/README.md +7 -4
  4. {sourcecode-3.3.0 → sourcecode-3.4.0}/pyproject.toml +1 -1
  5. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/__init__.py +1 -1
  6. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/cli.py +8 -3
  7. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/migrate_check.py +89 -7
  8. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/verify_edit.py +190 -7
  9. {sourcecode-3.3.0 → sourcecode-3.4.0}/.github/workflows/build-windows.yml +0 -0
  10. {sourcecode-3.3.0 → sourcecode-3.4.0}/.gitignore +0 -0
  11. {sourcecode-3.3.0 → sourcecode-3.4.0}/.ruff.toml +0 -0
  12. {sourcecode-3.3.0 → sourcecode-3.4.0}/CONTRIBUTING.md +0 -0
  13. {sourcecode-3.3.0 → sourcecode-3.4.0}/LICENSE +0 -0
  14. {sourcecode-3.3.0 → sourcecode-3.4.0}/SECURITY.md +0 -0
  15. {sourcecode-3.3.0 → sourcecode-3.4.0}/raw +0 -0
  16. {sourcecode-3.3.0 → sourcecode-3.4.0}/scripts/compare_integration_engines.py +0 -0
  17. {sourcecode-3.3.0 → sourcecode-3.4.0}/scripts/customer_smoke_test.sh +0 -0
  18. {sourcecode-3.3.0 → sourcecode-3.4.0}/scripts/generate_jdk_exports.py +0 -0
  19. {sourcecode-3.3.0 → sourcecode-3.4.0}/scripts/perf_harness.py +0 -0
  20. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/adaptive_scanner.py +0 -0
  21. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/archetype.py +0 -0
  22. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/architectural_baseline.py +0 -0
  23. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/architectural_delta.py +0 -0
  24. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/architecture_analyzer.py +0 -0
  25. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/architecture_summary.py +0 -0
  26. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/ast_extractor.py +0 -0
  27. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/cache.py +0 -0
  28. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/cache_model.py +0 -0
  29. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/call_surface.py +0 -0
  30. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/caller_metrics.py +0 -0
  31. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/canonical_ir.py +0 -0
  32. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/chain_rules.py +0 -0
  33. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/change_plan.py +0 -0
  34. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/cir_graphs.py +0 -0
  35. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/classifier.py +0 -0
  36. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  37. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/compare.py +0 -0
  38. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/confidence_analyzer.py +0 -0
  39. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/constraint_diff.py +0 -0
  40. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/context_cache.py +0 -0
  41. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/context_graph.py +0 -0
  42. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/context_scorer.py +0 -0
  43. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/context_summarizer.py +0 -0
  44. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/contract_diff.py +0 -0
  45. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/contract_model.py +0 -0
  46. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/contract_pipeline.py +0 -0
  47. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/coverage_parser.py +0 -0
  48. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/dependency_analyzer.py +0 -0
  49. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/deployment_prefix.py +0 -0
  50. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/__init__.py +0 -0
  51. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/base.py +0 -0
  52. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  53. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/dart.py +0 -0
  54. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/dotnet.py +0 -0
  55. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/elixir.py +0 -0
  56. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/go.py +0 -0
  57. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/heuristic.py +0 -0
  58. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/hybrid.py +0 -0
  59. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/java.py +0 -0
  60. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  61. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/nodejs.py +0 -0
  62. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/parsers.py +0 -0
  63. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/php.py +0 -0
  64. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/project.py +0 -0
  65. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/python.py +0 -0
  66. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/ruby.py +0 -0
  67. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/rust.py +0 -0
  68. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/systems.py +0 -0
  69. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/terraform.py +0 -0
  70. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/detectors/tooling.py +0 -0
  71. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/doc_analyzer.py +0 -0
  72. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/dynamic_argument_surface.py +0 -0
  73. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/endpoint_literals.py +0 -0
  74. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/endpoint_metrics.py +0 -0
  75. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  76. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/env_analyzer.py +0 -0
  77. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/envelope.py +0 -0
  78. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/error_schema.py +0 -0
  79. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/evidence_provider.py +0 -0
  80. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/explain.py +0 -0
  81. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/facts/__init__.py +0 -0
  82. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/facts/registry.json +0 -0
  83. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/file_chunker.py +0 -0
  84. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/file_classifier.py +0 -0
  85. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/filter_surface.py +0 -0
  86. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/format_contract.py +0 -0
  87. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/fqn_utils.py +0 -0
  88. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/git_analyzer.py +0 -0
  89. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/graph_analyzer.py +0 -0
  90. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/graph_evidence.py +0 -0
  91. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/hibernate_strat.py +0 -0
  92. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/integration_coordinates.py +0 -0
  93. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/jdk_exports.py +0 -0
  94. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/license.py +0 -0
  95. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/__init__.py +0 -0
  96. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  97. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  98. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  99. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  100. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  101. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  102. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/registry.py +0 -0
  103. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/runner.py +0 -0
  104. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp/server.py +0 -0
  105. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/mcp_nudge.py +0 -0
  106. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/metrics_analyzer.py +0 -0
  107. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/migration_blast.py +0 -0
  108. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/openapi_surface.py +0 -0
  109. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/output_budget.py +0 -0
  110. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/parse_cache.py +0 -0
  111. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/path_filters.py +0 -0
  112. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/perf.py +0 -0
  113. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/posture.py +0 -0
  114. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  115. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/pr_impact.py +0 -0
  116. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/prepare_context.py +0 -0
  117. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/progress.py +0 -0
  118. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/ranking_engine.py +0 -0
  119. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/readiness_timeline.py +0 -0
  120. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/reconciliation.py +0 -0
  121. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/redactor.py +0 -0
  122. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/reference_facts.py +0 -0
  123. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/relevance_scorer.py +0 -0
  124. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/rename_refactor.py +0 -0
  125. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/repo_classifier.py +0 -0
  126. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/repository_ir.py +0 -0
  127. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/__init__.py +0 -0
  128. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/context.py +0 -0
  129. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/errors.py +0 -0
  130. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/executor.py +0 -0
  131. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/planner.py +0 -0
  132. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/query.py +0 -0
  133. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/request.py +0 -0
  134. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/resolution.py +0 -0
  135. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/result.py +0 -0
  136. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/retriever.py +0 -0
  137. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/runtime.py +0 -0
  138. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps.py +0 -0
  139. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_endpoint.py +0 -0
  140. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_graph.py +0 -0
  141. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_impact.py +0 -0
  142. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_intf.py +0 -0
  143. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_struct.py +0 -0
  144. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/retrieval/steps_txsec.py +0 -0
  145. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/ris.py +0 -0
  146. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/runtime_classifier.py +0 -0
  147. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/scanner.py +0 -0
  148. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/schema.py +0 -0
  149. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/schemas/envelope-v1.schema.json +0 -0
  150. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/security_config.py +0 -0
  151. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/security_posture.py +0 -0
  152. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/semantic_analyzer.py +0 -0
  153. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/semantic_impact_engine.py +0 -0
  154. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/semantic_integration_engine.py +0 -0
  155. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/semantic_services.py +0 -0
  156. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/serializer.py +0 -0
  157. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_event_topology.py +0 -0
  158. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_findings.py +0 -0
  159. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_impact.py +0 -0
  160. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_model.py +0 -0
  161. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_profiles.py +0 -0
  162. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_properties.py +0 -0
  163. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_security_audit.py +0 -0
  164. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_semantic.py +0 -0
  165. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  166. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/summarizer.py +0 -0
  167. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/__init__.py +0 -0
  168. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/config.py +0 -0
  169. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/consent.py +0 -0
  170. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/events.py +0 -0
  171. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/filters.py +0 -0
  172. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/telemetry/transport.py +0 -0
  173. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/test_sources.py +0 -0
  174. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/token_estimate.py +0 -0
  175. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/tree_utils.py +0 -0
  176. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/type_usage_surface.py +0 -0
  177. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/validation_inference.py +0 -0
  178. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/validation_surface.py +0 -0
  179. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/verify_repo.py +0 -0
  180. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/verify_rules.py +0 -0
  181. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/version_check.py +0 -0
  182. {sourcecode-3.3.0 → sourcecode-3.4.0}/src/sourcecode/workspace.py +0 -0
  183. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/functions/README.md +0 -0
  184. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/functions/get-license/index.ts +0 -0
  185. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  186. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/functions/telemetry/index.ts +0 -0
  187. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/sql/license_event_ordering.sql +0 -0
  188. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/sql/licensing_schema.sql +0 -0
  189. {sourcecode-3.3.0 → sourcecode-3.4.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -2,6 +2,98 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.4.0] — 2026-07-29
6
+
7
+ **Two of the five reconciliation gates, and one commercial surface that answered five ways.**
8
+ The theme of field evaluation #4 was not missing analysis: it was analysis the product had
9
+ already done and did not consult — a migration estimate that ignored its own
10
+ `auto_fix_available`, and a gate that reported `pass` for a file it never looked at.
11
+
12
+ **A minor, not a patch, and for one reason: `verify-edit` can now exit 2 where it exited 0.**
13
+ No API changed, but a build-configuration-only edit stops being a green light, so an installed
14
+ hook or a pipeline that gates on the exit code will start blocking on it. A patch release must
15
+ never be able to turn a green pipeline red.
16
+
17
+ **Upgrade notes.**
18
+
19
+ - `verify-edit`: a change to `pom.xml`, `build.gradle`, `application*.yml`, `web.xml` or a
20
+ container descriptor now yields `unverified` (exit 2). `git commit --no-verify` and
21
+ `ask verify-edit --no-ci` are the escape hatches.
22
+ - `migrate-check`: published effort numbers **move down** — the same work, split by what it
23
+ actually is. Measured: openmrs-core 122.1 → 76.4 d, BroadleafCommerce 163.3 → 157.7,
24
+ alfresco 22.8 → 5.2. `blocking_count` and `readiness_score` are unchanged.
25
+ - `auth status`: new authoritative fields; `pro`, `pro_effective`, `pro_gating` and `pro_reason`
26
+ still ship, now derived from the authority and listed in `deprecated_fields`.
27
+
28
+ ### Fixed
29
+
30
+ - **`verify-edit` sees the files that decide what the code compiles to** (C3-14, the second
31
+ reconciliation gate). Editing a `pom.xml` produced `changed_files: []`, `verdict: pass`,
32
+ `exit_code: 0` — and `blast_radius: {"confidence": "high", "note": "no changed files"}`.
33
+ git reported the file; the gate did not look at it, then said everything was fine about a
34
+ change that moves every source file in the repository at once.
35
+ - New blocking axis **`build_config_changed`**, and a new `changed_build_files` list beside
36
+ `changed_files`. It names what moved: `effective_compiler_release` (from → to) and
37
+ `dependency_delta` per coordinate.
38
+ - **The verdict is `unverified`, not `break`.** A build edit is neither a proven regression
39
+ nor computed-clean, which is exactly what `changed: null` has always meant here — so it
40
+ flows through the existing "could not compute" path and exits 2. No special case.
41
+ - Admission is by file name and deliberately narrow — `pom.xml`, `build.gradle`,
42
+ `settings.gradle`, `gradle.properties`, `application*.yml|properties`, `bootstrap*`,
43
+ `web.xml`, `weblogic*.xml`, `jboss-*.xml`, `*-context.xml`, `persistence.xml`, `beans.xml`.
44
+ `messages.properties` and `logback.xml` are out: every admitted file degrades a verdict, and
45
+ a loose rule turns the gate into noise people learn to ignore.
46
+ - `blast_radius` stops reporting `confidence: "high"` over a change whose reach it has not
47
+ measured.
48
+ - **Upgrade note (behaviour change):** a build-configuration-only edit now exits **2**
49
+ (unverified) where it exited 0 (pass). An installed pre-commit/pre-push hook will block on
50
+ such an edit; `git commit --no-verify` remains the escape hatch, and `--no-ci` still reports
51
+ without gating.
52
+
53
+ ### Changed
54
+
55
+ - **The migration estimate reads the report's own `auto_fix_available`** (C1-11, the first of
56
+ the five reconciliation gates from field evaluation #4). `migrate-check` shipped an
57
+ OpenRewrite recipe for most findings and then billed every one of them at the hand-edit rate.
58
+ Reproduced on the battery before fixing: **openmrs-core carries a recipe for 296 of 310
59
+ findings and costed all 310 as hand work**, for a headline of 122.1 person-days.
60
+ - `effort_breakdown` splits the same total into **`manual_days`** + **`mechanical_days`** +
61
+ `hibernate_rewrite_effort_days`. The partition is **by file**, because the severity weights
62
+ are per file: a file is mechanical only when *every* finding in it has a recipe, and one
63
+ unautomatable finding costs the whole file manually — a human is in that file either way.
64
+ - **The review factor is published, not buried.** `mechanical_review_factor` (0.2) carries
65
+ `mechanical_review_factor_basis: "assumption, not a measurement"`, so a team can substitute
66
+ their own number instead of inheriting ours silently. `recipes[]` lists what would run and
67
+ `mechanical_basis` states both denominators.
68
+ - **`validation_days` is `null` with a note, never `0`** — the re-test scope is a different
69
+ measurement (`migrate-check --blast-radius`), and `0` would read as "no re-testing needed"
70
+ (invariant I-3).
71
+ - **Published numbers move.** Measured: openmrs-core 122.1 → **76.4 d**, BroadleafCommerce
72
+ 163.3 → **157.7 d**, alfresco 22.8 → **5.2 d**. `blocking_count` and `readiness_score` are
73
+ unchanged in all three — the split changes what the work is called, never what blocks.
74
+
75
+ - **`ask auth status` answers one question instead of five** (P-2). It used to publish
76
+ `{"status": "unauthenticated", "pro": true, "pro_reason": "early-adoption unlock"}` — every
77
+ field true on its own axis, and together unreadable: a buyer could not tell what they have,
78
+ why they have it, or what changes when it ends.
79
+ - One authority, `license.entitlement()`, now answers it: **`entitlement`** (`pro`/`free` —
80
+ what runs today), **`source`** (why: `license_key`, `early_adoption_unlock`, `free_tier`),
81
+ **`authenticated`** (whether a credential exists here — a *separate* fact, since today one
82
+ can be entitled without one), **`paywall_active`**, **`when_it_changes`** (what you lose
83
+ when that source stops applying) and **`free_repo_java_file_limit`** (the number the gate
84
+ actually compares against).
85
+ - `when_it_changes` states the gate in the terms the gate uses and **never quotes a price** —
86
+ pricing lives in `docs/PRODUCT_TIERS.md`, and a number printed here would be stale the day
87
+ it moves. Asserted.
88
+ - **`is_pro` is derived from the same authority.** It is what every gate reads
89
+ (`can_use`, `require_feature`), and it used to be computed beside the status block rather
90
+ than from it — which is how a status page came to contradict the commands next to it.
91
+ - `pro`, `pro_effective`, `pro_gating` and `pro_reason` are still emitted for existing
92
+ consumers, are derived from the authority (so they cannot drift), and are listed in the new
93
+ `deprecated_fields`. They are removed in a future major.
94
+ - `plan_status` no longer reports `unknown` for a licence file with no explicit status while
95
+ the authority reads that same file as active.
96
+
5
97
  ## [3.3.0] — 2026-07-29
6
98
 
7
99
  **A default you must remember to disable is the wrong default for someone else's code.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -283,10 +283,13 @@ Honest limits worth knowing before you rely on it:
283
283
  > full Pro entitlements — no size gate, no key. The tiers below describe the model the
284
284
  > paywall will return to later.
285
285
  >
286
- > **What that means concretely.** `ask auth status` reports `"status": "unauthenticated"`
287
- > together with `"pro": true`, `"pro_reason": "early-adoption unlock"` that combination is
288
- > expected, not a bug: you are unauthenticated *and* unlocked. When the unlock ends, gating
289
- > returns **by repo size and automation, never by command**: `posture`, `endpoints`,
286
+ > **What that means concretely.** Ask the product: `ask auth status` answers in one block —
287
+ > `entitlement` (what runs today), `source` (*why* — a licence, the unlock, or the free tier),
288
+ > `authenticated` (whether a credential exists, which is a separate fact) and
289
+ > `when_it_changes` (what you lose when that source stops applying). A fresh install reads
290
+ > `entitlement: pro`, `source: early_adoption_unlock`, `authenticated: false` — unauthenticated
291
+ > *and* entitled, stated as two facts instead of one contradiction. When the unlock ends,
292
+ > gating returns **by repo size and automation, never by command**: `posture`, `endpoints`,
290
293
  > `spring-audit` and `migrate-check` stay in the base tier at full output. Nothing you can run
291
294
  > today becomes a paid-only command tomorrow.
292
295
 
@@ -245,10 +245,13 @@ Honest limits worth knowing before you rely on it:
245
245
  > full Pro entitlements — no size gate, no key. The tiers below describe the model the
246
246
  > paywall will return to later.
247
247
  >
248
- > **What that means concretely.** `ask auth status` reports `"status": "unauthenticated"`
249
- > together with `"pro": true`, `"pro_reason": "early-adoption unlock"` that combination is
250
- > expected, not a bug: you are unauthenticated *and* unlocked. When the unlock ends, gating
251
- > returns **by repo size and automation, never by command**: `posture`, `endpoints`,
248
+ > **What that means concretely.** Ask the product: `ask auth status` answers in one block —
249
+ > `entitlement` (what runs today), `source` (*why* — a licence, the unlock, or the free tier),
250
+ > `authenticated` (whether a credential exists, which is a separate fact) and
251
+ > `when_it_changes` (what you lose when that source stops applying). A fresh install reads
252
+ > `entitlement: pro`, `source: early_adoption_unlock`, `authenticated: false` — unauthenticated
253
+ > *and* entitled, stated as two facts instead of one contradiction. When the unlock ends,
254
+ > gating returns **by repo size and automation, never by command**: `posture`, `endpoints`,
252
255
  > `spring-audit` and `migrate-check` stay in the base tier at full output. Nothing you can run
253
256
  > today becomes a paid-only command tomorrow.
254
257
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "3.3.0"
7
+ version = "3.4.0"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "3.3.0"
7
+ __version__ = "3.4.0"
@@ -6106,9 +6106,14 @@ def verify_edit_cmd(
6106
6106
 
6107
6107
  Deterministic, in-loop verdict over the diff of the working tree against HEAD:
6108
6108
  public contract, @Transactional proxy boundary, security surface, bean wiring,
6109
- custom domain rules, and blast radius. Built for the agent write-loop (MCP) and
6110
- git hooks. Only regressions the edit INTRODUCED (vs HEAD) block — pre-existing
6111
- debt never fails an unrelated commit.
6109
+ custom domain rules, build/configuration changes, and blast radius. Built for the
6110
+ agent write-loop (MCP) and git hooks. Only regressions the edit INTRODUCED (vs
6111
+ HEAD) block — pre-existing debt never fails an unrelated commit.
6112
+
6113
+ Build and configuration files (pom.xml, build.gradle, application*.yml, web.xml,
6114
+ container descriptors, Spring XML) are reported under `changed_build_files` and
6115
+ make the verdict UNVERIFIED, never `pass`: their effect is not decidable from a
6116
+ source diff, and a compiler-release bump moves every file in the repo at once.
6112
6117
 
6113
6118
  Custom rules: drop a `.ask/verify-rules.json` at the repo root to enforce your own
6114
6119
  invariants (e.g. "no @RestController injects a *DaoJpa", "every /payroll endpoint
@@ -765,6 +765,15 @@ _BOOT3_MIGRATION_TARGETS: frozenset[str] = frozenset(
765
765
  # reported as a separate hygiene metric, excluded from JDK-modernization scoring.
766
766
  _BEST_PRACTICE_TARGETS: frozenset[str] = frozenset({"java_8_best_practice"})
767
767
 
768
+ #: What a file costs when every finding in it has an OpenRewrite recipe, as a
769
+ #: fraction of the hand-edit rate (C1-11). The recipe does not remove the work —
770
+ #: it turns editing into running one recipe and reviewing the diff.
771
+ #:
772
+ #: This is an **assumption, not a measurement**, and it is published as a field
773
+ #: (`effort_breakdown.mechanical_review_factor`) with that label so a reader can
774
+ #: substitute their own team's number instead of inheriting ours silently.
775
+ _MECHANICAL_REVIEW_FACTOR: float = 0.2
776
+
768
777
  # BUG #3: the migration dimensions that feed the readiness_score aggregate, in
769
778
  # order. jdk_modernization is deliberately NOT here — it is orthogonal upkeep debt
770
779
  # reported on its own axis, never folded into the migration headline.
@@ -1625,12 +1634,45 @@ class MigrationReport:
1625
1634
 
1626
1635
  # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1627
1636
  # test fixtures) no longer pad the estimate.
1628
- _file_effort = (
1629
- len(critical_files) * 0.5
1630
- + len(high_files) * 0.25
1631
- + len(medium_files) * 0.1
1632
- + len(low_files) * 0.05
1633
- )
1637
+ #
1638
+ # C1-11 (field eval #4): the estimate used to bill every file at the
1639
+ # hand-edit rate while the report itself shipped an OpenRewrite recipe for
1640
+ # most of the findings in it — on openmrs, 296 of 310 findings carry
1641
+ # `auto_fix_available: true` and all 310 were costed as hand work. A number
1642
+ # that contradicts a field in the same payload is the ADR-0008 defect, so
1643
+ # the estimate now reads that field.
1644
+ #
1645
+ # The unit is the file, because the weights below are per file: a file is
1646
+ # mechanical only when EVERY main finding in it carries a recipe. One
1647
+ # unautomatable finding puts a human in the file, and the rest of that
1648
+ # file's work comes with them.
1649
+ _findings_by_file: "dict[str, list[MigrationFinding]]" = {}
1650
+ for f in main_findings:
1651
+ _findings_by_file.setdefault(f.source_file, []).append(f)
1652
+ _mechanical_files = {
1653
+ path for path, items in _findings_by_file.items()
1654
+ if items and all(bool(i.openrewrite_recipe) for i in items)
1655
+ }
1656
+
1657
+ def _weighted(files: "set[str]") -> float:
1658
+ return (
1659
+ len(critical_files & files) * 0.5
1660
+ + len(high_files & files) * 0.25
1661
+ + len(medium_files & files) * 0.1
1662
+ + len(low_files & files) * 0.05
1663
+ )
1664
+
1665
+ _all_effort_files = critical_files | high_files | medium_files | low_files
1666
+ _manual_effort = _weighted(_all_effort_files - _mechanical_files)
1667
+ # A recipe does not remove the work, it changes what the work is: run the
1668
+ # recipe once, then review the diff. Reviewing is charged at a fraction of
1669
+ # editing. The fraction is an assumption, not a measurement, so it is
1670
+ # published as a field rather than buried in this expression.
1671
+ _mechanical_raw = _weighted(_all_effort_files & _mechanical_files)
1672
+ _mechanical_effort = _mechanical_raw * _MECHANICAL_REVIEW_FACTOR
1673
+ _file_effort = _manual_effort + _mechanical_effort
1674
+ _auto_fixable = sum(1 for f in main_findings if f.openrewrite_recipe)
1675
+ _recipes = sorted({f.openrewrite_recipe for f in main_findings if f.openrewrite_recipe})
1634
1676
  # BUG #1 (v1.70.0): when the Hibernate 5→6 axis APPLIES, fold its measured
1635
1677
  # rewrite effort (risk_matrix → total_effort_range_days) into the headline
1636
1678
  # estimate. Previously a Hibernate-5 project whose ${hibernateVersion} was
@@ -1646,6 +1688,33 @@ class MigrationReport:
1646
1688
  self.estimated_effort_days = round(_file_effort + _hib_effort, 1)
1647
1689
  self.effort_breakdown = {
1648
1690
  "findings_effort_days": round(_file_effort, 1),
1691
+ # C1-11: the same total, split by what the work actually is.
1692
+ "mechanical_days": round(_mechanical_effort, 1),
1693
+ "manual_days": round(_manual_effort, 1),
1694
+ # Not estimated rather than estimated at zero: the re-test scope is a
1695
+ # different measurement (which endpoints the changed files reach), and
1696
+ # `migrate-check --blast-radius` is what produces it. I-3: a fact that
1697
+ # cannot be decided says so.
1698
+ "validation_days": None,
1699
+ "validation_days_note": (
1700
+ "Not estimated here — the re-test scope is the endpoints the changed "
1701
+ "files reach. Run `migrate-check --blast-radius` for that ordering."
1702
+ ),
1703
+ "mechanical_basis": (
1704
+ f"{_auto_fixable} of {len(main_findings)} findings carry an OpenRewrite "
1705
+ f"recipe; {len(_mechanical_files & _all_effort_files)} of "
1706
+ f"{len(_all_effort_files)} affected files have a recipe for *every* "
1707
+ f"finding in them and are costed at {_MECHANICAL_REVIEW_FACTOR:g}× the "
1708
+ "hand-edit rate (run the recipe, review the diff). A file with one "
1709
+ "unautomatable finding is costed manually in full — a human is in that "
1710
+ "file either way."
1711
+ ),
1712
+ "mechanical_review_factor": _MECHANICAL_REVIEW_FACTOR,
1713
+ "mechanical_review_factor_basis": "assumption, not a measurement",
1714
+ "auto_fixable_findings": _auto_fixable,
1715
+ "auto_fixable_files": len(_mechanical_files & _all_effort_files),
1716
+ "affected_files": len(_all_effort_files),
1717
+ "recipes": _recipes,
1649
1718
  "hibernate_rewrite_effort_days": round(_hib_effort, 1),
1650
1719
  "hibernate_rewrite_range": (
1651
1720
  hib.total_effort_range_days if (_hibernate_applies and hib is not None) else None
@@ -1809,7 +1878,20 @@ class MigrationReport:
1809
1878
  f"(critical: {main_crit}, high: {main_high})"
1810
1879
  + (f" [+{nb} in test/generated, non-blocking]" if nb else ""),
1811
1880
  f"Affected files: {self.summary.get('affected_files', 0)}",
1812
- f"Estimated effort: {self.estimated_effort_days}d",
1881
+ f"Estimated effort: {self.estimated_effort_days}d"
1882
+ + (
1883
+ f" ({self.effort_breakdown.get('manual_days', 0)}d manual + "
1884
+ f"{self.effort_breakdown.get('mechanical_days', 0)}d mechanical"
1885
+ + (f" + {self.effort_breakdown.get('hibernate_rewrite_effort_days')}d hibernate"
1886
+ if self.effort_breakdown.get("hibernate_rewrite_effort_days") else "")
1887
+ + "; validation not estimated)"
1888
+ if self.effort_breakdown else ""
1889
+ ),
1890
+ (
1891
+ f" {self.effort_breakdown.get('auto_fixable_findings', 0)} findings carry an "
1892
+ f"OpenRewrite recipe — see effort_breakdown.recipes"
1893
+ if self.effort_breakdown.get("auto_fixable_findings") else ""
1894
+ ),
1813
1895
  "",
1814
1896
  ]
1815
1897
 
@@ -19,6 +19,7 @@ No public command and no verdict layer yet (those are Fase V1). What it must pro
19
19
  """
20
20
  from __future__ import annotations
21
21
 
22
+ import re
22
23
  import shutil
23
24
  import subprocess
24
25
  import tempfile
@@ -48,6 +49,10 @@ class HeadVsWorking:
48
49
  head_sha: str
49
50
  head_cache_hit: bool
50
51
  timings_ms: dict[str, float] = field(default_factory=dict)
52
+ #: Changed files that are not code but decide what the code compiles to and how
53
+ #: it is wired (C3-14). Kept apart from ``changed_files`` because the CIR diff
54
+ #: cannot reason about them — they make the verdict *unverified*, not *broken*.
55
+ changed_build_files: tuple[str, ...] = ()
51
56
 
52
57
 
53
58
  # Git env vars that REDIRECT git at where it operates. When verify-edit runs INSIDE a
@@ -98,8 +103,8 @@ def _head_sha(root: Path) -> str:
98
103
  return ""
99
104
 
100
105
 
101
- def _changed_java_files(root: Path) -> tuple[str, ...]:
102
- """`.java` paths that differ from HEAD: tracked diff ∪ untracked adds.
106
+ def _changed_paths(root: Path) -> tuple[str, ...]:
107
+ """Every path that differs from HEAD: tracked diff ∪ untracked adds.
103
108
 
104
109
  Deletions are kept (the path is gone but the contract change is real). Pure
105
110
  git; never reads file contents.
@@ -114,7 +119,53 @@ def _changed_java_files(root: Path) -> tuple[str, ...]:
114
119
  files.update(line.strip() for line in ru.stdout.splitlines() if line.strip())
115
120
  except Exception:
116
121
  pass
117
- return tuple(sorted(f for f in files if f.endswith(".java")))
122
+ return tuple(sorted(files))
123
+
124
+
125
+ def _changed_java_files(root: Path) -> tuple[str, ...]:
126
+ """The `.java` subset of `_changed_paths` — what the CIR diff can reason about."""
127
+ return tuple(f for f in _changed_paths(root) if f.endswith(".java"))
128
+
129
+
130
+ #: Files that change what the code *means* without being code: the build, the
131
+ #: container contract, the Spring wiring, the configuration a profile resolves.
132
+ #:
133
+ #: C3-14 (field eval #4): an edit to any of these was invisible to `verify-edit`,
134
+ #: which answered `changed_files: []` and `verdict: pass` — a confident green over
135
+ #: a change that can move every source file in the repository at once. Matched on
136
+ #: the file name, never on content, so the check costs nothing.
137
+ _BUILD_FILE_NAMES: frozenset[str] = frozenset({
138
+ "pom.xml", "build.gradle", "build.gradle.kts",
139
+ "settings.gradle", "settings.gradle.kts", "gradle.properties",
140
+ "web.xml", "context.xml", "jboss-web.xml", "beans.xml", "persistence.xml",
141
+ })
142
+ #: Descriptor families matched by prefix + `.xml`: the container contract and the
143
+ #: classic Spring XML wiring.
144
+ _XML_DESCRIPTOR_PREFIXES: tuple[str, ...] = ("weblogic", "jboss-", "applicationcontext")
145
+ #: Config file families that a profile resolves against. Deliberately narrow —
146
+ #: `messages.properties` is text for humans, not wiring.
147
+ _CONFIG_PREFIXES: tuple[str, ...] = ("application", "bootstrap")
148
+ _CONFIG_SUFFIXES: tuple[str, ...] = (".properties", ".yml", ".yaml")
149
+
150
+
151
+ def _is_build_config_path(rel_path: str) -> bool:
152
+ """Admission rule, kept narrow on purpose: every admitted file degrades a verdict
153
+ to unverified, and a loose rule turns the gate into noise people learn to ignore."""
154
+ name = rel_path.rsplit("/", 1)[-1].lower()
155
+ if name in _BUILD_FILE_NAMES:
156
+ return True
157
+ if name.endswith(".xml") and (
158
+ name.endswith("-context.xml") or name.startswith(_XML_DESCRIPTOR_PREFIXES)
159
+ ):
160
+ return True
161
+ if name.endswith(_CONFIG_SUFFIXES) and name.startswith(_CONFIG_PREFIXES):
162
+ return True
163
+ return False
164
+
165
+
166
+ def _changed_build_files(root: Path) -> tuple[str, ...]:
167
+ """Changed non-Java files that can alter compilation or runtime wiring."""
168
+ return tuple(f for f in _changed_paths(root) if _is_build_config_path(f))
118
169
 
119
170
 
120
171
  # ── CIR construction ───────────────────────────────────────────────────────────
@@ -213,7 +264,9 @@ def head_vs_working(path: Path) -> HeadVsWorking:
213
264
  timings: dict[str, float] = {}
214
265
 
215
266
  t0 = time.perf_counter()
216
- changed = _changed_java_files(root)
267
+ all_changed = _changed_paths(root)
268
+ changed = tuple(f for f in all_changed if f.endswith(".java"))
269
+ changed_build = tuple(f for f in all_changed if _is_build_config_path(f))
217
270
  timings["changed_files_ms"] = (time.perf_counter() - t0) * 1000.0
218
271
 
219
272
  t1 = time.perf_counter()
@@ -231,6 +284,7 @@ def head_vs_working(path: Path) -> HeadVsWorking:
231
284
  head_sha=head_sha,
232
285
  head_cache_hit=hit,
233
286
  timings_ms=timings,
287
+ changed_build_files=changed_build,
234
288
  )
235
289
 
236
290
 
@@ -252,7 +306,7 @@ VERDICT_SCHEMA = "verify-edit-v1"
252
306
  # no-op (clean) when no rules file exists, so adding it never blocks a repo that has none.
253
307
  _BLOCKING_AXES = (
254
308
  "contract_broken", "tx_boundary_changed", "security_delta", "new_orphan_bean",
255
- "custom_rule_violated",
309
+ "custom_rule_violated", "build_config_changed",
256
310
  )
257
311
 
258
312
 
@@ -264,6 +318,7 @@ class AxisResult:
264
318
  changed: Optional[bool]
265
319
  detail: str = ""
266
320
  items: tuple[dict, ...] = ()
321
+ data: dict = field(default_factory=dict)
267
322
 
268
323
  def to_dict(self) -> dict:
269
324
  d: dict = {"changed": self.changed}
@@ -271,6 +326,7 @@ class AxisResult:
271
326
  d["detail"] = self.detail
272
327
  if self.items:
273
328
  d["items"] = list(self.items)
329
+ d.update(self.data)
274
330
  return d
275
331
 
276
332
 
@@ -279,6 +335,7 @@ class VerifyVerdict:
279
335
  verdict: str # "pass" | "break" | "unverified"
280
336
  axes: dict[str, AxisResult]
281
337
  changed_files: tuple[str, ...]
338
+ changed_build_files: tuple[str, ...]
282
339
  blast_radius: dict
283
340
  reasons: tuple[str, ...]
284
341
  exit_code: int
@@ -290,6 +347,7 @@ class VerifyVerdict:
290
347
  "verdict": self.verdict,
291
348
  "head_sha": self.head_sha,
292
349
  "changed_files": list(self.changed_files),
350
+ "changed_build_files": list(self.changed_build_files),
293
351
  "axes": {name: ax.to_dict() for name, ax in self.axes.items()},
294
352
  "blast_radius": self.blast_radius,
295
353
  "reasons": list(self.reasons),
@@ -451,9 +509,132 @@ def _custom_rules_axis(cir_head: Any, cir_working: Any, root: Path) -> AxisResul
451
509
  return AxisResult(False)
452
510
 
453
511
 
512
+ # ── build configuration (C3-14) ────────────────────────────────────────────────
513
+ _COMPILER_PROPS: tuple[str, ...] = (
514
+ "java.version", "maven.compiler.release", "maven.compiler.source",
515
+ "maven.compiler.target",
516
+ )
517
+ _MAVEN_DEP_RE: "re.Pattern[str]" = re.compile(
518
+ r"<dependency>(.*?)</dependency>", re.DOTALL | re.IGNORECASE
519
+ )
520
+ _MAVEN_FIELD_RE: "re.Pattern[str]" = re.compile(
521
+ r"<(groupId|artifactId|version)>\s*([^<]+?)\s*</\1>", re.IGNORECASE
522
+ )
523
+
524
+
525
+ def _file_at_head(root: Path, rel_path: str) -> Optional[str]:
526
+ """The HEAD content of a path, or None when it did not exist there."""
527
+ try:
528
+ r = _run_git(root, "show", f"HEAD:{rel_path}")
529
+ return r.stdout if r.returncode == 0 else None
530
+ except Exception:
531
+ return None
532
+
533
+
534
+ def _maven_property(text: str, name: str) -> Optional[str]:
535
+ m = re.search(rf"<{re.escape(name)}>\s*([^<]+?)\s*</{re.escape(name)}>", text, re.IGNORECASE)
536
+ return m.group(1) if m else None
537
+
538
+
539
+ def _maven_dependencies(text: str) -> "dict[str, str]":
540
+ """`groupId:artifactId` → version (or "" when managed elsewhere). A diff input,
541
+ not an inventory: this answers *what changed in this file*, which is why it does
542
+ not go through the declared-coordinates authority (that one answers a different
543
+ question — which client libraries the build declares, allowlisted by kind)."""
544
+ out: dict[str, str] = {}
545
+ for block in _MAVEN_DEP_RE.findall(text or ""):
546
+ fields = {k.lower(): v for k, v in _MAVEN_FIELD_RE.findall(block)}
547
+ gid, aid = fields.get("groupid"), fields.get("artifactid")
548
+ if gid and aid:
549
+ out[f"{gid}:{aid}"] = fields.get("version", "")
550
+ return out
551
+
552
+
553
+ def _build_config_axis(root: Path, changed_build_files: tuple[str, ...]) -> AxisResult:
554
+ """C3-14: a changed build/config file is a change `verify-edit` cannot decide.
555
+
556
+ `changed: None` on purpose. The CIR diff proves nothing about a `pom.xml` edit —
557
+ it is neither a proven regression (`True`) nor computed-clean (`False`) — so the
558
+ axis degrades to *unverified* and the verdict follows the machinery that already
559
+ exists for "could not compute". Answering `pass` was the defect: a compiler-release
560
+ bump moves every source file in the repository at once, and the gate said nothing
561
+ changed.
562
+ """
563
+ if not changed_build_files:
564
+ return AxisResult(False, "no build or configuration file changed")
565
+
566
+ compiler: dict = {}
567
+ dependency_delta: list[dict] = []
568
+ for rel in changed_build_files:
569
+ if not rel.endswith("pom.xml"):
570
+ continue
571
+ before = _file_at_head(root, rel)
572
+ try:
573
+ after = (root / rel).read_text(encoding="utf-8", errors="replace")
574
+ except OSError:
575
+ after = None
576
+ if before is None or after is None:
577
+ continue
578
+ for prop in _COMPILER_PROPS:
579
+ was, now = _maven_property(before, prop), _maven_property(after, prop)
580
+ if was != now:
581
+ compiler[prop] = {"from": was, "to": now, "file": rel}
582
+ deps_before, deps_after = _maven_dependencies(before), _maven_dependencies(after)
583
+ for key in sorted(set(deps_before) | set(deps_after)):
584
+ if deps_before.get(key) == deps_after.get(key):
585
+ continue
586
+ dependency_delta.append({
587
+ "coordinate": key,
588
+ "from": deps_before.get(key),
589
+ "to": deps_after.get(key),
590
+ "file": rel,
591
+ })
592
+
593
+ detail = f"{len(changed_build_files)} build/configuration file(s) changed; "
594
+ if compiler:
595
+ first = next(iter(compiler.values()))
596
+ detail += f"compiler release {first['from']} → {first['to']}; "
597
+ if dependency_delta:
598
+ detail += f"{len(dependency_delta)} dependency change(s); "
599
+ detail += "effect on runtime behaviour is not decidable from the source diff"
600
+
601
+ return AxisResult(
602
+ None,
603
+ detail,
604
+ items=tuple({"file": f} for f in changed_build_files[:20]),
605
+ data={
606
+ "files": list(changed_build_files),
607
+ "effective_compiler_release": compiler or None,
608
+ "dependency_delta": dependency_delta or None,
609
+ "blast_note": (
610
+ "A build or configuration change applies to the whole compilation "
611
+ "unit, not to a file: its reach is every source file in scope, and "
612
+ "`verify-edit` does not model it. Verdict degrades to unverified "
613
+ "(exit 2) rather than passing."
614
+ ),
615
+ },
616
+ )
617
+
618
+
454
619
  # ── blast radius (reach over the changed files; informational, not a diff) ──────
455
- def _blast_radius(cir_working: Any, changed_files: tuple[str, ...]) -> dict:
620
+ def _blast_radius(
621
+ cir_working: Any,
622
+ changed_files: tuple[str, ...],
623
+ changed_build_files: tuple[str, ...] = (),
624
+ ) -> dict:
456
625
  if not changed_files:
626
+ # C3-14: "no changed files" with high confidence was a lie when the build
627
+ # changed — the reach of that edit is the whole tree, and it is unmeasured.
628
+ if changed_build_files:
629
+ return {
630
+ "affected_entry_points": None,
631
+ "confidence": "unknown",
632
+ "note": (
633
+ f"no changed .java files, but {len(changed_build_files)} build/"
634
+ "configuration file(s) changed — reach is the whole compilation "
635
+ "unit and is not modelled here"
636
+ ),
637
+ }
457
638
  return {"affected_entry_points": 0, "confidence": "high", "note": "no changed files"}
458
639
  try:
459
640
  from sourcecode.context_graph import ContextGraph
@@ -487,8 +668,9 @@ def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
487
668
  "security_delta": _security_axis(hvw.cir_head, hvw.cir_working, root),
488
669
  "new_orphan_bean": _orphan_bean_axis(hvw.cir_head, hvw.cir_working),
489
670
  "custom_rule_violated": _custom_rules_axis(hvw.cir_head, hvw.cir_working, root),
671
+ "build_config_changed": _build_config_axis(root, hvw.changed_build_files),
490
672
  }
491
- blast = _blast_radius(hvw.cir_working, hvw.changed_files)
673
+ blast = _blast_radius(hvw.cir_working, hvw.changed_files, hvw.changed_build_files)
492
674
 
493
675
  reasons: list[str] = []
494
676
  broke = False
@@ -513,6 +695,7 @@ def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
513
695
  verdict=verdict_str,
514
696
  axes=axes,
515
697
  changed_files=hvw.changed_files,
698
+ changed_build_files=hvw.changed_build_files,
516
699
  blast_radius=blast,
517
700
  reasons=tuple(reasons),
518
701
  exit_code=code,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes