rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -28,15 +28,34 @@ module Rigor
28
28
 
29
29
  # One constant reference. `from` is the fully-qualified name of the innermost enclosing declaration, or
30
30
  # nil for a reference written at file level — that is what makes the graph a reachability graph rather
31
- # than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8).
32
- Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line)
31
+ # than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8). `rooted` is true
32
+ # when the constant was written with a leading `::` (#625).
33
+ Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line, :rooted) do
34
+ def initialize(rooted: false, **) = super
35
+ end
33
36
 
34
37
  # ADR-102 WD4 — a site where a constant is reached by a mechanism the static reading cannot follow.
35
38
  # `name` is the exact constant when the argument is a literal (`"Foo".constantize`), in which case this
36
- # is as good as a reference. `prefix` is the namespace a dynamic construction can reach into
37
- # (`"Foo::#{k}".constantize` `"Foo"`, and `nil` when even that is unknown), which taints every
38
- # declaration at or below it rather than proving any single one used.
39
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line)
39
+ # is as good as a reference. `prefix` is what a dynamic construction can reach, and `scope` says how
40
+ # far the two kinds of weak evidence are not the same shape:
41
+ #
42
+ # - `:namespace` — `"Foo::#{k}".constantize` can construct ANY name under `Foo`, so it taints `Foo`
43
+ # and every declaration beneath it. This is the default and the original meaning of `prefix`.
44
+ # - `:exact` — a data-file or template match names ONE declaration and says nothing about its
45
+ # children (#370). `config/recurring.yml` mentioning `Admin` is not evidence about
46
+ # `Admin::CollectionPolicy`; treating it as such demoted 18 unrelated Mastodon rows off one
47
+ # Afrikaans word ("Administrasie" contains "Admin").
48
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope) do
49
+ def initialize(scope: :namespace, **) = super
50
+
51
+ # Whether this site's evidence reaches `fqn`.
52
+ def taints?(fqn)
53
+ return false if prefix.nil?
54
+ return fqn == prefix if scope == :exact
55
+
56
+ fqn == prefix || fqn.start_with?("#{prefix}::")
57
+ end
58
+ end
40
59
 
41
60
  Result = Data.define(:declarations, :references, :dynamic_uses)
42
61
 
@@ -232,7 +251,8 @@ module Rigor
232
251
 
233
252
  @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
234
253
  from: nesting.empty? ? nil : nesting.join("::"),
235
- role: @role, path: @path, line: node.location.start_line)
254
+ role: @role, path: @path, line: node.location.start_line,
255
+ rooted: Source::ConstantPath.rooted?(node))
236
256
  end
237
257
  end
