rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  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 +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -8,6 +8,7 @@ require_relative "environment/rbs_loader"
8
8
  require_relative "environment/reflection"
9
9
  require_relative "environment/reporters"
10
10
  require_relative "environment/hkt_registry_holder"
11
+ require_relative "environment/failure_slot"
11
12
  require_relative "environment/constant_type_cache_holder"
12
13
  require_relative "environment/missing_gem_constant_index"
13
14
  require_relative "environment/bundle_sig_discovery"
@@ -38,9 +39,9 @@ module Rigor
38
39
  # ADR-87 WD4 boot-slimming probe can read it without loading the inference engine.
39
40
 
40
41
  # ADR-72 — a Gemfile.lock gem name mapped to the opt-in plugin id that ships the SAME core-ext RBS. When
41
- # that plugin is loaded the auto-overlay for the gem stands down, so the two never both declare the
42
- # methods (which would raise a duplicate-declaration error). Keyed on the gem name `RbsCoverageReport`
43
- # reports.
42
+ # that plugin's signatures are reachable the auto-overlay for the gem stands down, so the two never both
43
+ # declare the methods (which would raise a duplicate-declaration error). Keyed on the gem name
44
+ # `RbsCoverageReport` reports.
44
45
  GEM_OVERLAY_PLUGIN_IDS = { "activesupport" => "activesupport-core-ext" }.freeze
45
46
 
46
47
  attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
@@ -88,6 +89,10 @@ module Rigor
88
89
  # --no-stats` from doing the RBS env build at all.
89
90
  @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
90
91
  @hkt_registry_holder = HktRegistryHolder.new
92
+ # Issue #784 — where either stage of the HKT-registry build (the plugin overlay, #791; the RBS
93
+ # `type`-alias scan) records a raise, so it surfaces once for the run instead of once per file — or,
94
+ # for the overlay, instead of aborting the run outright (see {#hkt_registry}).
95
+ @hkt_scan_failure = FailureSlot.new
91
96
  @constant_type_cache = ConstantTypeCacheHolder.new
92
97
  # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
93
98
  # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
@@ -104,21 +109,93 @@ module Rigor
104
109
  # beat plugin entries, which beat the bundled JSON_VALUE. Memoised; single-threaded use only (under the
105
110
  # Ractor pool path each worker has its own Environment so cross-worker mutation is impossible; the LSP
106
111
  # single-publish-at-a-time invariant serialises here).
112
+ #
113
+ # BOTH stages are guarded (#784, #791): whichever raises is recorded on {#hkt_scan_failure} with the
114
+ # stage that failed and the other stage still runs, so this getter never raises into its callers —
115
+ # neither into a file's `analyze_body` rescue nor out of the run-owned demands, which have no rescue
116
+ # above them at all.
107
117
  def hkt_registry
108
118
  @hkt_registry_holder.fetch do
109
- with_plugin_overlay = if @plugin_registry.respond_to?(:hkt_overlay_registry)
110
- @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
111
- else
112
- @hkt_registry_base
113
- end
114
- Inference::HktRegistry.scan_rbs_loader(
115
- @rbs_loader,
116
- base: with_plugin_overlay,
117
- reporter: rbs_extended_reporter
118
- )
119
+ pre_scan = pre_scan_hkt_registry
120
+ begin
121
+ Inference::HktRegistry.scan_rbs_loader(
122
+ @rbs_loader,
123
+ base: pre_scan,
124
+ reporter: rbs_extended_reporter
125
+ )
126
+ rescue StandardError => e
127
+ # Issue #784 — the seam. This build is shared and memoised, and it is first demanded from inside
128
+ # a file's analysis, so a raise here would otherwise land in every file's `analyze_body` rescue
129
+ # and turn the whole run into N identical `internal analyzer error` rows (issue #776). Record it
130
+ # and degrade to the pre-scan registry: analysis proceeds, HKT inference from RBS `type` aliases
131
+ # reads its bound (`Dynamic[top]`, ADR-20 WD2), and the run surfaces ONE
132
+ # `rbs.coverage.hkt-scan-failed` row. Not a silent skip — the row is `:error` and names the
133
+ # exception and raise site — and the same shape as `RbsLoader#record_env_build_failure`.
134
+ # Memoising the degraded registry is what keeps the scan from being re-attempted per file.
135
+ record_hkt_registry_failure(e, stage: :scan)
136
+ pre_scan
137
+ end
119
138
  end