238
258
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "zlib"
6
+
7
+ require_relative "../../version"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ module Reachability
12
+ # ADR-102 — the warm cache for `rigor unused`'s two per-file passes: the Prism reachability
13
+ # scan over `.rb` / `.rake` files, and the capital-run extraction over template files. Both
14
+ # passes are pure functions of one file's bytes, and both were measured re-running in full on
15
+ # every invocation (2.0 s of a 2.6 s warm run on Mastodon; the Marshal restore is 25× cheaper
16
+ # than the rescan, `docs/notes/20260825-feature-warm-cold-corpus-perf.md`).
17
+ #
18
+ # One self-validating zlib-Marshal blob under the cache root, the ADR-46 IncrementalSnapshot
19
+ # shape rather than an ADR-54 `Cache::Store` producer: the sound unit of reuse is the FILE
20
+ # (any subset of files may change between runs), so the payload carries a stat signature per
21
+ # entry and validates each on read, where a store entry validates all-or-nothing and a miss
22
+ # would throw away every unchanged file's work. The whole-project answer stays complete —
23
+ # every file is still consulted every run; an unchanged one contributes its cached product.
24
+ #
25
+ # Fail-soft everywhere: a missing, torn, or stale-schema blob is an empty cache, and a file
26
+ # whose signature cannot be taken is computed and never recorded. Nothing here can change
27
+ # what the report says — only whether a per-file product was recomputed to say it.
28
+ class ScanCache
29
+ SCHEMA = 1
30
+ FILE_NAME = "reachability-scan.bundle"
31
+
32
+ # The sources whose behaviour the cached products embody. Digested into the header so a
33
+ # checkout that edits the scan invalidates its survey targets' bundles without anyone
34
+ # remembering to bump {SCHEMA} — the `Cache::EngineSource` idea, scoped to this feature.
35
+ SOURCE_FILES = [
36
+ File.expand_path("scan.rb", __dir__),
37
+ File.expand_path(__FILE__)
38
+ ].freeze
39
+
40
+ # A file modified within this window of the recording instant is computed but never
41
+ # recorded — the FileDigest racy-write guard, one tier simpler because a refused signature
42
+ # costs one rescan on the next run, never a wrong answer.
43
+ RACY_WINDOW_NS = 2_000_000_000
44
+ private_constant :RACY_WINDOW_NS
45
+
46
+ # @param cache_path [String, nil] the configuration's cache root; nil or empty is an inert
47
+ # cache (every fetch computes, nothing persists).
48
+ # @param target_ruby [String, nil] parse-affecting configuration, part of the identity.
49
+ def self.open(cache_path, target_ruby: nil)
50
+ return new(path: nil, header: nil) if cache_path.nil? || cache_path.to_s.empty?
51
+
52
+ new(path: File.join(cache_path.to_s, FILE_NAME),
53
+ header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}")
54
+ end
55
+
56
+ def self.source_digest
57
+ Digest::SHA256.hexdigest(SOURCE_FILES.map { |path| File.read(path) }.join)
58
+ rescue StandardError
59
+ "unreadable"
60
+ end
61
+
62
+ def initialize(path:, header:)
63
+ @path = path
64
+ @header = header
65
+ @entries = load_entries
66
+ @live = {}
67
+ @dirty = false
68
+ end
69
+
70
+ # Serves the cached product for `(kind, path)` while the file's stat signature holds;
71
+ # otherwise computes via the block and records the fresh product.
72
+ def serve(kind, path)
73
+ key = [kind, path]
74
+ sig = signature(path)
75
+ cached = @entries[key]
76
+ if cached && sig && cached[0] == sig
77
+ @live[key] = cached
78
+ return cached[1]
79
+ end
80
+
81
+ value = yield
82
+ @live[key] = [sig, value] if sig
83
+ @dirty = true
84
+ value
85
+ end
86
+
87
+ # Best-effort persist, only when something changed. Carries forward entries this run never
88
+ # consulted (a path-argument run must not shrink the full-project bundle) but drops the ones
89
+ # whose file no longer exists; tmp-then-rename so a torn write is never read back.
90
+ def save
91
+ return if @path.nil? || !@dirty
92
+
93
+ keep = @entries.merge(@live).select { |(_, path), _| File.exist?(path) }
94
+ FileUtils.mkdir_p(File.dirname(@path))
95
+ tmp = "#{@path}.#{Process.pid}.tmp"
96
+ File.binwrite(tmp, Zlib::Deflate.deflate(Marshal.dump([@header, keep])))
97
+ File.rename(tmp, @path)
98
+ nil
99
+ rescue StandardError
100
+ FileUtils.rm_f(tmp) if tmp
101
+ nil
102
+ end
103
+
104
+ private
105
+
106
+ def load_entries
107
+ return {} if @path.nil?
108
+
109
+ # Same trust model as the ADR-45 store and the ADR-46 snapshot: the blob lives in the
110
+ # project's own cache directory and a corrupt one degrades to an empty cache.
111
+ header, entries = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
112
+ header == @header && entries.is_a?(Hash) ? entries : {}
113
+ rescue StandardError
114
+ {}
115
+ end
116
+
117
+ def signature(path)
118
+ st = File.stat(path)
119
+ mtime_ns = (st.mtime.to_i * 1_000_000_000) + st.mtime.nsec
120
+ now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
121
+ return nil if mtime_ns >= now_ns - RACY_WINDOW_NS
122
+
123
+ [st.size, mtime_ns, (st.ctime.to_i * 1_000_000_000) + st.ctime.nsec, st.ino]
124
+ rescue SystemCallError
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "crash_signature"
4
+
3
5
  module Rigor
4
6
  module Analysis
5
7
  class Result
@@ -17,6 +19,36 @@ module Rigor
17
19
  diagnostics.none?(&:error?)
18
20
  end
19
21
 