120
139
  end
121
140
 
141
+ # Issue #791 — the OTHER build behind {#hkt_registry}, and the reason the seam covers two stages
142
+ # rather than one. Aggregating the loaded plugins' manifest-declared HKT entries runs plugin-authored
143
+ # code (`Plugin::Registry#hkt_overlay_registry` reads each manifest); before this it sat one line ABOVE
144
+ # the #784 rescue, so a raise there escaped {#hkt_registry} entirely. Post-#788 that is not a per-file
145
+ # crash storm but an uncaught abort: {HktRegistryHolder#fetch} memoises only on success, so the raise
146
+ # is re-attempted at every demand, and the run-owned demands (`Runner::PoolCoordinator#hkt_scan_outcome`
147
+ # after the file loop, `Analysis::WorkerSession#drain_reporters`) sit outside any rescue.
148
+ #
149
+ # The degradation is narrower than the scan's: only the plugin overlay is dropped, and the RBS scan
150
+ # still runs on top of the bundled base, so a user's own `.rbs` HKT registrations survive a plugin
151
+ # defect. First-write-wins on the slot means a run whose overlay AND scan both raise reports the
152
+ # overlay — the actionable one, since the plugin is what a user can remove.
153
+ #
154
+ # @return [Rigor::Inference::HktRegistry] the registry the RBS scan is layered on top of.
155
+ def pre_scan_hkt_registry
156
+ return @hkt_registry_base unless @plugin_registry.respond_to?(:hkt_overlay_registry)
157
+
158
+ @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
159
+ rescue StandardError => e
160
+ record_hkt_registry_failure(e, stage: :overlay)
161
+ @hkt_registry_base
162
+ end
163
+ private :pre_scan_hkt_registry
164
+
165
+ # Issue #784 — the `[error_class_name, first_message_line, raw_frame_or_nil, stage]` tuple the seam in
166
+ # {#hkt_registry} recorded, or nil when both stages built (or were never demanded). `stage` is `:scan`
167
+ # for the RBS `type`-alias scan and `:overlay` for the plugin-manifest aggregation (#791); it decides
168
+ # the row's wording, because "the implicit HKT scan over RBS `type` aliases raised" would point a user
169
+ # at their own `.rbs` for a plugin's defect. The run reads the tuple only after demanding
170
+ # {#hkt_registry} itself — after the file loop on the sequential paths, at drain time in each pool
171
+ # worker (the coordinator never analyses a file under the pool, so the drain is how its snapshot learns
172
+ # the outcome: the #696 lesson), and from a resolved environment when the analyze set is empty — so the
173
+ # row never depends on which files happened to be analysed.
174
+ #
175
+ # @return [Array(String, String, String, Symbol), Array(String, String, nil, Symbol), nil]
176
+ def hkt_scan_failure
177
+ @hkt_scan_failure.value
178
+ end
179
+
180
+ def record_hkt_registry_failure(error, stage:)
181
+ frames = error.backtrace || []
182
+ frame = frames.find { |f| f.include?("/lib/rigor/") } || frames.first
183
+ # `Class#name` is nil for an anonymous exception class; its nearest NAMED ancestor is the stable
184
+ # description (`inspect` embeds an object address, which would make the row's text differ across
185
+ # processes). Each String is frozen individually, not just the Array: the tuple crosses the fork
186
+ # boundary Marshal-clean and the drain channel's stated invariant
187
+ # ({Analysis::WorkerSession#drain_reporters}) is that its payload is also `Ractor.shareable?`, which a
188
+ # shallow freeze over `chomp`'s fresh String would not satisfy.
189
+ named = error.class.ancestors.find { |a| a.is_a?(Class) && a.name }
190
+ @hkt_scan_failure.record([
191
+ (error.class.name || "anonymous #{named&.name || 'Exception'}").dup.freeze,
192
+ error.message.to_s.lines.first.to_s.chomp.freeze,
193
+ frame&.dup&.freeze,
194
+ stage
195
+ ])
196
+ end
197
+ private :record_hkt_registry_failure
198
+
122
199
  # ADR-82 WD9 — the gem name owning `root_constant_name`, when that gem is locked in the project's
123
200
  # Gemfile.lock, ships no resolvable RBS, and its entry file declares the constant at top level. Nil for
124
201
  # everything else — the caller then keeps the generic provenance cause. The index is built once, lazily,
@@ -190,7 +267,7 @@ module Rigor
190
267
  # {Environment::RbsLoader} so constant lookups (and, in later v0.0.9 slices, other reflection
191
268
  # artefacts) consult the cache. Pass `nil` (the default) to skip caching for this environment.
192
269
  # @return [Rigor::Environment]
193
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
270
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
194
271
  def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
195
272
  plugin_registry: nil, dependency_source_index: nil,
196
273
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
@@ -253,11 +330,13 @@ module Rigor
253
330
  # extensions resolve (e.g. `Integer#minutes` on a Rails project) — turning a systematic false
254
331
  # `call.undefined-method` into a no-op while a project WITHOUT the gem still sees the genuine
255
332
  # diagnostic. Appended last so any RBS the project already supplies wins, and skipped for a gem whose
256
- # opt-in plugin twin is loaded (no duplicate declaration). The paths ride in
257
- # `loader_signature_paths`, so the env cache descriptor digests them for free.
333
+ # opt-in plugin twin is REACHABLE — loaded as a plugin, or wired in by `signature_paths:` (#672) —
334
+ # so the two never both declare the methods. The paths ride in `loader_signature_paths`, so the env
335
+ # cache descriptor digests them for free.
258
336
  missing_gems, overlay_paths = missing_gems_and_overlay_paths(
259
337
  locked: locked, default_libraries: merged_libraries, bundle_sig_paths: gem_sig_paths,
260
- rbs_collection_paths: collection_paths, plugin_registry: plugin_registry
338
+ rbs_collection_paths: collection_paths, plugin_registry: plugin_registry,
339
+ signature_paths: resolved_paths
261
340
  )
262
341
  loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths +
263
342
  collection_paths + overlay_paths
@@ -276,7 +355,11 @@ module Rigor
276
355
  libraries: merged_libraries,
277
356
  signature_paths: loader_signature_paths,
278
357
  cache_store: cache_store,
279
- virtual_rbs: virtual_rbs
358
+ virtual_rbs: virtual_rbs,
359
+ # Issue #610 — the plugin-contributed subset is loaded LAST and may stand down against a colliding
360
+ # generic arity. Only the bundled plugins' own `sig/` is deferred: the user's `signature_paths:`,
361
+ # the bundle walk and `rbs collection install` all stay authoritative.
362
+ deferred_signature_paths: plugin_sig_paths
280
363
  )
281
364
  # ADR-20 slice 2c + 2e — seed hkt_registry with the bundled builtins. The Environment's
282
365
  # `#hkt_registry` getter then LAZILY merges in the RBS env scan on first call so fast paths that
@@ -297,7 +380,6 @@ module Rigor
297
380
  missing_rbs_bundle_path: bundle_root
298
381
  )
299
382
  end
300
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
301
383
 
302
384
  private
303
385
 
@@ -330,27 +412,45 @@ module Rigor
330
412
  # and consumed twice: as `[gem_name, version]` pairs for the ADR-82 WD9 constant-ownership index, and
331
413
  # as the eligible-gem set for the ADR-72 overlay resolution. Returns `[pairs, overlay_paths]`.