22
+ # Issue #686 — true when a check rule (or a plugin's node-rule contribution) raised, so
23
+ # `Runner#analyze_file_body`'s rescue REPLACED the affected file's whole diagnostic list with one
24
+ # synthetic row. What comes back is then not a weaker account of the code; it is no account of it.
25
+ #
26
+ # Any consumer that compares two results, or reads an EMPTY result as "clean", has to ask this first.
27
+ # A crashed baseline and a crashed mutant carry the same synthetic row, so `==` reports agreement; an
28
+ # absence assertion holds on the one-diagnostic list the rescue leaves behind. That is how a kill
29
+ # oracle scored survivors it never measured (#686) and how 1,177 spec examples passed with every check
30
+ # rule crashing (#674).
31
+ #
32
+ # Narrower than "the run reported a failure", deliberately. A raising PLUGIN loses only that plugin's
33
+ # contribution and a failed RBS definition build loses only a type — in both cases every builtin rule
34
+ # still ran and its diagnostics are still here, so a consumer that refused those would be discarding a
35
+ # real measurement. {CrashSignature} carries the full table and the reasoning.
36
+ #
37
+ # `success?` is deliberately NOT this question: a crash makes `success?` false (the row is `:error`),
38
+ # which is why the CLI is loud and CI goes red. It is the callers that read the diagnostic LIST rather
39
+ # than the exit code which need the predicate.
40
+ def crashed?
41
+ diagnostics.any? { |diagnostic| CrashSignature.discards_file_analysis?(diagnostic) }
42
+ end
43
+
44
+ # The diagnostics {#crashed?} answers true for, so a caller can name what it saw rather than only that
45
+ # it saw something.
46
+ #
47
+ # @return [Array<Rigor::Analysis::Diagnostic>]
48
+ def crash_diagnostics
49
+ diagnostics.select { |diagnostic| CrashSignature.discards_file_analysis?(diagnostic) }
50
+ end
51
+
20
52
  def error_count
21
53
  diagnostics.count(&:error?)
22
54
  end
@@ -14,8 +14,13 @@ end
14
14
  require_relative "../version"
15
15
  require_relative "../cache/descriptor"
16
16
  require_relative "../cache/engine_source"
17
+ require_relative "../cache/file_digest"
17
18
  require_relative "../cache/rbs_descriptor"
18
19
  require_relative "../environment/default_libraries"
20
+ # Both resolvers are leaf files (no requires beyond `yaml`), so the boot-slimming probe reaches the two
21
+ # lockfile paths without loading `rigor/environment` or the RBS machinery.
22
+ require_relative "../environment/lockfile_resolver"
23
+ require_relative "../environment/rbs_collection_discovery"
19
24
 
20
25
  module Rigor
21
26
  module Analysis
@@ -48,6 +53,18 @@ module Rigor
48
53
  # collecting run wrote elsewhere.
49
54
  RUN_EFFECTS_PRODUCER_ID = "analysis.run-effects"
50
55
 
56
+ # #482 — the **serving** half of the sidecar, under the same effects identity: the propagated table,
57
+ # the unit sources and the merged as-written superclass table. Everything a warm run reads, and
58
+ # nothing else.
59
+ #
60
+ # It is a separate entry rather than a section of {RUN_EFFECTS_PRODUCER_ID} because the collections
61
+ # blob is large in exactly the projects where warm latency matters — 6.9 MB and 0.71 s of `Marshal`
62
+ # on gitlab `app lib`, against ~40 KB here — and a warm run consumes none of it. The collections
63
+ # entry stays, read lazily by the paths that genuinely need per-file form (an ADR-46 recheck, the
64
+ # fail-soft re-propagation), so a consumer this split did not anticipate loads the blob rather than
65
+ # seeing an empty table.
66
+ RUN_EFFECTS_TABLE_PRODUCER_ID = "analysis.run-effects-table"
67
+
51
68
  # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
52
69
  # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
53
70
  # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