332
414
  def missing_gems_and_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
333
- rbs_collection_paths:, plugin_registry:)
415
+ rbs_collection_paths:, plugin_registry:, signature_paths: [])
334
416
  return [[], []] if locked.empty?
335
417
 
336
418
  rows = RbsCoverageReport.classify(
337
419
  locked_gems: locked, default_libraries: default_libraries,
338
420
  bundle_sig_paths: bundle_sig_paths, rbs_collection_paths: rbs_collection_paths
339
421
  ).select { |row| row.source == :missing }
340
- overlays = gem_overlay_paths(missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry)
422
+ overlays = gem_overlay_paths(
423
+ missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry,
424
+ signature_paths: signature_paths
425
+ )
341
426
  [rows.map { |row| [row.gem_name, row.version] }, overlays]
342
427
  end
343
428
 
344
429
  # ADR-72 — resolve the bundled RBS overlay directories to load for this project. A gem is eligible when
345
- # it is classified `:missing` (see {missing_rbs_gem_rows}) and its conflicting opt-in plugin (if any)
346
- # is not loaded. Returns `[Pathname]`, deterministically ordered, or `[]` when no eligible gem.
347
- def gem_overlay_paths(missing_gem_names:, plugin_registry:)
430
+ # it is classified `:missing` (see {missing_rbs_gem_rows}) and its conflicting opt-in plugin twin (if
431
+ # any) is NOT already reachable. Returns `[Pathname]`, deterministically ordered, or `[]` when no
432
+ # eligible gem.
433
+ #
434
+ # "Reachable" is two questions, not one. The plugin being LOADED is the route ADR-72 anticipated. The
435
+ # second is the route issue #672 found: a project that wires the plugin's signatures through
436
+ # `signature_paths:` instead of `plugins:` reaches the same `.rbs` with no registry entry to key on, so
437
+ # both halves loaded, `RBS::DefinitionBuilder` raised `DuplicatedMethodDefinitionError` for every class
438
+ # they share, and each one silently degraded to `Dynamic[top]` — the run reported LESS while exiting 0.
439
+ #
440
+ # This method keeps the POLICY (which gem maps to which plugin id, and when an overlay is eligible at
441
+ # all); `RbsLoader.bundled_overlay_twin_signatures_loaded?` answers the filesystem half, beside the
442
+ # overlay-side answer to the same question, and carries the reasoning for why it asks what an entry
443
+ # LOADS rather than what it looks like.
444
+ def gem_overlay_paths(missing_gem_names:, plugin_registry:, signature_paths: [])
348
445
  return [] if missing_gem_names.empty?
349
446
 
350
447
  loaded_ids = plugin_registry ? plugin_registry.ids.to_set : Set.new
351
448
  eligible = missing_gem_names.reject do |gem_name|
352
449
  plugin_id = GEM_OVERLAY_PLUGIN_IDS[gem_name]
353
- plugin_id && loaded_ids.include?(plugin_id)
450
+ next false unless plugin_id
451
+
452
+ loaded_ids.include?(plugin_id) ||
453
+ RbsLoader.bundled_overlay_twin_signatures_loaded?(plugin_id, signature_paths)
354
454
  end.sort
355
455
  RbsLoader.gem_overlay_sig_paths(eligible)
356
456
  end
@@ -23,7 +23,7 @@ module Rigor
23
23
  # `Nominal[Hash, [K, V]]` the shape is projected to its underlying nominal so the existing
24
24
  # generic-acceptance pipeline continues to apply; the converse direction (a Tuple receiver accepting a
25
25
  # generic Array) stays conservative because the analyzer cannot verify arity from a raw nominal alone.
26
- # rubocop:disable Metrics/ModuleLength
26
+ # rubocop:disable-next Metrics/ModuleLength
27
27
  module Acceptance
28
28
  module_function
29
29
 
@@ -67,11 +67,15 @@ module Rigor
67
67
  Type::Refined => :accepts_refined,
68
68
  Type::Intersection => :accepts_intersection,
69
69
  Type::Tuple => :accepts_tuple,
70
- Type::HashShape => :accepts_hash_shape
70
+ Type::HashShape => :accepts_hash_shape,
71
+ Type::Result => :accepts_result,
72
+ Type::Maybe => :accepts_maybe,
73
+ Type::StructClass => :accepts_class_factory,
74
+ Type::DataClass => :accepts_class_factory
71
75
  }.freeze
72
76
  private_constant :TYPE_HANDLERS
73
77
 
74
- # rubocop:disable Metrics/ClassLength
78
+ # rubocop:disable-next Metrics/ClassLength
75
79
  class << self
76
80
  private
77
81
 
@@ -165,20 +169,28 @@ module Rigor
165
169
  end
166
170
  end
167
171
 
168
- # Singleton[C] only accepts another Singleton[D] where D is a subclass of (or equal to) C. Any other
169
- # carrier (instance, constant, ...) is no, because the singleton type's inhabitants are the class
170
- # objects themselves.
172
+ # Singleton[C] only accepts another Singleton[D] (or StructClass / DataClass) where D is a subclass
173
+ # of (or equal to) C. Any other carrier (instance, constant, ...) is no, because the singleton type's
174
+ # inhabitants are the class objects themselves.
171
175
  def accepts_singleton(self_type, other_type, mode)
172
- unless other_type.is_a?(Type::Singleton)
173
- return Type::AcceptsResult.no(
174
- mode: mode,
175
- reasons: "Singleton[#{self_type.class_name}] does not accept #{other_type.class}"
176
- )
177
- end
176
+ actual_name =
177
+ case other_type
178
+ when Type::Singleton then other_type.class_name
179
+ when Type::StructClass, Type::DataClass
180
+ return Type::AcceptsResult.yes(mode: mode, reasons: "exact name match") if
181
+ other_type.class_name == self_type.class_name
182
+
183
+ class_factory_base_name(other_type)
184
+ else
185
+ return Type::AcceptsResult.no(
186
+ mode: mode,
187
+ reasons: "Singleton[#{self_type.class_name}] does not accept #{other_type.class}"
188
+ )
189
+ end
178
190
 
179
191
  class_subtype_result(
180
192
  target_name: self_type.class_name,
181
- actual_name: other_type.class_name,
193
+ actual_name: actual_name,
182
194
  mode: mode,
183
195
  kind: :singleton
184
196
  )
@@ -195,11 +207,13 @@ module Rigor
195
207
  # - HashShape{*} when self is the Hash (or a supertype) family, projected to
196
208
  # `Nominal[Hash, [union(keys), union(values)]]`.
197
209
  # - Singleton: never (wrong value kind).
210
+ # - StructClass / DataClass: class-side carriers, accepted when C is a meta nominal (Class, Module, ...).
198
211
  def accepts_nominal(self_type, other_type, mode)
199
212
  case other_type
200
213
  when Type::Nominal then accepts_nominal_from_nominal(self_type, other_type, mode)
201
214
  when Type::Constant then accepts_nominal_from_constant(self_type, other_type, mode)
202
215
  when Type::Singleton then accepts_nominal_from_singleton(self_type, other_type, mode)
216
+ when Type::StructClass, Type::DataClass then accepts_nominal_from_class_factory(self_type, other_type, mode)
203
217
  when Type::IntegerRange then accepts_nominal_from_integer_range(self_type, other_type, mode)
204
218
  else accepts_nominal_from_shape(self_type, other_type, mode)
205
219
  end
@@ -234,6 +248,8 @@ module Rigor
234
248
  # is bounded because every refinement carrier's `base` is closer to the nominal layer.
235
249
  accepts(self_type, other_type.base, mode: mode)
236
250
  .with_reason("projected #{other_type.class.name.split('::').last} to its base")