@@ -68,7 +85,7 @@ module Rigor
68
85
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)
69
86
  Cache::Descriptor.new(
70
87
  gems: [Cache::RbsDescriptor.rbs_gem_entry],
71
- configs: rbs_config_entries + engine_source_entries + [
88
+ configs: rbs_config_entries + engine_source_entries + lockfile_entries(configuration) + [
72
89
  config_entry("configuration", Marshal.dump(configuration.to_h)),
73
90
  config_entry("engine",
74
91
  "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
@@ -94,6 +111,41 @@ module Rigor
94
111
  [config_entry("engine-source", identity)]
95
112
  end
96
113
 
114
+ # Issue #564 — the two dependency lockfiles, BY CONTENT. Both are inputs to the run's diagnostics and
115
+ # neither was represented in the key: the locked gem set decides which bundle-shipped `sig/` dirs and
116
+ # which `rbs_collection` dirs load, which ADR-72 gem overlays apply, what the ADR-82 WD9 missing-gem
117
+ # constant index owns, and whether `rbs.coverage.missing-gem` fires at all. Editing a lockfile touches
118
+ # no analyzed source and no `.rbs` file, so the `paths` slot, the `configuration` slot (which carries
119
+ # the lockfile PATH, never its bytes) and the recorded dependency descriptor were all unchanged by a
120
+ # `bundle add` — and a warm run replayed the pre-edit diagnostics in BOTH directions: an
121
+ # `undefined-method` on `3.minutes` surviving the `bundle add activesupport` that licenses the overlay,
122
+ # and the same call staying silent after the `bundle remove` that revokes it.
123
+ #
124
+ # A KEY slot rather than a dependency entry, because a dependency descriptor can only say "a file I
125
+ # recorded changed": it cannot express a lockfile that did not exist when the entry was written and
126
+ # does now, which is the `bundle init` / first-`bundle install` case.
127
+ #
128
+ # Content-only — the resolved PATH is deliberately not in the payload, so a checkout that moves (a CI
129
+ # runner's workspace, a renamed directory) still hits with an identical lockfile.
130
+ def lockfile_entries(configuration)
131
+ bundler = Environment::LockfileResolver.resolve_lockfile_path(
132
+ lockfile_path: configuration.bundler_lockfile, auto_detect: configuration.bundler_auto_detect
133
+ )
134
+ collection = Environment::RbsCollectionDiscovery.resolve_lockfile_path(
135
+ lockfile_path: configuration.rbs_collection_lockfile,
136
+ auto_detect: configuration.rbs_collection_auto_detect
137
+ )
138
+ [lockfile_entry("bundler.lockfile", bundler), lockfile_entry("rbs_collection.lockfile", collection)]
139
+ end
140
+
141
+ # `nil` — no lockfile resolves — is itself part of the identity, so it carries its own sentinel rather
142
+ # than dropping the slot: "absent" and "present but empty" must not key the same.
143
+ ABSENT_LOCKFILE = "\0absent"
144
+
145
+ def lockfile_entry(key, path)
146
+ config_entry(key, path.nil? ? ABSENT_LOCKFILE : Cache::FileDigest.hexdigest(path.to_s))
147
+ end
148
+
97
149
  def config_entry(key, payload)
98
150
  Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
99
151
  end
@@ -36,13 +36,16 @@ module Rigor
36
36
  # because they do not parse (`[path, first_error_line]` pairs).
37
37
  # @param env_build_failure_snapshot [#call] reader returning the total RBS env-build failure tuple
38
38
  # (`[error_class, first_error_line, conflicting_buffer_names]`) or nil when the env built.
39
+ # @param definition_build_failures_snapshot [#call] issue #696 — reader returning the per-class
40
+ # `RBS::DefinitionBuilder` failures the run observed, as `[class_name, error_class, member,
41
+ # conflicting_buffer_names]` tuples. Empty for a healthy sig set.
39
42
  # @param conformance_results_snapshot [#call] reader.
40
43
  def initialize(configuration:, rbs_extended_reporter:, boundary_cross_reporter:, # rubocop:disable Metrics/ParameterLists
41
44
  source_rbs_synthesis_reporter:, plugin_registry:, dependency_source_index:,
42
45
  pool_mode:, cached_plugin_prepare_diagnostics:,
43
46
  pre_eval_diagnostics_from_scanner:, synthesized_namespaces_snapshot:,
44
47
  quarantined_signatures_snapshot:, env_build_failure_snapshot:,
45
- conformance_results_snapshot:)
48
+ definition_build_failures_snapshot:, conformance_results_snapshot:)
46
49
  @configuration = configuration
47
50
  @rbs_extended_reporter = rbs_extended_reporter
48
51
  @boundary_cross_reporter = boundary_cross_reporter
@@ -55,6 +58,7 @@ module Rigor
55
58
  @synthesized_namespaces_snapshot_reader = synthesized_namespaces_snapshot
56
59
  @quarantined_signatures_snapshot_reader = quarantined_signatures_snapshot
57
60
  @env_build_failure_snapshot_reader = env_build_failure_snapshot
61
+ @definition_build_failures_snapshot_reader = definition_build_failures_snapshot
58
62
  @conformance_results_snapshot_reader = conformance_results_snapshot
59
63
  end
60
64
 
@@ -351,6 +355,32 @@ module Rigor
351
355
  [build_rbs_environment_build_failed_diagnostic(failure)]
352
356
  end
353
357
 
358
+ # Issue #696 — the third rung of the same ladder, between its two neighbours by consequence: a
359
+ # QUARANTINED file removes what one FILE declared, this removes what one CLASS declared, an
360
+ # ENV-BUILD failure removes everything. `RBS::DefinitionBuilder` raising for a class (typically
361
+ # `DuplicatedMethodDefinitionError`: two signature sources declaring the same method, e.g. a project
362
+ # `sig/` carrying a vendored COPY of a bundled plugin's `.rbs`) leaves the class KNOWN but with no
363
+ # method surface, so every call on it — real methods and typos alike — reads `Dynamic[top]`. When
364
+ # the collision is on a class others inherit, the whole bundled type universe goes with it: the
365
+ # observed shape is thousands of stderr warnings, ZERO diagnostics, and exit 0.
366
+ #
367
+ # Not "drop the class to genuinely-unknown" instead. That also removes diagnostics, so it is not
368
+ # louder; it changes `Dynamic[top]` semantics for every rule that consults `class_known?`; and
369
+ # `class_known?` reads {Environment::RbsLoader#known_class_names_set}, a different table from the
370
+ # definition builder, so it would need a new "known but unbuildable" state regardless. The failure
371
+ # is made REPORTABLE; the class is not made invisible.
372
+ #
373
+ # Authored `:warning`, not `:error`, for the same reason as both neighbours: the collision is
374
+ # typically between the user's `sig/` and Rigor's OWN bundled RBS, so an `:error` default would let
375
+ # a Rigor release turn a green build red with no user change (ADR-5 / AGENTS.md § FP discipline).
376
+ # The `reject-unparseable-signatures` bleeding-edge feature promotes it to `:error`.
377
+ def rbs_definition_build_failed_diagnostics
378
+ failures = definition_build_failures_snapshot
379
+ return [] if failures.nil? || failures.empty?
380
+
381
+ [build_rbs_definition_build_failed_diagnostic(failures)]
382
+ end
383
+
354
384
  def rbs_synthesized_namespace_diagnostics
355
385
  synthesized = synthesized_namespaces_snapshot
356
386
  return [] if synthesized.nil? || synthesized.empty?
@@ -462,6 +492,64 @@ module Rigor
462
492
  )
463
493
  end
464
494
 
495
+ # Issue #696. One diagnostic per RUN, not per class: a collision on a widely-inherited class fails
496
+ # every descendant, and 2,709 rows saying the same thing about one `.rbs` line is noise, not
497
+ # visibility.
498
+ #
499
+ # The failed-class list alone is not actionable, and that is what the "First failure" clause is for.
500
+ # A duplicate on `::Object#blank?` fails `String`, `Integer`, `Array` and 1,333 more — every name in
501
+ # that list is a DESCENDANT, and none of them is where the fix goes. The member comes off the error
502
+ # object rather than out of its message ({RbsLoader#definition_build_member}), so it names the
503
+ # culprit and reads the same on a cache hit, where every `RBS::Location` has been dumped to a
504
+ # sentinel. The error class is attributed to that first failure explicitly rather than to all of
505
+ # them: the per-class memo keeps only the first reason, and one run can mix error classes.
506
+ def build_rbs_definition_build_failed_diagnostic(failures)
507
+ sample_size = 5
508
+ files = failures.flat_map { |failure| Array(failure[3]) }.uniq.map { |name| relative_signature_path(name) }
509
+ Diagnostic.new(
510
+ path: ".rigor.yml",
511
+ line: 1,
512
+ column: 1,
513
+ message: "#{failures.size} RBS class definition(s) failed to build: " \
514
+ "#{sampled(failures.map(&:first), sample_size)}." \
515
+ "#{first_failure_clause(failures.first)}#{conflicting_files_clause(files, sample_size)} " \
516
+ "Rigor still treats each class as KNOWN, so calls into it — real methods and typos " \
517
+ "alike — silently read `Dynamic[top]` instead of resolving, and this run is quieter " \
518
+ "than it should be rather than cleaner. Two signature sources declare the same " \
519
+ "member; remove the duplicate declaration (`rbs validate`) to restore type coverage.",
520
+ severity: :warning,
521
+ rule: "rbs.coverage.definition-build-failed",
522
+ source_family: :builtin
523
+ )
524
+ end
525
+
526
+ # `[class_name, error_class, member, buffers]`. The member is nil for the error classes that carry no
527
+ # name at all (`RecursiveAncestorError`), and the clause then names the error class alone rather than
528
+ # inventing a member.
529
+ def first_failure_clause(failure)
530
+ _, error_class, member, = failure
531
+ return " First failure: #{error_class}." if member.nil? || member.empty?
532
+
533
+ " First failure: #{error_class} on `#{member}`."
534
+ end
535
+
536
+ # Identical on a cache HIT: the ADR-54 env cache drops location POSITIONS but keeps each buffer's
537
+ # NAME, so a warm run names the same files a cold one does. Where a name is absent anyway, the
538
+ # reconstructed {RbsLoader::CACHED_LOCATION_BUFFER_NAME} sentinel is filtered out and the clause is
539
+ # omitted, rather than naming `<cached>` as if it were a path.
540
+ def conflicting_files_clause(files, sample_size)
541
+ return "" if files.empty?
542
+
543
+ " Conflicting signature file(s): #{sampled(files, sample_size)}."
544
+ end
545
+
546
+ # "a, b, c, and N more" — the sampling shape every `rbs.coverage.*` message above uses.
547
+ def sampled(names, sample_size)
548
+ sample = names.first(sample_size)
549
+ suffix = names.size > sample_size ? ", and #{names.size - sample_size} more" : ""
550
+ "#{sample.join(', ')}#{suffix}"
551
+ end
552
+
465
553
  # The absolute path is what the loader records; the user thinks in project-relative terms.
466
554
  def relative_signature_path(path)
467
555
  root = "#{Dir.pwd}#{File::SEPARATOR}"
@@ -684,6 +772,10 @@ module Rigor
684
772
  @env_build_failure_snapshot_reader.call
685
773
  end
686
774
 
775
+ def definition_build_failures_snapshot
776
+ @definition_build_failures_snapshot_reader.call
777
+ end
778
+
687
779
  def conformance_results_snapshot
688
780
  @conformance_results_snapshot_reader.call
689
781
  end
@@ -30,11 +30,13 @@ module Rigor
30
30
  #
31
31
  # 1. **Read the envelopes** off the project's own RBS ({RbsExtended::EnvelopeScanner}). A project
32
32
  # with `effects:` but no envelope pays this walk and stops here.
33
- # 2. **Force cross-file discovery**, but only when step 1 found something — the diagnostic is
34
- # positioned at the Ruby `def`, and the discovery tables are what map a method key to one.
35
- # 3. **Judge** ({Effects::EnvelopeCheck}) and render, then run the findings through the ordinary
36
- # suppression filter so `# rigor:disable effect.envelope-exceeded` on the `def` line and
37
- # `disable:` in `.rigor.yml` work exactly as they do for a per-file rule.
33
+ # 2. **Judge** ({Effects::EnvelopeCheck}). Cross-file discovery — the diagnostic is positioned
34
+ # at the Ruby `def`, and the discovery tables are what map a method key to one — is forced
35
+ # from inside the judgment, on the first finding built, so an envelope nothing exceeds costs
36
+ # no discovery parse at all.
37
+ # 3. **Render**, then run the findings through the ordinary suppression filter so
38
+ # `# rigor:disable effect.envelope-exceeded` on the `def` line and `disable:` in `.rigor.yml`
39
+ # work exactly as they do for a per-file rule.
38
40
  #
39
41
  # **Why this is not part of the cached run assembly.** ADR-103 WD12 says envelope diagnostics are
40
42
  # recomputed every run from the (possibly cached) summaries and never stored. The `effects:` block
@@ -80,7 +82,8 @@ module Rigor
80
82
  # @param rbs_loader [Rigor::Environment::RbsLoader, nil] the run's loader; nil disables the pass.
81
83
  # @param effect_table [Rigor::Effects::EffectTable] the propagated graph.
82
84
  # @param discovery [#call] forces and returns the cross-file discovery tables as
83
- # `[def_sources, singleton_def_sources, class_sources]`. Called only when an envelope exists.
85
+ # `[def_sources, singleton_def_sources, class_sources]`. Called only when a finding needs a
86
+ # position — a judged-clean envelope never forces it.
84
87
  # @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path.
85
88
  # @param unit_sources [Hash{String => Array<String>}] `Runner#effect_sources` — where each effect
86
89
  # unit is defined, which is what an `effects.envelopes[].match:` path glob selects on. Its paths
@@ -272,9 +275,12 @@ module Rigor
272
275
  )
273
276
  end
274
277
 
275
- # The discovery tables both judgments position their findings from, forced once.
278
+ # The discovery tables both judgments position their findings from behind a deferred value,
279
+ # forced at most once, on the first finding either judgment builds. A clean run (an envelope
280
+ # nothing exceeds — the common CI case) therefore never calls `@discovery` at all, which on a
281
+ # warm run is the difference between a no-op and a Prism parse of every project file.
276
282
  def positions
277
- @positions ||= begin
283
+ @positions ||= Effects::EnvelopeCheck::DeferredPositions.new do
278
284
  def_sources, singleton_def_sources, class_sources = @discovery.call
279
285
  Effects::EnvelopeCheck::Positions.build(
280
286
  def_sources: def_sources, singleton_def_sources: singleton_def_sources,
@@ -126,6 +126,10 @@ module Rigor
126
126
  snapshot_project_signature_state(environment)
127
127
  snapshot_effect_annotation_carrier(environment&.rbs_loader)
128
128
  result = files.flat_map { |path| @analyze_file.call(path, environment) }
129
+ # Issue #696 — AFTER the loop, deliberately. Definition builds are lazy (ADR-54 WD1: per class, on
130
+ # first demand), so a snapshot taken beside the ones above — which run BEFORE `files.flat_map` —
131
+ # would read an empty list on every run, including the ones this diagnostic exists for.
132
+ record_definition_build_failures(environment&.rbs_loader&.definition_build_failures)
129
133
  if @collect_stats
130
134
  loader = environment.rbs_loader
131
135
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
@@ -567,6 +571,11 @@ module Rigor
567
571
  snapshot_effect_annotation_carrier(environment.rbs_loader)
568
572
  diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
569
573
  loader = environment.rbs_loader
574
+ # Issue #696 — this path analyses on the coordinator, so its loader IS the one that reached the
575
+ # failing build, and it must snapshot the same slot the two real paths do. Reached whenever
576
+ # `fork` is unavailable (Windows) and on `--incremental` / effects runs without it: a run that
577
+ # degraded to sequential must not also report less than a sequential run would.
578
+ record_definition_build_failures(loader&.definition_build_failures)
570
579
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
571
580
  @snapshots.signature_paths = loader&.signature_paths || [].freeze
572
581
  @snapshots.quarantined_signatures =
@@ -608,10 +617,35 @@ module Rigor
608
617
  plugin_id: entry.plugin_id, path: entry.path, message: entry.message
609
618
  )
610
619
  end
620
+ # Issue #696. Fetched with a default so an older drain stays compatible, exactly as the line above.
621
+ record_definition_build_failures(drained[:definition_build_failures])
611
622
  end
612
623
 
613
624
  private
614
625
 
626
+ # Issue #696 — accumulate the per-class `RBS::DefinitionBuilder` failures this run observed, deduped
627
+ # by class name and kept in first-seen order.
628
+ #
629
+ # Accumulating rather than assigning is what makes a pooled run say what a sequential one says: each
630
+ # worker holds its OWN loader and its own memo (the `@state` that also gates the stderr banner, which
631
+ # is why the banner repeats per worker — #295), so a class that fails in two workers arrives twice
632
+ # and a class only one worker touched arrives once. The union over the workers is the set the RUN
633
+ # hit; neither worker alone is.
634
+ #
635
+ # NOT gated on `project_signature_paths?`, unlike {#snapshot_project_signature_state}. That gate
636
+ # exists because each condition there forces the otherwise-lazy env build, which would warm the cache
637
+ # on a project with no sig set of its own. Nothing is forced here — this reads what the analysis
638
+ # already demanded — and the failure is not a project-sig-only condition anyway: two BUNDLED sources
639
+ # declaring the same method collapse a class with no `signature_paths:` in sight (the
640
+ # `bigdecimal` / `BigMath` shape, #299). Gating it would suppress exactly the variant the user cannot
641
+ # fix by editing their own `sig/`.
642
+ def record_definition_build_failures(failures)
643
+ return if failures.nil? || failures.empty?
644
+
645
+ @snapshots.definition_build_failures =
646
+ (@snapshots.definition_build_failures + failures).uniq(&:first).freeze
647
+ end
648
+
615
649
  # True when the project declares its own `signature_paths:` (the only place the
616
650
  # qualified-name-without-namespace mistake lives).
617
651
  def project_signature_paths?
@@ -53,17 +53,11 @@ module Rigor
53
53
  # consumes them and so never pays the double parse (see {#discover} + `Runner#ensure_project_discovery`).
54
54
  # The slot names mirror the discovery half of {Result} exactly.
55
55
  Discovery = Data.define(
56
- :discovered_classes,
57
- :discovered_def_nodes,
58
- :discovered_singleton_def_nodes,
59
- :discovered_def_sources,
60
- :discovered_singleton_def_sources,
61
- :discovered_superclasses,
62
- :discovered_includes,
63
- :discovered_class_sources,
64
- :discovered_method_visibilities,
65
- :discovered_methods,
66
- :data_member_layouts,
56
+ :discovered_classes, :discovered_def_nodes, :discovered_def_nestings,
57
+ :discovered_singleton_def_nodes, :discovered_def_sources, :discovered_singleton_def_sources,
58
+ :discovered_superclasses, :discovered_header_nestings, :discovered_includes,
59
+ :discovered_class_sources, :constant_values, :constant_sources, :constant_writes,
60
+ :discovered_method_visibilities, :discovered_methods, :data_member_layouts,
67
61
  :struct_member_layouts
68
62
  )
69
63
 
@@ -156,12 +150,22 @@ module Rigor
156
150
  Discovery.new(
157
151
  discovered_classes: index.fetch(:classes),
158
152
  discovered_def_nodes: def_index.fetch(:def_nodes),
153
+ # Issue #681 — the recorded `Module.nesting` per declared `def`, so a callee's body re-walk
154
+ # resolves its constants the way the declaration that owns it does.
155
+ discovered_def_nestings: def_index.fetch(:def_nestings),
159
156
  discovered_singleton_def_nodes: def_index.fetch(:singleton_def_nodes),
160
157
  discovered_def_sources: def_index.fetch(:def_sources),
161
158
  discovered_singleton_def_sources: def_index.fetch(:singleton_def_sources),
162
159
  discovered_superclasses: def_index.fetch(:superclasses),
160
+ # Issue #682 — the nesting each declaration header is written in, so an ancestor name resolves in
161
+ # the cref Ruby resolves it in rather than by peeling the subclass's own qualified name.
162
+ discovered_header_nestings: def_index.fetch(:header_nestings),
163
163
  discovered_includes: def_index.fetch(:includes),
164
164
  discovered_class_sources: def_index.fetch(:class_sources),
165
+ # Issue #644 — the cross-file value-constant publication table and its write attribution.
166
+ constant_values: def_index.fetch(:constant_values),
167
+ constant_sources: def_index.fetch(:constant_sources),
168
+ constant_writes: def_index.fetch(:constant_writes),
165
169
  discovered_method_visibilities: def_index.fetch(:method_visibilities),
166
170
  discovered_methods: def_index.fetch(:methods),
167
171
  data_member_layouts: def_index.fetch(:data_member_layouts),
@@ -17,12 +17,15 @@ module Rigor
17
17
  class RunSnapshots
18
18
  attr_accessor :class_decl_paths, :signature_paths,
19
19
  :synthesized_namespaces, :quarantined_signatures, :conformance_results,
20
- :env_build_failure, :effect_annotation_carrier
20
+ :env_build_failure, :definition_build_failures, :effect_annotation_carrier
21
21
 
22
22
  # Constructor defaults match the {Runner} constructor: the pre-seed values `build_run_stats` /
23
23
  # `pre_file_diagnostics` read before the first analysis path runs are frozen empties. The
24
24
  # `env_build_failure` slot is nil (no failure) rather than an empty collection — it holds a single
25
- # `[error_class, first_line, buffer_names]` tuple or nothing.
25
+ # `[error_class, first_line, buffer_names]` tuple or nothing. Its per-class sibling
26
+ # `definition_build_failures` (#696) holds a LIST, because a collapsed universe fails many classes,
27
+ # and is accumulated ACROSS pool workers rather than assigned once — see
28
+ # {PoolCoordinator#merge_worker_reporters}.
26
29
  def initialize
27
30
  @class_decl_paths = {}.freeze
28
31
  @signature_paths = [].freeze
@@ -30,6 +33,7 @@ module Rigor
30
33
  @quarantined_signatures = [].freeze
31
34
  @conformance_results = [].freeze
32
35
  @env_build_failure = nil
36
+ @definition_build_failures = [].freeze
33
37
  @effect_annotation_carrier = [].freeze
34
38
  end
35
39
 
@@ -42,6 +46,7 @@ module Rigor
42
46
  @quarantined_signatures = []
43
47
  @conformance_results = []
44
48
  @env_build_failure = nil
49
+ @definition_build_failures = []
45
50
  @effect_annotation_carrier = [].freeze
46
51
  end
47
52
  end