251
+ when Type::Result, Type::Maybe
252
+ accepts_nominal_from_monad(self_type, other_type, mode)
237
253
  else
238
254
  Type::AcceptsResult.no(
239
255
  mode: mode,
@@ -242,6 +258,32 @@ module Rigor
242
258
  end
243
259
  end
244
260
 
261
+ def accepts_nominal_from_monad(self_type, other_type, mode)
262
+ cls = self_type.class_name.sub(/\A::/, "")
263
+ case other_type
264
+ when Type::Result
265
+ unless %w[Dry::Monads::Result Result].include?(cls)
266
+ return Type::AcceptsResult.no(mode: mode, reasons: "Nominal[#{self_type.class_name}] rejects Result")
267
+ end
268
+
269
+ target = Type::Combinator.nominal_of(
270
+ self_type.class_name,
271
+ type_args: [other_type.ok_type, other_type.err_type]
272
+ )
273
+ accepts(self_type, target, mode: mode).with_reason("projected Result to Nominal")
274
+ when Type::Maybe
275
+ unless %w[Dry::Monads::Maybe Maybe].include?(cls)
276
+ return Type::AcceptsResult.no(mode: mode, reasons: "Nominal[#{self_type.class_name}] rejects Maybe")
277
+ end
278
+
279
+ target = Type::Combinator.nominal_of(
280
+ self_type.class_name,
281
+ type_args: [other_type.value_type]
282
+ )
283
+ accepts(self_type, target, mode: mode).with_reason("projected Maybe to Nominal")
284
+ end
285
+ end
286
+
245
287
  # `Nominal[Integer]` (and anything Integer is-a, like Numeric) accepts any `IntegerRange`; nothing
246
288
  # else does. Argument-bearing `Nominal`s never accept `IntegerRange` because IntegerRange has no
247
289
  # type args.
@@ -275,11 +317,11 @@ module Rigor
275
317
  # introspection patterns. The rule conservatively answers `:yes` for `Module` (every singleton is at
276
318
  # least a Module) and for `Class` / `Object` / `BasicObject` (the class object inherits from those).
277
319
  # Other Nominals fall through to the default `:no`.
278
- META_NOMINALS_FROM_SINGLETON = %w[Module Class Object BasicObject].freeze
279
- private_constant :META_NOMINALS_FROM_SINGLETON
320
+ CLASS_OBJECT_NOMINALS = %w[Module Class Object Kernel BasicObject].freeze
321
+ private_constant :CLASS_OBJECT_NOMINALS
280
322
 
281
323
  def accepts_nominal_from_singleton(self_type, other_type, mode)
282
- if META_NOMINALS_FROM_SINGLETON.include?(self_type.class_name)
324
+ if CLASS_OBJECT_NOMINALS.include?(self_type.class_name)
283
325
  return Type::AcceptsResult.yes(
284
326
  mode: mode,
285
327
  reasons: "Singleton[#{other_type.class_name}] is-a #{self_type.class_name}"
@@ -292,6 +334,38 @@ module Rigor
292
334
  )
293
335
  end
294
336
 
337
+ def accepts_nominal_from_class_factory(self_type, other_type, mode)
338
+ if CLASS_OBJECT_NOMINALS.include?(self_type.class_name) && self_type.type_args.empty?
339
+ return Type::AcceptsResult.yes(
340
+ mode: mode,
341
+ reasons: "#{other_type.describe} is-a #{self_type.class_name}"
342
+ )
343
+ end
344
+
345
+ if self_type.class_name == "Class" && self_type.type_args.one?
346
+ return accepts_class_factory_instance_arg(self_type.type_args.first, other_type, mode)
347
+ end
348
+
349
+ Type::AcceptsResult.no(
350
+ mode: mode,
351
+ reasons: "Nominal[#{self_type.class_name}] rejects #{other_type.class}"
352
+ )
353
+ end
354
+
355
+ def accepts_class_factory_instance_arg(formal, factory, mode)
356
+ if factory.class_name
357
+ named = accepts(formal, Type::Combinator.nominal_of(factory.class_name), mode: mode)
358
+ return named.with_reason("matched the factory class name") if named.yes?
359
+ end
360
+
361
+ accepts(formal, Type::Combinator.nominal_of(class_factory_base_name(factory)), mode: mode)
362
+ .with_reason("projected the factory instance type")
363
+ end
364
+
365
+ def class_factory_base_name(factory)
366
+ factory.is_a?(Type::StructClass) ? "Struct" : "Data"
367
+ end
368
+
295
369
  def accepts_nominal_from_nominal(self_type, other_type, mode)
296
370
  class_result = class_subtype_result(
297
371
  target_name: self_type.class_name,
@@ -753,6 +827,63 @@ module Rigor
753
827
  Type::AcceptsResult.no(mode: mode, reasons: reason)
754
828
  end
755
829
 
830
+ def accepts_result(self_type, other_type, mode)
831
+ case other_type
832
+ when Type::Result
833
+ combine_arg_results(
834
+ [
835
+ accepts(self_type.ok_type, other_type.ok_type, mode: mode),
836
+ accepts(self_type.err_type, other_type.err_type, mode: mode)
837
+ ],
838
+ mode
839
+ )
840
+ when Type::Nominal
841
+ cls = other_type.class_name.sub(/\A::/, "")
842
+ if %w[Dry::Monads::Result Result].include?(cls) && other_type.type_args.size == 2
843
+ combine_arg_results(
844
+ [
845
+ accepts(self_type.ok_type, other_type.type_args[0], mode: mode),
846
+ accepts(self_type.err_type, other_type.type_args[1], mode: mode)
847
+ ],
848
+ mode
849
+ )
850
+ else
851
+ Type::AcceptsResult.no(mode: mode, reasons: "Result does not accept Nominal[#{other_type.class_name}]")
852
+ end
853
+ else
854
+ Type::AcceptsResult.no(
855
+ mode: mode,
856
+ reasons: "Result does not accept #{other_type.class}"
857
+ )
858
+ end
859
+ end
860
+
861
+ def accepts_maybe(self_type, other_type, mode)
862
+ case other_type
863
+ when Type::Maybe
864
+ accepts(self_type.value_type, other_type.value_type, mode: mode)
865
+ when Type::Nominal
866
+ cls = other_type.class_name.sub(/\A::/, "")
867
+ if %w[Dry::Monads::Maybe Maybe].include?(cls) && other_type.type_args.size == 1
868
+ accepts(self_type.value_type, other_type.type_args[0], mode: mode)
869
+ else
870
+ Type::AcceptsResult.no(mode: mode, reasons: "Maybe does not accept Nominal[#{other_type.class_name}]")
871
+ end
872
+ else
873
+ Type::AcceptsResult.no(
874
+ mode: mode,
875
+ reasons: "Maybe does not accept #{other_type.class}"
876
+ )
877
+ end
878
+ end
879
+
880
+ def accepts_class_factory(self_type, other_type, mode)
881
+ Type::AcceptsResult.no(
882
+ mode: mode,
883
+ reasons: "#{self_type.describe} rejects #{other_type.class}"
884
+ )
885
+ end
886
+
756
887
  # Uses Ruby's actual class hierarchy via Object.const_get to answer "is D a subclass of C?" for core,
757
888
  # stdlib, and application classes. When either name fails to resolve we surface "maybe": the caller
758
889
  # (overload selector) treats yes/maybe identically, so the conservative answer keeps overload
@@ -791,14 +922,35 @@ module Rigor
791
922
  end
792
923
  end
793
924
 
925
+ # Resolves a class name taken from the analysed program without triggering `const_missing`
926
+ # or executing a pending autoload.
927
+ #
928
+ # `const_defined?(part, false)` answers true for a REGISTERED-BUT-NOT-YET-TRIGGERED autoload,
929
+ # so on its own it does not mean "already in memory" and `const_get` would execute the target
930
+ # file inside the analyzer. `Module#autoload?` separates the two: it returns the registered
931
+ # path while the autoload is pending and nil once it has run, so declining on it declines
932
+ # exactly the dangerous case and still resolves a class genuinely loaded ([#680], [#689]).
933
+ #
934
+ # The rescue catches `StandardError`, `ScriptError` and `SystemExit`. `Interrupt`,
935
+ # `SignalException` and `NoMemoryError` stay uncaught on purpose.
794
936
  def resolve_class(name)
795
- Object.const_get(name)
796
- rescue NameError
937
+ return nil unless name.is_a?(::String)
938
+
939
+ parts = name.delete_prefix("::").split("::")
940
+ return nil if parts.empty?
941
+
942
+ mod = ::Object
943
+ parts.each do |part|
944
+ return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
945
+ return nil if mod.autoload?(part)
946
+
947
+ mod = mod.const_get(part, false)
948
+ end
949
+ mod.is_a?(::Module) ? mod : nil
950
+ rescue ::StandardError, ::ScriptError, ::SystemExit
797
951
  nil
798
952
  end
799
953
  end
800
- # rubocop:enable Metrics/ClassLength
801
954
  end
802
- # rubocop:enable Metrics/ModuleLength
803
955
  end
804
956
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_walker"
6
+ require_relative "block_parameter_binder"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # The outer locals a block body can REBIND — the one name set that ADR-56's captured-local write-back
11
+ # (`StatementEvaluator#write_back_block_captures`), the escaping-block narrowing drop
12
+ # (`StatementEvaluator#drop_captured_narrowing`) and issue #587's per-element fold
13
+ # (`ExpressionTyper#per_element_captured_bindings`) all key on. It lives in one place so the four
14
+ # cannot disagree about what "captured" means.
15
+ #
16
+ # A write counts across every local-write form — plain `=` (`LocalVariableWriteNode`), the operator /
17
+ # `||=` / `&&=` compound forms, and a multi-assign target (`x, y = …` → `LocalVariableTargetNode` under
18
+ # a `MultiWriteNode`) — at ANY depth: a block is a closure, so a write inside a nested block binds the
19
+ # same outer variable. Block-introduced names (parameters, numbered parameters, `;`-locals) and names
20
+ # not bound in the outer scope are excluded; a write to either is not a captured rebind of an outer
21
+ # variable.
22
+ module CapturedLocals
23
+ LOCAL_WRITE_NODES = [
24
+ Prism::LocalVariableWriteNode,
25
+ Prism::LocalVariableOperatorWriteNode,
26
+ Prism::LocalVariableOrWriteNode,
27
+ Prism::LocalVariableAndWriteNode,
28
+ Prism::LocalVariableTargetNode
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ # @param block_node [Prism::BlockNode]
34
+ # @param base_scope [Rigor::Scope] the call-site scope the block closes over.
35
+ # @return [Array<Symbol>] the captured names the body writes, each once, in first-write order.
36
+ def writes(block_node, base_scope)
37
+ body = block_node.body
38
+ return [] if body.nil?
39
+
40
+ introduced = introduced_locals(block_node)
41
+ outer_writes = []
42
+ Source::NodeWalker.each(body) do |descendant|
43
+ next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
44
+ next if introduced.include?(descendant.name)
45
+ next unless base_scope.locals.key?(descendant.name)
46
+
47
+ outer_writes << descendant.name
48
+ end
49
+ outer_writes.uniq
50
+ end
51
+
52
+ # Names the block itself introduces: parameters (numbered parameters included, via
53
+ # `BlockParameterBinder`) plus the explicit `;`-prefixed block-locals on `BlockParametersNode`.
54
+ def introduced_locals(block_node)
55
+ introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
56
+ params_root = block_node.parameters
57
+ params_root.locals.each { |loc| introduced << loc.name } if params_root.is_a?(Prism::BlockParametersNode)
58
+ introduced
59
+ end
60
+ end
61
+ end
62
+ end