rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -10,9 +10,11 @@ require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
11
  require_relative "anonymous_meta_class"
12
12
  require_relative "definer_resolution"
13
+ require_relative "block_call_timing"
13
14
  require_relative "block_repetition"
14
15
  require_relative "block_parameter_binder"
15
16
  require_relative "body_fixpoint"
17
+ require_relative "callee_mutation_chain"
16
18
  require_relative "captured_locals"
17
19
  require_relative "dynamic_origin"
18
20
  require_relative "error_info"
@@ -28,7 +30,9 @@ require_relative "define_method_block_self"
28
30
  require_relative "macro_block_self_type"
29
31
  require_relative "guard_rebinding"
30
32
  require_relative "self_call_ivar_writes"
33
+ require_relative "intervening_ivar_reset"
31
34
  require_relative "match_rebinding"
35
+ require_relative "clause_widening"
32
36
  require_relative "element_read_widening"
33
37
  require_relative "hash_lookup_mutation"
34
38
  require_relative "indexed_narrowing"
@@ -42,6 +46,8 @@ require_relative "narrowing"
42
46
  require_relative "operand_effects"
43
47
  require_relative "operand_walk"
44
48
  require_relative "optimistic_origin"
49
+ require_relative "loop_value_record"
50
+ require_relative "project_yielder"
45
51
  require_relative "return_barrier"
46
52
  require_relative "repeated_or_writes"
47
53
  require_relative "rewrite_mutation"
@@ -114,8 +120,8 @@ module Rigor
114
120
  # the default expression pair, so routing the or-writes here adds the body entry and nothing else.
115
121
  Prism::ConstantOrWriteNode => :eval_constant_write,
116
122
  Prism::ConstantPathOrWriteNode => :eval_constant_write,
117
- Prism::IfNode => :eval_if,
118
- Prism::UnlessNode => :eval_unless,
123
+ Prism::IfNode => :eval_recorded_if,
124
+ Prism::UnlessNode => :eval_recorded_unless,
119
125
  Prism::ElseNode => :eval_else,
120
126
  Prism::CaseNode => :eval_case,
121
127
  Prism::CaseMatchNode => :eval_case,
@@ -124,9 +130,9 @@ module Rigor
124
130
  Prism::BeginNode => :eval_begin_node,
125
131
  Prism::RescueNode => :eval_rescue,
126
132
  Prism::EnsureNode => :eval_ensure,
127
- Prism::WhileNode => :eval_repeating_loop,
128
- Prism::UntilNode => :eval_repeating_loop,
129
- Prism::ForNode => :eval_repeating_for,
133
+ Prism::WhileNode => :eval_recorded_loop,
134
+ Prism::UntilNode => :eval_recorded_loop,
135
+ Prism::ForNode => :eval_recorded_for,
130
136
  Prism::AndNode => :eval_and_or,
131
137
  Prism::OrNode => :eval_and_or,
132
138
  Prism::ParenthesesNode => :eval_parentheses,
@@ -183,6 +189,40 @@ module Rigor
183
189
  RETURN_SINK_KEY = :rigor_return_sink
184
190
  private_constant :RETURN_SINK_KEY
185
191
 
192
+ # Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
193
+ # than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
194
+ PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
195
+ private_constant :PROVISIONAL_RETURNS_KEY
196
+
197
+ # The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
198
+ # with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
199
+ # binding of as `Dynamic[top]`. A provisional record of a `ReturnNode` therefore yields to the walk's record of
200
+ # the SAME node, and stands only where the walk never reaches that node: a block in expression position (a call
201
+ # argument, an array element, a chain receiver, a `return` value, an endless-def body), whose tail the side pass
202
+ # is the only walk to type.
203
+ class ReturnSink
204
+ def initialize
205
+ @types = []
206
+ @recorded = Set.new.compare_by_identity
207
+ @provisional = []
208
+ end
209
+
210
+ def record(node, type)
211
+ if Thread.current[PROVISIONAL_RETURNS_KEY]
212
+ @provisional << [node, type]
213
+ else
214
+ @types << type
215
+ @recorded << node
216
+ end
217
+ end
218
+
219
+ # The walk's records, then each provisional record whose node the walk did not record.
220
+ def types
221
+ @types + @provisional.filter_map { |node, type| type unless @recorded.include?(node) }
222
+ end
223
+ end
224
+ private_constant :ReturnSink
225
+
186
226
  # Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
187
227
  # leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
188
228
  # block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
@@ -310,14 +350,31 @@ module Rigor
310
350
  # method-return type.
311
351
  def self.with_return_sink
312
352
  previous = Thread.current[RETURN_SINK_KEY]
313
- sink = []
353
+ previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
354
+ sink = ReturnSink.new
314
355
  Thread.current[RETURN_SINK_KEY] = sink
356
+ Thread.current[PROVISIONAL_RETURNS_KEY] = nil
315
357
  begin
316
358
  result = yield
317
359
  ensure
318
360
  Thread.current[RETURN_SINK_KEY] = previous
361
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
362
+ end
363
+ [result, sink.types]
364
+ end
365
+
366
+ # Runs `block` with the `return`s it reaches filed as provisional ({ReturnSink}), restoring the previous state
367
+ # on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
368
+ # `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
369
+ # although the statement walk of the same block recorded `r` with its real binding.
370
+ def self.provisional_returns
371
+ previous = Thread.current[PROVISIONAL_RETURNS_KEY]
372
+ Thread.current[PROVISIONAL_RETURNS_KEY] = true
373
+ begin
374
+ yield
375
+ ensure
376
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous
319
377
  end
320
- [result, sink]
321
378
  end
322
379
 
323
380
  # Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
@@ -365,7 +422,18 @@ module Rigor
365
422
  # Default: the node is treated as a pure expression. Type it through the existing expression typer (which
366
423
  # observes the current scope's locals) and leave the scope unchanged, but for the match globals a call in it
367
424
  # may rebind (`super(line.sub(re, ""))`, issue #1365).
368
- [@scope.type_of(node, tracer: @tracer), forget_rebound_specials(@scope, node)]
425
+ after = reset_ivars_past_unknown_code(node, forget_rebound_specials(@scope, node))
426
+ [@scope.type_of(node, tracer: @tracer), after]
427
+ end
428
+
429
+ # Issue #1402 — `after`, past a statement `yield` or `super`, with each narrowed instance variable widened over
430
+ # its class-ivar seed ({InterveningIvarReset.after_operand}): the block `yield` runs, and the superclass method
431
+ # `super` runs on `self`, may write any of them, as an implicit-self call may. Inside an operand the statement
432
+ # that holds it answers instead.
433
+ def reset_ivars_past_unknown_code(node, after)
434
+ return after if @in_operand || !InterveningIvarReset.candidate?(node)
435
+
436
+ InterveningIvarReset.after_operand(node, after)
369
437
  end
370
438
 
371
439
  # Issue #1429 — a compound write or a `for` loop calls a method its syntax does not spell (`r += r` calls `r.+`,
@@ -438,19 +506,28 @@ module Rigor
438
506
  [type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
439
507
  end
440
508
 
441
- # ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
509
+ # ADR-89 WD2 — the sorted slots of the parameters (positional, then keyword) whose CONTENT `def_node` mutates
442
510
  # (`callee_content_mutated_parameters`, the ADR-56 arg-flooring surface a caller consumes). A per-def
443
511
  # static property of the AST — this is a pure exposure of the existing private computation (it never
444
512
  # reads the scope), so the incremental session can compute it on any `StatementEvaluator` (an empty
445
513
  # scope suffices), persist it in a return summary, and re-check a callee's symbol dependents when it
446
514
  # moves (a caller's arg flooring changes even if the callee's return does not).
515
+ #
516
+ # Issue #2104 — a mutated keyword parameter reports its keyword symbol, after the positions. A parameter the
517
+ # def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
518
+ # callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
519
+ # the effects equal and the callers' floors stale.
447
520
  def content_mutated_parameter_positions(def_node)
448
- callee_content_mutated_parameters(def_node).values.uniq.sort
521
+ slots = content_parameter_slots(def_node)
522
+ census = callee_content_mutated_parameters(def_node).values
523
+ each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
524
+ positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
525
+ positions.sort + keywords.sort
449
526
  end
450
527
 
451
- # The local-variable reads among `call_node`'s positional arguments whose matching parameter the callee
452
- # content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's scope
453
- # (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
528
+ # The local-variable reads among `call_node`'s arguments (positional, or a keyword's value) whose parameter the
529
+ # callee content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's
530
+ # scope (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
454
531
  # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
455
532
  # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
456
533
  def content_mutated_arguments(call_node)
@@ -466,12 +543,25 @@ module Rigor
466
543
  return NO_ARGUMENT_READS if mutated.empty?
467
544
 
468
545
  argument_nodes = call_node.arguments.arguments
469
- mutated.values.uniq.filter_map do |index|
470
- argument = argument_nodes[index]
546
+ mutated.values.uniq.filter_map do |slot|
547
+ argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot]
548
+ argument = argument.value if argument.is_a?(Prism::ImplicitNode)
471
549
  argument if argument.is_a?(Prism::LocalVariableReadNode)
472
550
  end
473
551
  end
474
552
 
553
+ # The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
554
+ def keyword_argument(argument_nodes, name)
555
+ hash = argument_nodes.last
556
+ return unless hash.is_a?(Prism::KeywordHashNode)
557
+
558
+ pair = hash.elements.find do |element|
559
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
560
+ element.key.unescaped.to_sym == name
561
+ end
562
+ pair&.value
563
+ end
564
+
475
565
  NO_ARGUMENT_READS = [].freeze
476
566
  private_constant :NO_ARGUMENT_READS
477
567
 
@@ -664,7 +754,19 @@ module Rigor
664
754
  # stamped by {#seed_instance_ivars}.
665
755
  bound = bound.with_published_constant_mark(:ivar, node.name) if
666
756
  published_constant_copy?(node.value, rhs_type, post_rhs)
667
- [rhs_type, bound]
757
+ [rhs_type, mark_inferred_param_ivar(bound, node)]
758
+ end
759
+
760
+ # Issue #2189 — the ivar twin of {#bind_local_write}'s ADR-67 WD6b stamp: `@x = param; @x.upcase` reads the
761
+ # parameter's open-call-site lower bound back, so the ivar carries the `:inferred_param` mark under its
762
+ # `@`-prefixed name and {Analysis::CheckRules::InferredParamGuard} declines on the read. A write from any
763
+ # other RHS clears it. Method-local only: the class-ivar census seeding a sibling method types the RHS
764
+ # without the inference seed, so no lower bound crosses the method boundary to guard.
765
+ def mark_inferred_param_ivar(bound, node)
766
+ return bound.with_inferred_param_mark(node.name) if
767
+ Analysis::CheckRules::InferredParamGuard.rooted?(node.value, scope)
768
+
769
+ bound.without_inferred_param_mark(node.name)
668
770
  end
669
771
 
670
772
  def eval_cvar_write(node)
@@ -818,11 +920,24 @@ module Rigor
818
920
 
819
921
  def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
820
922
  current_type = current_type_for(kind, node.name)
821
- rhs_type, post_rhs = sub_eval(node.value, scope)
923
+ rhs_type, post_rhs = sub_eval(node.value, compound_value_scope(kind, node.name, op))
822
924
  result_type = compound_result_type(current_type, rhs_type, op)
823
925
  [result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
824
926
  end
825
927
 
928
+ # Issue #2214 — the scope a compound write's value operand runs in. `x &&= v` runs `v` only when `x` is truthy,
929
+ # as `x && (x = v)` does, so `v` reads `x` narrowed to its truthy part: `c &&= c + 1` on an `Integer?` `c` reads
930
+ # `Integer`, not the `nil` that drew `call.possible-nil-receiver`. `x ||= v` and an operator write run `v` from
931
+ # the scope they start in; a variable the scope does not bind is left alone.
932
+ def compound_value_scope(kind, name, operator)
933
+ return scope unless operator == :and
934
+
935
+ current = scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
936
+ return scope if current.nil?
937
+
938
+ scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, Narrowing.narrow_truthy(current))
939
+ end
940
+
826
941
  VAR_KIND_GETTERS = {
827
942
  local: :local, ivar: :ivar, cvar: :cvar, global: :global
828
943
  }.freeze
@@ -892,7 +1007,40 @@ module Rigor
892
1007
  arg_types: index_write_arg_types(node, result_type))
893
1008
  post = post.with_indexed_narrowing(*address, result_type) if address
894
1009
 
895
- [result_type, forget_rebound_specials(post, node)]
1010
+ # The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
1011
+ # fresh container or construction rvalue, with a container's literal precision erased
1012
+ # ({#memo_bound_value}).
1013
+ [memo_bound_value(node, result_type), forget_rebound_specials(post, node)]
1014
+ end
1015
+
1016
+ # What `x = (tbl[k] ||= v)` binds `x` to. Where {#index_compound_write_value} reads the memoizing idiom, the
1017
+ # slot may hold ANY earlier value of the table, so `x` is the rvalue only as far as its CLASS is known: a
1018
+ # `{}` / `[]` rvalue (a shape or tuple) erases to `Hash[untyped, untyped]` / `Array[untyped]`, because the
1019
+ # stored table is not empty after the first store (`entry[:name].upcase` is no call on nil), and a
1020
+ # construction (`new(...)`, a nominal) is kept. Every other rvalue (a Constant, a union) keeps the stored
1021
+ # `Dynamic | v`, since a literal would read the slot as freshly created at every later read.
1022
+ def memo_bound_value(node, stored)
1023
+ return stored unless node.is_a?(Prism::IndexOrWriteNode)
1024
+
1025
+ value = index_compound_write_value(node)
1026
+ return stored if value == stored
1027
+
1028
+ erased = erase_memo_container(value)
1029
+ erased || stored
1030
+ end
1031
+
1032
+ def erase_memo_container(type)
1033
+ any = Type::Combinator.untyped
1034
+ case type
1035
+ when Type::HashShape then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1036
+ when Type::Tuple then Type::Combinator.nominal_of("Array", type_args: [any])
1037
+ when Type::Nominal
1038
+ case type.class_name
1039
+ when "Hash" then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1040
+ when "Array" then Type::Combinator.nominal_of("Array", type_args: [any])
1041
+ else type
1042
+ end
1043
+ end
896
1044
  end
897
1045
 
898
1046
  # `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
@@ -1349,6 +1497,9 @@ module Rigor
1349
1497
  def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
1350
1498
  results = []
1351
1499
  falsey_scope = entry_scope
1500
+ # Issue #1963 — set once a `when` test may have rebound the subject variable, which from there on no longer
1501
+ # holds the value the later tests match ({#subject_when_scopes}).
1502
+ rebound = false
1352
1503
  conditions.each do |branch|
1353
1504
  # Issue #1359 — a clause's conditions, or its pattern's pins and guard, run before its body and before
1354
1505
  # every later clause, and the walk types them without evaluating them, so a reader there forgets `$_` here.
@@ -1359,7 +1510,11 @@ module Rigor
1359
1510
  # recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
1360
1511
  # keys the node.
1361
1512
  record_clause_entry_scope(branch, falsey_scope)
1362
- body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1513
+ if subject && branch.is_a?(Prism::WhenNode)
1514
+ body_scope, falsey_scope, rebound = subject_when_scopes(subject, branch.conditions, falsey_scope, rebound)
1515
+ else
1516
+ body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1517
+ end
1363
1518
  results << sub_eval(branch, body_scope)
1364
1519
  end
1365
1520
  [results, falsey_scope]
@@ -1399,6 +1554,142 @@ module Rigor
1399
1554
  end
1400
1555
  end
1401
1556
 
1557
+ # Issue #1963 — a `when` clause of a `case` with a subject. Ruby runs each test in order (`test === subject`)
1558
+ # until one matches, so a test that runs code — a call, a write, a lambda literal, whose `===` runs its body —
1559
+ # runs before the clause's body and every later clause. Such a test is evaluated ({#run_when_test}), so a call
1560
+ # in it resets the instance variables and the guard narrowings it may rebind, as a statement call does, and a
1561
+ # write in it binds. The body is reached after any prefix of the tests ran, so it enters from the join of the
1562
+ # scopes each test leaves, the next clause from the scope the last one leaves.
1563
+ #
1564
+ # The subject is evaluated once, before every test, so a test that may rebind the subject variable
1565
+ # ({#when_test_rebinds_subject?}) leaves the variable holding something other than the value the later tests
1566
+ # match: from that test on, no clause narrows it (`case @x when String then 1 when reset! then @x.upcase`, with
1567
+ # `reset!` assigning `@x`, reads `@x` as `reset!` may leave it, not as `nil`). `rebound` carries that across
1568
+ # the clauses; the answer is `[body_scope, falsey_scope, rebound]`. A clause whose tests run no code, before
1569
+ # any test rebound the subject, keeps {Narrowing.case_when_scopes}' narrowing unchanged.
1570
+ def subject_when_scopes(subject, conditions, entry, rebound)
1571
+ unless rebound || conditions.any? { |condition| when_test_runs_code?(condition) }
1572
+ return [*Narrowing.case_when_scopes(subject, conditions, entry), false]
1573
+ end
1574
+
1575
+ after = entry
1576
+ test_exits = conditions.map do |condition|
1577
+ before = after
1578
+ after = run_when_test(condition, before)
1579
+ rebound ||= when_test_rebinds_subject?(subject, condition, before, after)
1580
+ after
1581
+ end
1582
+ body_entry = reduce_scopes_with_nil_injection(test_exits)
1583
+ if rebound
1584
+ body_entry, after = [body_entry, after].map { |s| forget_called_global_subject(subject, s) }
1585
+ return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true]
1586
+ end
1587
+
1588
+ [Narrowing.case_when_scopes(subject, conditions, body_entry).first,
1589
+ Narrowing.case_when_scopes(subject, conditions, after).last, false]
1590
+ end
1591
+
1592
+ # Whether a `when` test runs code beyond reading a value: a call, `super`, `yield`, a lambda literal or a write.
1593
+ WHEN_TEST_CODE_NODES = [
1594
+ Prism::CallNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::YieldNode, Prism::LambdaNode
1595
+ ].freeze
1596
+ private_constant :WHEN_TEST_CODE_NODES
1597
+
1598
+ def when_test_runs_code?(condition)
1599
+ return true if OperandEffects.any?(condition)
1600
+
1601
+ found = false
1602
+ Source::NodeWalker.each(condition) do |descendant|
1603
+ found ||= WHEN_TEST_CODE_NODES.any? { |klass| descendant.is_a?(klass) }
1604
+ end
1605
+ found
1606
+ end
1607
+
1608
+ # The scope a `when` test leaves. The instance variables its implicit-self calls may write are widened first,
1609
+ # a lambda literal's body included, since its `===` runs it ({InterveningIvarReset.after_operand}, which
1610
+ # marks a binding whose narrowing had ruled `nil` out). The test is then evaluated, which binds its writes
1611
+ # and drops the narrowing of a variable a lambda literal captures and writes ({#eval_lambda}). A guard's
1612
+ # narrowing that a lambda body may rebind is restored as a call's would be, since its `===` runs the body.
1613
+ def run_when_test(condition, before)
1614
+ before = InterveningIvarReset.after_operand(condition, before, run_lambdas: true)
1615
+ _type, after = sub_eval(condition, before)
1616
+ body = when_lambda_body(condition)
1617
+ return after unless body && after.guard_narrowed? && GuardRebinding.may_rebind?(body, after)
1618
+
1619
+ after.forget_guard_narrowings
1620
+ end
1621
+
1622
+ def when_lambda_body(condition)
1623
+ condition = condition.body.body.first while condition.is_a?(Prism::ParenthesesNode) &&
1624
+ condition.body.is_a?(Prism::StatementsNode) &&
1625
+ condition.body.body.size == 1
1626
+ condition.body if condition.is_a?(Prism::LambdaNode)
1627
+ end
1628
+
1629
+ # Whether the `when` test `condition`, which took `before` to `after`, may have rebound the variable `subject`
1630
+ # reads: its binding moved, the test writes it, or — for an instance variable — a call in the test may write
1631
+ # it ({InterveningIvarReset.may_write?}), or — for a global or constant — the test may run code that rebinds
1632
+ # one ({GuardRebinding.may_rebind?}).
1633
+ #
1634
+ # A subject that writes a local or instance variable (`case (@x = v)`) is read as that variable, as
1635
+ # {Narrowing.case_when_scopes} narrows it (issue #2211). For such an instance variable the binding test above
1636
+ # answers first (the test's call widens it in {#run_when_test}), so its `may_write?` arm is a guard.
1637
+ def when_test_rebinds_subject?(subject, condition, before, after)
1638
+ subject = case_subject_variable(subject)
1639
+ name = case_subject_name(subject)
1640
+ return false if name.nil?
1641
+ return true unless case_subject_binding(subject, before) == case_subject_binding(subject, after)
1642
+ return true if OperandEffects.written_variables(condition).include?(name)
1643
+
1644
+ case subject
1645
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
1646
+ InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1647
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
1648
+ false
1649
+ else
1650
+ body = when_lambda_body(condition)
1651
+ GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
1652
+ end
1653
+ end
1654
+
1655
+ # Issue #2211 — a global subject from a `when` test that may rebind it on ({#when_test_rebinds_subject?}): a
1656
+ # method may assign a global, and the statement walk keeps a global's binding past a call, so the clause
1657
+ # bodies and the later clauses read it as `Dynamic[top]`, as the scope index's walk of a value-position `case`
1658
+ # reads it
1659
+ # (`ScopeIndexer#forget_called_subject`). `$g = nil; case $g when String then 1 when reset! then 2 else $g + 1
1660
+ # end`, with `reset!` assigning `$g`, is `6` in Ruby. An instance variable a test's call may write is widened by
1661
+ # {#run_when_test} instead; any other subject is left as is.
1662
+ def forget_called_global_subject(subject, scope)
1663
+ return scope unless subject.is_a?(Prism::GlobalVariableReadNode)
1664
+
1665
+ scope.with_global(subject.name, Type::Combinator.untyped)
1666
+ end
1667
+
1668
+ # The subject node the rebinding test reads: a parenthesised one unwrapped, and a local or instance variable
1669
+ # write kept as the variable it writes.
1670
+ def case_subject_variable(subject)
1671
+ Narrowing.unwrap_case_subject(subject)
1672
+ end
1673
+
1674
+ def case_subject_name(subject)
1675
+ case subject
1676
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
1677
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
1678
+ subject.name
1679
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1680
+ Narrowing.constant_key(subject)
1681
+ end
1682
+ end
1683
+
1684
+ def case_subject_binding(subject, scope)
1685
+ case subject
1686
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.local(subject.name)
1687
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.ivar(subject.name)
1688
+ when Prism::GlobalVariableReadNode then scope.global(subject.name)
1689
+ else scope.constant_narrowing(Narrowing.constant_key(subject))
1690
+ end
1691
+ end
1692
+
1402
1693
  # Issue #1495 — a `case` without a subject tests each `when` condition for truthiness, in order, and stops at the
1403
1694
  # first truthy one, so a clause is the `||` of its conditions in an `if` / `elsif` chain. Each condition is
1404
1695
  # evaluated under the falsey edges of the conditions before it, in this clause and every earlier one; the body
@@ -1517,9 +1808,32 @@ module Rigor
1517
1808
 
1518
1809
  # The scope past `node`'s `ensure` clause, entered with `exit_scope`, the `next` and `break` scopes recorded past
1519
1810
  # `jump_marks` carried through it.
1811
+ #
1812
+ # Issue #1839 — the code after the `begin` runs only when the clause finished from a `begin` that did, so the
1813
+ # scope it continues from is the clause's under `exit_scope`. A `return`, `next` or `break` in the clause can
1814
+ # also leave from one that raised, after any prefix of the body: those jumps are taken from the pass under the
1815
+ # widened entry ({#widen_clause_entry}), when a jump sink is installed to collect them ({#jump_sinks_installed?}).
1520
1816
  def eval_begin_ensure(node, exit_scope, jump_marks)
1521
- carry_jumps_through_ensure(node.ensure_clause, jump_marks)
1522
- eval_widened_clause(node.ensure_clause, node.ensure_clause.statements, exit_scope, ensure_writes(node)).last
1817
+ clause = node.ensure_clause
1818
+ carry_jumps_through_ensure(clause, jump_marks)
1819
+ jumps = jump_sinks_installed? && clause_jumps?(clause.statements)
1820
+ record_widened = widening_recorded?
1821
+ widened = clause.statements && (jumps || record_widened) &&
1822
+ widen_clause_entry(exit_scope, ensure_writes(node), clause.statements)
1823
+ return sub_eval(clause, exit_scope).last unless widened
1824
+
1825
+ record_operand_clause(clause.statements, widened) if record_widened
1826
+
1827
+ widened_options = record_widened ? {} : UNRECORDED
1828
+ if jumps
1829
+ sub_eval(clause, widened, **widened_options)
1830
+ else
1831
+ without_jump_sinks { sub_eval(clause, widened, next_scope_sink: nil, **widened_options) }
1832
+ end
1833
+ plain_options = record_widened ? UNRECORDED : {}
1834
+ return sub_eval(clause, exit_scope, **plain_options).last unless jumps
1835
+
1836
+ without_jump_sinks { sub_eval(clause, exit_scope, next_scope_sink: nil, **plain_options) }.last
1523
1837
  end
1524
1838
 
1525
1839
  # Issue #1231 — a rescue clause runs after the body raised from any point in it, and an `ensure` clause after the
@@ -1527,22 +1841,27 @@ module Rigor
1527
1841
  # instance variable those wrote at a value the scope it enters with does not hold: `conn = nil; begin; conn =
1528
1842
  # open_conn; Integer(s); rescue; conn.size; end` reads the connection. The engine does not track where the body
1529
1843
  # raises (the precise recorder of #1397 put new false positives on correct code); it widens instead, and
1530
- # deliberately imprecisely. Each name the clause mentions ({#clause_mentions}) that its entry binds and those
1531
- # clauses write, outside a nested `def` or class body, reads as `Dynamic[top]`, which reports nothing. A plain
1532
- # join would read `x` as `1?` in `x = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a
1533
- # `Dynamic` that keeps the join as its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member
1534
- # for the next call to report. A name written inside a nested `ensure` clause, which also runs on the way out of
1535
- # a raise, is covered by the same floor.
1844
+ # deliberately imprecisely. Each name that the clause's entry binds and those clauses write, outside a nested
1845
+ # `def` or class body, and that the clause mentions or they write more than once ({ClauseWidening}),
1846
+ # reads as `Dynamic[top]`, which reports nothing. A plain join would read `x` as `1?` in `x
1847
+ # = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a `Dynamic` that keeps the join as
1848
+ # its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member for the next call to report. A name
1849
+ # written inside a nested `ensure` clause, which also runs on the way out of a raise, is covered by the same
1850
+ # floor.
1851
+ #
1852
+ # Issue #1839 — the widening is not for the clause's own reads only: a rescue clause's value, the scope it falls
1853
+ # through with and a `return`, `next` or `break` it takes all come from its widened entry, on every path (an
1854
+ # inferred return included), so `got = begin; conn = make; work; rescue; conn; end` leaves `got` untyped rather
1855
+ # than the entry's `nil`, and a name the clause never mentions but the body writes twice joins the code after the
1856
+ # `begin` untyped rather than at the entry's binding, which misses every write the body made before the last.
1536
1857
  #
1537
1858
  # The names the clauses of `node` read from: the primary body's for a rescue clause, none under a retry `edge`.
1538
1859
  def rescue_writes(node, edge)
1539
- clause_writes([node.statements]) if node.rescue_clause && edge.nil? && widening_recorded?
1860
+ clause_writes([node.statements]) if node.rescue_clause && edge.nil?
1540
1861
  end
1541
1862
 
1542
1863
  # For the `ensure` clause: the writes of the body, its `else` and every rescue clause.
1543
1864
  def ensure_writes(node)
1544
- return nil unless widening_recorded?
1545
-
1546
1865
  sources = [node.statements, node.else_clause]
1547
1866
  current = node.rescue_clause
1548
1867
  while current
@@ -1552,83 +1871,68 @@ module Rigor
1552
1871
  clause_writes(sources)
1553
1872
  end
1554
1873
 
1555
- # The local and instance variable names `sources` write, a nested block's included (it may write an outer local),
1556
- # or nil for none. A `def` or class body writes nothing here.
1557
- def clause_writes(sources)
1558
- writes = nil
1559
- sources.each { |source| writes = collect_clause_names(source, CLAUSE_WRITE_NODES, nil, writes) if source }
1560
- writes
1561
- end
1874
+ def clause_writes(sources) = ClauseWidening.writes(sources)
1562
1875
 
1563
- CLAUSE_WRITE_NODES = Set[
1564
- *CapturedLocals::LOCAL_WRITE_NODES,
1565
- Prism::InstanceVariableWriteNode,
1566
- Prism::InstanceVariableOperatorWriteNode,
1567
- Prism::InstanceVariableOrWriteNode,
1568
- Prism::InstanceVariableAndWriteNode,
1569
- Prism::InstanceVariableTargetNode
1570
- ].freeze
1571
- private_constant :CLAUSE_WRITE_NODES
1876
+ CLAUSE_JUMP_NODES = Set[Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
1877
+ private_constant :CLAUSE_JUMP_NODES
1572
1878
 
1573
- CLAUSE_MENTION_NODES = Set[
1574
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *CLAUSE_WRITE_NODES
1575
- ].freeze
1576
- private_constant :CLAUSE_MENTION_NODES
1879
+ # Whether a `return`, `next` or `break` reached here is collected: a pass {#without_jump_sinks} runs collects
1880
+ # none, so an `ensure` clause inside it, nested at any depth, is evaluated once rather than once per pass of
1881
+ # every enclosing clause holding a jump.
1882
+ def jump_sinks_installed?
1883
+ !@next_scope_sink.nil? || JUMP_SINK_KEYS.any? { |key| Thread.current[key] }
1884
+ end
1577
1885
 
1578
- # The names of the `node_classes` nodes under `node` (restricted to `among` when given) added to `found`, outside
1579
- # a nested `def` or class body.
1580
- def collect_clause_names(node, node_classes, among, found)
1581
- return found if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
1886
+ # Whether `node` holds a `return`, `next` or `break` outside a nested `def`.
1887
+ def clause_jumps?(node)
1888
+ return false if node.nil? || node.is_a?(Prism::DefNode)
1889
+ return true if CLAUSE_JUMP_NODES.include?(node.class)
1582
1890
 
1583
- if node_classes.include?(node.class) && (among.nil? || among.include?(node.name))
1584
- (found ||= Set.new) << node.name
1585
- end
1586
- node.rigor_each_child { |child| found = collect_clause_names(child, node_classes, among, found) }
1587
- found
1891
+ node.rigor_each_child { |child| return true if clause_jumps?(child) }
1892
+ false
1588
1893
  end
1589
1894
 
1590
- # `clause_scope` with each name of `writes` that `statements` mentions, and `clause_scope` binds, rebound to
1591
- # `Dynamic[top]`, or nil when none changes.
1592
- def widen_clause_entry(clause_scope, statements, writes)
1593
- return nil if writes.nil? || statements.nil? || !widening_recorded?
1895
+ # `clause_scope` entering `clause` with the names of `writes` {ClauseWidening.widen} picks rebound to
1896
+ # `Dynamic[top]`, or nil when none changes. An empty rescue clause is widened too: the scope it falls through
1897
+ # with is its entry.
1898
+ def widen_clause_entry(clause_scope, writes, clause) = ClauseWidening.widen(clause_scope, writes, clause)
1594
1899
 
1595
- names = collect_clause_names(statements, CLAUSE_MENTION_NODES, writes, nil)
1596
- return nil if names.nil?
1900
+ # Issue #1839 — an operand evaluator ({#thread_operand}) records only the later operands its walk takes, so the
1901
+ # index's walk hands a clause it evaluated the scope of the `begin`'s nearest recorded ancestor, which binds
1902
+ # each name the body writes as the `begin` found it: `puts(begin; conn = make; work; rescue; conn.size; end)`
1903
+ # read `conn` as `nil`. On the check path it records the clause's `statements` under the `widened` entry, with
1904
+ # `$!`, `$@` and `$?` unbound as the walk leaves them for a clause the evaluator did not enter; the
1905
+ # walk fills the clause's nodes from there. The clause's later operands are recorded by its own pass. An
1906
+ # evaluator that records its nodes itself skips this: its pass over the clause records `statements` right
1907
+ # after, and the index keeps the last visit, so the `@on_enter` test is a guard no example can fail.
1908
+ def record_operand_clause(statements, widened)
1909
+ return unless statements && widened && @on_enter.nil? && @operand_recorder && widening_recorded?
1597
1910
 
1598
- untyped = Type::Combinator.untyped
1599
- widened = names.reduce(clause_scope) do |acc, name|
1600
- kind = name.start_with?("@") ? :ivar : :local
1601
- current = clause_scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
1602
- current.nil? || current == untyped ? acc : rebind_variable(acc, kind, name, untyped)
1603
- end
1604
- widened.equal?(clause_scope) ? nil : widened
1911
+ @operand_recorder.call(statements, widened.forget_error_info.forget_last_status)
1605
1912
  end
1606
1913
 
1607
- # Whether a widened pass would record anything: only the check path asks for one ({#initialize}'s
1608
- # `widen_clauses`), and a pass nothing records is skipped, so a nested clause inside an unrecorded pass is
1609
- # evaluated once.
1914
+ # Whether this pass records the per-node index under a clause's widened entry: only the check path asks for that
1915
+ # ({#initialize}'s `widen_clauses`). Every other recorder keeps the clause's plain entry ({#recording?}).
1610
1916
  def widening_recorded?
1611
- @widen_clauses && !(@on_enter.nil? && @operand_recorder.nil?)
1917
+ @widen_clauses && recording?
1612
1918
  end
1613
1919
 
1614
- # `[type, scope]` of a rescue or `ensure` clause entered with `clause_scope`. When {#widen_clause_entry} widens
1615
- # it, the clause is evaluated twice: under the widened entry for the per-node index, which is what the check
1616
- # rules read, and under `clause_scope` without recording, for the value and the scope the code after the
1617
- # `begin` continues from. The widened pass collects no `return`, `next`, `break` or retry-edge scope, so the
1618
- # widening reaches neither an inferred return nor a loop: it can only take diagnostics away inside the clause.
1619
- def eval_widened_clause(node, statements, clause_scope, writes)
1620
- widened = widen_clause_entry(clause_scope, statements, writes)
1621
- return sub_eval(node, clause_scope) unless widened
1622
-
1623
- eval_widened_pass(node, widened)
1624
- sub_eval(node, clause_scope, **UNRECORDED)
1920
+ # Whether this pass records the per-node index at all.
1921
+ def recording?
1922
+ !(@on_enter.nil? && @operand_recorder.nil?)
1625
1923
  end
1626
1924
 
1627
- # The recorded pass over a widened clause: it reaches no `return`, `next`, `break` or retry-frame sink.
1628
- def eval_widened_pass(node, widened)
1629
- return if node.nil?
1925
+ # `[type, scope]` of a rescue clause's `statements` entered with `widened` ({#widen_clause_entry}), the value,
1926
+ # the scope and the jumps the code after the `begin` sees. A recorder that keeps the clause's plain entry
1927
+ # ({#widening_recorded?}) records a second pass under `plain` that reaches no jump sink, so nothing records twice.
1928
+ # The widened pass then records nothing, and a clause nested inside it is evaluated once, so `d` nested clauses
1929
+ # cost `d * (d + 1) / 2` evaluations; every other pass evaluates the clause once.
1930
+ def eval_widened_clause(statements, plain, widened, on_enter:)
1931
+ return [Type::Combinator.constant_of(nil), widened] if statements.nil?
1932
+ return sub_eval(statements, widened, on_enter: on_enter) if widening_recorded? || !recording?
1630
1933
 
1631
- without_jump_sinks { sub_eval(node, widened, next_scope_sink: nil) }
1934
+ without_jump_sinks { sub_eval(statements, plain, next_scope_sink: nil, on_enter: on_enter) }
1935
+ sub_eval(statements, widened, **UNRECORDED)
1632
1936
  end
1633
1937
 
1634
1938
  # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
@@ -1739,6 +2043,10 @@ module Rigor
1739
2043
  CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
1740
2044
  private_constant :CLAUSE_SINK_KEYS
1741
2045
 
2046
+ # The thread-local sinks a `return`, `next` or `break` is collected into.
2047
+ JUMP_SINK_KEYS = (CLAUSE_SINK_KEYS - [RETRY_FRAMES_KEY]).freeze
2048
+ private_constant :JUMP_SINK_KEYS
2049
+
1742
2050
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1743
2051
  # no `retry` targets.
1744
2052
  def retry_edge_for(node)
@@ -2016,7 +2324,7 @@ module Rigor
2016
2324
  # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
2017
2325
  # types (`log(tries < 5 ? retry : :gave_up)`).
2018
2326
  #
2019
- # Issue #1231 — `writes` widens the scope each clause enters with, as {#eval_widened_clause} does, before its
2327
+ # Issue #1231 — `writes` widens the scope each clause enters with ({#widen_clause_entry}) before its
2020
2328
  # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2021
2329
  # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2022
2330
  def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
@@ -2024,11 +2332,12 @@ module Rigor
2024
2332
  results = []
2025
2333
  current = rescue_node
2026
2334
  while current
2027
- widened = widen_clause_entry(entry_scope, current.statements, writes)
2028
- eval_widened_pass(current.statements, bind_rescue_reference(current, widened)) if widened
2335
+ widened = widen_clause_entry(entry_scope, writes, current.statements)
2336
+ record_operand_clause(current.statements, widened)
2029
2337
  rescue_scope = bind_rescue_reference(current, entry_scope)
2030
2338
  arm = if widened
2031
- sub_eval(current.statements, rescue_scope, **UNRECORDED)
2339
+ eval_widened_clause(current.statements, rescue_scope, bind_rescue_reference(current, widened),
2340
+ on_enter: on_enter)
2032
2341
  else
2033
2342
  eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2034
2343
  end
@@ -2069,24 +2378,50 @@ module Rigor
2069
2378
  after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
2070
2379
  end
2071
2380
 
2072
- # Issue #1889 — {#eval_loop} / {#eval_for} with a literal-name `instance_variable_set` in the body joining its
2073
- # value into the ivar's binding rather than replacing it ({ReflectiveIvarWrite.in_repeating_body}).
2074
- def eval_repeating_loop(node) = ReflectiveIvarWrite.in_repeating_body { eval_loop(node) }
2075
-
2381
+ # Issue #1889 — {#eval_for} with a literal-name `instance_variable_set` in the body joining its value into the
2382
+ # ivar's binding rather than replacing it ({ReflectiveIvarWrite.in_repeating_body}): a `for` body has no rebind
2383
+ # fixpoint. A `while` / `until` body needs no such join since issue #1930: its fixpoint widens the ivar
2384
+ # ({#loop_body_ivar_writes}).
2076
2385
  def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2077
2386
 
2387
+ # A loop's evaluation, with its value recorded for `ExpressionTyper#type_of_loop` ({LoopValueRecord}).
2388
+ # A conditional's evaluation, recorded when it evaluated a loop ({LoopValueRecord.conditional}): the typer
2389
+ # types a conditional that holds a loop again from the scope it was evaluated from, and the loop in its branch
2390
+ # is evaluated from a branch scope no record matches.
2391
+ def eval_recorded_if(node) = record_conditional_value(node) { eval_if(node) }
2392
+
2393
+ def eval_recorded_unless(node) = record_conditional_value(node) { eval_unless(node) }
2394
+
2395
+ # The "evaluated a loop" gate is a memory-only guard: recording every conditional would give the same answers, at
2396
+ # the cost of a retained scope per `if` node, so no behavioural example pins it.
2397
+ def record_conditional_value(node)
2398
+ before = LoopValueRecord.loops_evaluated
2399
+ result = yield
2400
+ LoopValueRecord.conditional(node, scope, result.first) unless LoopValueRecord.loops_evaluated == before
2401
+ result
2402
+ end
2403
+
2404
+ def eval_recorded_loop(node) = record_loop_value(node, eval_loop(node))
2405
+
2406
+ def eval_recorded_for(node) = record_loop_value(node, eval_repeating_for(node))
2407
+
2408
+ def record_loop_value(node, result)
2409
+ LoopValueRecord.record(node, scope, result.first)
2410
+ result
2411
+ end
2412
+
2078
2413
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
2079
- # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
2080
- # 1), reflecting the common case where no `break VALUE` is observed.
2414
+ # degrade to `T | nil` in the post-loop scope. The loop expression is `nil` joined with the value of every `break`
2415
+ # that leaves this loop ({#loop_value}, issue #2031).
2081
2416
  def eval_loop(node)
2082
- _pred_type, post_pred = sub_eval(node.predicate, loop_entry_scope(node))
2083
- post_pred = widen_predicate_pins(node, post_pred)
2417
+ post_pred = loop_predicate_scope(node)
2084
2418
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
2085
2419
 
2086
2420
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
2087
2421
  # fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
2088
- # Tuple), body-introduced locals' nil-injection, an instance variable's rebind, and the loop value itself. The
2089
- # fixpoint then OVERLAYS only the rebound-local bindings it corrects.
2422
+ # Tuple), body-introduced locals' nil-injection, a rebind of an instance variable the fixpoint does not track
2423
+ # ({#loop_body_ivar_writes}), and the loop value itself. The fixpoint then OVERLAYS only the rebound bindings it
2424
+ # corrects.
2090
2425
  #
2091
2426
  # Like every fixpoint pass, it enters the body on the predicate's loop-entry edge ({#loop_pass_entry}): the
2092
2427
  # body of `while (line = gets)` reads `line` as `String`, and the body of `while gets` reads `$_` as one
@@ -2096,28 +2431,60 @@ module Rigor
2096
2431
  # The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
2097
2432
  # superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
2098
2433
  jumps = loop_jumps(node.statements)
2099
- body_scope, first_breaks = single_pass(node, post_pred, jumps)
2434
+ body_scope, first_breaks, first_values = single_pass(node, post_pred, jumps)
2100
2435
  base_scope = join_with_nil_injection(post_pred, body_scope)
2101
2436
 
2102
- rebound, body_first = loop_body_local_writes(node.statements, post_pred)
2103
- names = rebound + body_first
2437
+ names, body_first = loop_rebound_names(node.statements, post_pred)
2104
2438
 
2105
2439
  # Fast path: a loop whose body rebinds no local skips the rebind fixpoint, but still needs the slice-C content
2106
2440
  # writeback (a loop may content-mutate a collection without rebinding any local — `acc << x`), so apply it to
2107
2441
  # the single-pass join before returning.
2108
2442
  if names.empty?
2109
2443
  fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
2110
- return [Type::Combinator.constant_of(nil), loop_exit(node, post_pred, fast)]
2444
+ return [loop_value(node, first_values), loop_exit(node, post_pred, fast)]
2111
2445
  end
2112
2446
 
2113
- post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2447
+ post_loop, values = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2114
2448
  # A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
2115
2449
  # entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
2116
2450
  # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
2117
2451
  # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
2118
2452
  post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
2119
2453
  post_loop = loop_exit(node, post_pred, post_loop)
2120
- [Type::Combinator.constant_of(nil), post_loop]
2454
+ [loop_value(node, first_values + values), post_loop]
2455
+ end
2456
+
2457
+ # The scope a loop's predicate leaves, which the body and the exit edge start from. Issue #1930 — the predicate
2458
+ # runs before every iteration but is walked once, so a literal-name `instance_variable_set` in it joins its value
2459
+ # and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}): the rebind fixpoint tracks the body's writes only.
2460
+ def loop_predicate_scope(node)
2461
+ entry = loop_entry_scope(node)
2462
+ _pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
2463
+ widen_predicate_pins(node, post_pred)
2464
+ end
2465
+
2466
+ # Issue #2031 — a `while` / `until` expression is `nil` when its predicate ends it and the value of the `break`
2467
+ # that ends it otherwise: `x = while i < k; break @r if i == 4; end` is `"four"` or `nil`. `break_values` holds
2468
+ # what each `break` targeting this loop carried, from the single body pass (the first iteration) and the pass
2469
+ # from the converged bindings ({#loop_break_arms}), which together cover the entry of every iteration. A literal
2470
+ # `while true` / `until false` / `until nil` is never ended by its predicate, so only its `break`s give it a
2471
+ # value; any other predicate is assumed to end it, since its first evaluation says nothing about later ones. A
2472
+ # loop with no reachable `break` keeps its `nil`.
2473
+ def loop_value(node, break_values)
2474
+ nil_type = Type::Combinator.constant_of(nil)
2475
+ return nil_type if break_values.empty?
2476
+ return Type::Combinator.union(*break_values) if endless_loop_predicate?(node)
2477
+
2478
+ Type::Combinator.union(nil_type, *break_values)
2479
+ end
2480
+
2481
+ def endless_loop_predicate?(node)
2482
+ predicate = node.predicate
2483
+ if node.is_a?(Prism::UntilNode)
2484
+ predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
2485
+ else
2486
+ predicate.is_a?(Prism::TrueNode)
2487
+ end
2121
2488
  end
2122
2489
 
2123
2490
  # Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
@@ -2179,7 +2546,8 @@ module Rigor
2179
2546
 
2180
2547
  # The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
2181
2548
  # `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
2182
- # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
2549
+ # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`). Returns `[scope, values]`,
2550
+ # `values` being what the converged pass's `break`s carry ({#loop_value}).
2183
2551
  def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2184
2552
  # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
2185
2553
  # single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
@@ -2187,20 +2555,20 @@ module Rigor
2187
2555
  # fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
2188
2556
  # post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
2189
2557
  # degrades it to `T | nil`, matching the nil-injection treatment.
2190
- break_pass = jumps.breaks && { entry: nil, arms: [] }
2558
+ break_pass = { entry: nil, arms: [], values: [] } if jumps.breaks || later_pass_returns?(node.statements)
2191
2559
  result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2192
2560
  # Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
2193
2561
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
2194
2562
  # constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
2195
2563
  record_converged_loop_body(node, post_pred, result, names, body_first, jumps, break_pass)
2196
- post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
2564
+ post_loop = result.reduce(base_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
2197
2565
  # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
2198
2566
  # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
2199
2567
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
2200
2568
  # from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
2201
2569
  post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
2202
- arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2203
- join_break_scopes(post_loop, arms, names)
2570
+ arms, values = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2571
+ [join_break_scopes(post_loop, arms, names), values]
2204
2572
  end
2205
2573
 
2206
2574
  # A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
@@ -2261,6 +2629,13 @@ module Rigor
2261
2629
  NO_LOOP_NAMES = [].freeze
2262
2630
  private_constant :NO_LOOP_BINDINGS, :NO_LOOP_NAMES
2263
2631
 
2632
+ # The instance-variable write forms {#loop_body_ivar_writes} counts.
2633
+ IVAR_WRITE_NODES = Set[
2634
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode,
2635
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
2636
+ ].freeze
2637
+ private_constant :IVAR_WRITE_NODES
2638
+
2264
2639
  # A body with no targeting jump pays two allocation-free scans.
2265
2640
  def loop_jumps(statements)
2266
2641
  kinds = JumpTargets.kinds(statements, LOOP_JUMP_CLASSES)
@@ -2286,8 +2661,11 @@ module Rigor
2286
2661
  [sink, result]
2287
2662
  end
2288
2663
 
2289
- # One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with, and the
2290
- # scopes at the `break`s that target the loop ({LoopJumps}). Every reader of a loop body goes through here — the
2664
+ # One evaluation of a loop body from `entry`. Returns `[exit, breaks, values]`: the scope the iteration ends with,
2665
+ # the scopes at the `break`s that target the loop ({LoopJumps}), and the values those `break`s carry, read from a
2666
+ # `break`-value sink of the loop's own ({#loop_value}); filtering it by the loop's `break`s is defensive, since a
2667
+ # nested loop installs its own sink and a nested block's call evaluates its body under another, so no example
2668
+ # tells the filter apart today. Every reader of a loop body goes through here — the
2291
2669
  # single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
2292
2670
  # pass.
2293
2671
  #
@@ -2305,18 +2683,21 @@ module Rigor
2305
2683
  recording = recorded ? {} : UNRECORDED
2306
2684
  evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
2307
2685
  if jumps.breaks
2308
- break_sink, fall_through = collect_break_scopes(&evaluate)
2686
+ break_sink, (fall_through, value_sink) = collect_break_scopes do
2687
+ StatementEvaluator.with_break_value_sink(&evaluate)
2688
+ end
2309
2689
  breaks = targeted_scopes(break_sink, jumps.breaks)
2690
+ values = targeted_scopes(value_sink, jumps.breaks)
2310
2691
  else
2311
2692
  fall_through = evaluate.call
2312
- breaks = NO_BREAK_ARMS
2693
+ breaks = values = NO_BREAK_ARMS
2313
2694
  end
2314
- return [fall_through, breaks] if next_sink.nil?
2695
+ return [fall_through, breaks, values] if next_sink.nil?
2315
2696
 
2316
2697
  exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
2317
2698
  join_with_nil_injection(acc, next_scope)
2318
2699
  end
2319
- [exit_scope, breaks]
2700
+ [exit_scope, breaks, values]
2320
2701
  end
2321
2702
 
2322
2703
  # The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
@@ -2336,12 +2717,24 @@ module Rigor
2336
2717
  # The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
2337
2718
  # inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
2338
2719
  # fixpoint's own passes never evaluated.
2720
+ #
2721
+ # Returns `[arms, values]`, `values` being what the same pass's `break`s carry ({#loop_value}).
2339
2722
  def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
2340
- return NO_BREAK_ARMS if break_pass.nil?
2341
- return break_pass[:arms] if break_pass[:entry] == converged.except(*body_first)
2723
+ return [NO_BREAK_ARMS, NO_BREAK_ARMS] if break_pass.nil?
2724
+ return break_pass.values_at(:arms, :values) if break_pass[:entry] == converged.except(*body_first)
2342
2725
 
2343
2726
  entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
2344
- loop_iteration(node.statements, entry, jumps, recorded: false).last
2727
+ loop_iteration(node.statements, entry, jumps, recorded: false).drop(1)
2728
+ end
2729
+
2730
+ # Issue #2128 — whether a pass from the converged bindings must run for the `return`s in a loop body: a method's
2731
+ # return sink is active and the body holds a `return` that exits the method ({ReturnBarrier.exits_method?}). A
2732
+ # `return` whose condition reads a loop-carried local (`return false if seen > 3` after `seen += 1`) is dead in
2733
+ # every capped fixpoint pass, which runs from a value-pinned assumption (`seen` at most `3`), and only the pass
2734
+ # from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
2735
+ # caller's test on it folded always-truthy on correct code.
2736
+ def later_pass_returns?(statements)
2737
+ !Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
2345
2738
  end
2346
2739
 
2347
2740
  # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
@@ -2356,11 +2749,11 @@ module Rigor
2356
2749
  floor = Type::Combinator.untyped
2357
2750
  breaks.reduce(continuation) do |cont, break_scope|
2358
2751
  names.reduce(cont) do |acc, name|
2359
- break_value = break_scope.local(name)
2360
- current = acc.local(name)
2752
+ break_value = CapturedLocals.bound_type(break_scope, name)
2753
+ current = CapturedLocals.bound_type(acc, name)
2361
2754
  next acc if break_value.nil? || current == floor
2362
2755
 
2363
- acc.with_local(name, current ? Type::Combinator.union(current, break_value) : break_value)
2756
+ bind_capture(acc, name, current ? Type::Combinator.union(current, break_value) : break_value)
2364
2757
  end
2365
2758
  end
2366
2759
  end
@@ -2433,7 +2826,7 @@ module Rigor
2433
2826
  # treatment. `break_pass` rides along so every pass leaves its `break` arms for {#loop_break_arms}.
2434
2827
  def loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2435
2828
  nil_const = Type::Combinator.constant_of(nil)
2436
- seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
2829
+ seed = names.to_h { |name| [name, CapturedLocals.bound_type(post_pred, name) || nil_const] }
2437
2830
  evaluate_body = lambda do |bindings|
2438
2831
  loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
2439
2832
  end
@@ -2465,6 +2858,34 @@ module Rigor
2465
2858
  [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
2466
2859
  end
2467
2860
 
2861
+ # Every name {#eval_loop}'s rebind fixpoint tracks, and the body-first locals among them: the locals the body
2862
+ # rebinds ({#loop_body_local_writes}) and the instance variables ({#loop_body_ivar_writes}).
2863
+ def loop_rebound_names(statements, base_scope)
2864
+ rebound, body_first = loop_body_local_writes(statements, base_scope)
2865
+ [rebound + loop_body_ivar_writes(statements, base_scope) + body_first, body_first]
2866
+ end
2867
+
2868
+ # Issue #1930 — the instance variables the loop body can rebind, which the rebind fixpoint widens as it widens a
2869
+ # local: a write node (`@n = i + 1`, any depth, a nested block's included) or a literal-name
2870
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}). Only an ivar `base_scope` binds off its
2871
+ # ADR-58 declaration seed counts ({CapturedLocals.rebindable_ivar?}), as for a block's write-back: one the method
2872
+ # has not bound reads `Dynamic[top]` before the loop, and the join drops it after; one on its declaration seed
2873
+ # already holds the union of every write in the class. A call that writes the ivar inside the callee
2874
+ # (`self.bump`) is not a write here; the intervening-call reset handles it.
2875
+ def loop_body_ivar_writes(statements, base_scope)
2876
+ return NO_LOOP_NAMES if base_scope.ivars.empty?
2877
+
2878
+ names = nil
2879
+ Source::NodeWalker.each(statements) do |descendant|
2880
+ name = ReflectiveIvarWrite.literal_name(descendant) ||
2881
+ (descendant.name if IVAR_WRITE_NODES.include?(descendant.class))
2882
+ next if name.nil? || names&.include?(name) || !CapturedLocals.rebindable_ivar?(base_scope, name)
2883
+
2884
+ (names ||= []) << name
2885
+ end
2886
+ names || NO_LOOP_NAMES
2887
+ end
2888
+
2468
2889
  # Evaluates the loop body once with each fixpoint-tracked local bound to the supplied running assumption and
2469
2890
  # returns the per-name exit binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
2470
2891
  #
@@ -2488,19 +2909,20 @@ module Rigor
2488
2909
  # it.
2489
2910
  def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
2490
2911
  entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2491
- exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
2912
+ exit_scope, breaks, values = loop_iteration(node.statements, entry, jumps)
2492
2913
  if break_pass
2493
2914
  break_pass[:entry] = bindings.except(*body_first)
2494
2915
  break_pass[:arms] = breaks
2916
+ break_pass[:values] = values
2495
2917
  end
2496
- names.to_h { |name| [name, exit_scope.local(name)] }
2918
+ names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
2497
2919
  end
2498
2920
 
2499
2921
  # The scope one fixpoint pass enters the body with: `post_pred` overlaid with the pre-existing names' running
2500
2922
  # assumption, then narrowed by the predicate's loop-entry edge ({#loop_body_exit_bindings} carries the why).
2501
2923
  def loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2502
2924
  overlaid = bindings.except(*body_first)
2503
- entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
2925
+ entry = overlaid.reduce(post_pred) { |acc, (name, type)| bind_capture(acc, name, type) }
2504
2926
  entry = loop_content_entry(node.statements, post_pred, entry)
2505
2927
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
2506
2928
  edge = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
@@ -2550,29 +2972,157 @@ module Rigor
2550
2972
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
2551
2973
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
2552
2974
  # `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
2553
- # expression itself types as `Constant[nil]`, the policy `eval_loop` uses for `while` / `until` — a known gap for
2554
- # `for`, whose value in Ruby is the collection it iterated (issue #1216).
2975
+ # expression is the collection's `each` value joined with every `break` value ({#for_value}, issue #1216).
2555
2976
  def eval_for(node)
2556
2977
  coll_type, post_coll = sub_eval(node.collection, scope)
2557
- element_type = for_iteration_element_type(coll_type)
2558
- # Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
2559
- body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
2560
- body_entry = forget_guard_for_body(node, post_coll, body_entry)
2561
- body_entry = loop_content_entry(node.statements, post_coll, body_entry)
2978
+ body_entry = for_body_entry(node, for_iteration_element_type(coll_type), post_coll)
2562
2979
 
2563
2980
  if node.statements.nil?
2564
- return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
2981
+ return [for_value(node, coll_type, post_coll, NO_BREAK_ARMS),
2982
+ join_with_nil_injection(post_coll, body_entry)]
2565
2983
  end
2566
2984
 
2567
2985
  # The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
2568
2986
  # the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
2569
2987
  # the only continuation and the only source of `break` arms).
2570
2988
  jumps = loop_jumps(node.statements)
2571
- body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
2989
+ body_scope, breaks, values = loop_iteration(node.statements, body_entry, jumps)
2990
+ values += later_for_pass_values(node.statements, body_entry, body_scope, jumps)
2572
2991
  continuation = join_with_nil_injection(post_coll, body_scope)
2573
2992
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
2574
- continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
2575
- [Type::Combinator.constant_of(nil), for_exit(node, post_coll, continuation)]
2993
+ continuation = for_exit(node, post_coll, join_break_scopes(continuation, breaks, pre_existing + body_first))
2994
+ continuation = widen_for_elements(node, continuation)
2995
+ [for_value(node, coll_type, continuation, values), continuation]
2996
+ end
2997
+
2998
+ # Issue #2262 — the variables the collection reads, past a body that mutates an element in place: `for x in a;
2999
+ # x << 2; end` leaves `a[0]` holding what the literal's shape did not.
3000
+ def widen_for_elements(node, scope)
3001
+ DeepErasure.widen_element_aliases(
3002
+ node.collection, node.statements, DeepErasure.parameter_names(node.index), scope
3003
+ )
3004
+ end
3005
+
3006
+ # Issue #2128 — a `for` body has no rebind fixpoint, so its only pass enters with every loop-carried local at its
3007
+ # pre-loop binding, and a `return` or `break` whose condition needs a later iteration (`n = 0; for x in xs;
3008
+ # return :f if (n += 1) > 2; end`) was never reached. When the body holds such a jump (a `break`, or a `return`
3009
+ # that exits the method while a return sink is active, {#later_pass_returns?}), one more unrecorded pass runs
3010
+ # with each local and instance variable the body rebinds at its value-pinned widening
3011
+ # (`Type::Combinator.widen_value_pinned`) of its entry binding joined with the first pass's exit, a binding that
3012
+ # covers what any iteration enters with, as the `while` fixpoint's widened binding does. The `return`s it
3013
+ # reaches join the method's return type as any pass's do, and the `break` values it collects are returned for
3014
+ # the loop's value ({#for_value}). The pass records no scope, and the continuation keeps the first pass's
3015
+ # answer.
3016
+ def later_for_pass_values(statements, body_entry, body_scope, jumps)
3017
+ return NO_BREAK_ARMS unless jumps.breaks || later_pass_returns?(statements)
3018
+
3019
+ names, body_first = loop_rebound_names(statements, body_entry)
3020
+ bindings = (names - body_first).filter_map do |name|
3021
+ joined = [body_entry, body_scope].filter_map { |s| CapturedLocals.bound_type(s, name) }
3022
+ [name, Type::Combinator.widen_value_pinned(Type::Combinator.union(*joined))] unless joined.empty?
3023
+ end
3024
+ return NO_BREAK_ARMS if bindings.empty?
3025
+
3026
+ entry = bindings.reduce(body_entry) { |acc, (name, type)| bind_capture(acc, name, type) }
3027
+ loop_iteration(statements, entry, jumps, recorded: false)[2]
3028
+ end
3029
+
3030
+ # The core collections whose `each` with a block answers its receiver.
3031
+ FOR_SELF_EACH_CLASSES = Set["Array", "Hash", "Range"].freeze
3032
+ private_constant :FOR_SELF_EACH_CLASSES
3033
+
3034
+ # The nodes through which a `for` body or index may change the iterated collection's contents: a call (an
3035
+ # in-place mutator on the collection or on an alias of it, or a method that reaches it), `yield` / `super`, an
3036
+ # index store, and an operator write, which calls its operator on the variable's value (`b <<= 9` appends to
3037
+ # the array `b` holds).
3038
+ FOR_MUTATING_NODES = Set[
3039
+ Prism::CallNode, Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::IndexTargetNode,
3040
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
3041
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
3042
+ Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
3043
+ Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode
3044
+ ].freeze
3045
+ FOR_VARIABLE_READS = Set[Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode].freeze
3046
+ private_constant :FOR_MUTATING_NODES, :FOR_VARIABLE_READS
3047
+
3048
+ # Issue #1216 — `for x in coll` is `coll.each { |x| … }`, so the loop's value is what that `each` answers, or
3049
+ # the value of the `break` that ends it. An Array, a Hash and a Range answer themselves, including when
3050
+ # they are empty and the body never runs, so the value is the collection ({#for_collection_after}). Any other
3051
+ # collection (`Dynamic`, a project class, an `Enumerator`, a union with `nil`) answers `Dynamic[top]`: its
3052
+ # `each` is not known to answer its receiver, and a guessed type would be a claim the analysis cannot back.
3053
+ def for_value(node, coll_type, continuation, break_values)
3054
+ each_value = each_answers_self?(coll_type) ? for_collection_after(node, coll_type, continuation) : Type::Combinator.untyped
3055
+ break_values.empty? ? each_value : Type::Combinator.union(each_value, *break_values)
3056
+ end
3057
+
3058
+ # The collection as the loop leaves it. `each` answers the object it iterated, after the body ran, so a body that
3059
+ # may change its contents ({FOR_MUTATING_NODES}: `a.pop`, `h.delete(k)`, `b << 3` on an alias `b`) leaves
3060
+ # neither its shape nor its size: `a = [1, 2]; for i in a; a.pop; end` is `[1]`. The value is then the
3061
+ # collection's type with every shape and refinement dropped to its nominal ({#shape_erased_collection}), joined,
3062
+ # when the collection is a local or instance variable the loop does not rebind, with that variable's binding
3063
+ # after the loop, which holds the contents the body's own mutator calls add. A body that runs no such code
3064
+ # leaves the collection as it was.
3065
+ def for_collection_after(node, coll_type, continuation)
3066
+ return coll_type unless [node.index, node.statements].any? { |part| for_may_mutate?(part) }
3067
+
3068
+ types = [coll_type]
3069
+ name = for_collection_variable(node)
3070
+ types << CapturedLocals.bound_type(continuation, name) if name
3071
+ deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index))
3072
+ Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
3073
+ end
3074
+
3075
+ def for_may_mutate?(part)
3076
+ return false if part.nil?
3077
+
3078
+ found = false
3079
+ Source::NodeWalker.each(part) { |descendant| found ||= FOR_MUTATING_NODES.include?(descendant.class) }
3080
+ found || FOR_MUTATING_NODES.include?(part.class)
3081
+ end
3082
+
3083
+ # The local or instance variable `for` iterates, when neither its index nor its body rebinds it.
3084
+ def for_collection_variable(node)
3085
+ collection = node.collection
3086
+ return nil unless FOR_VARIABLE_READS.include?(collection.class)
3087
+
3088
+ written = OperandEffects.written_variables(node.index) + OperandEffects.written_variables(node.statements)
3089
+ written.include?(collection.name) ? nil : collection.name
3090
+ end
3091
+
3092
+ # `type` with each `Tuple` / `HashShape` / literal Array or Hash dropped to its nominal, each refinement to its
3093
+ # base. A Range is immutable and keeps its value.
3094
+ def shape_erased_collection(type, deep: false)
3095
+ # A body that mutates an element in place changes the shapes one level down too, which `each` answers
3096
+ # as the elements' own objects ({DeepErasure.widen}).
3097
+ return DeepErasure.widen(type) if deep && !(type.is_a?(Type::Constant) && type.value.is_a?(Range))
3098
+
3099
+ case type
3100
+ when Type::Tuple then MutationWidening.widen_tuple(type)
3101
+ when Type::HashShape then MutationWidening.widen_hash_shape(type)
3102
+ when Type::Constant then type.value.is_a?(Range) ? type : Type::Combinator.widen_value_pinned(type)
3103
+ when Type::Refined, Type::Difference then shape_erased_collection(type.base)
3104
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| shape_erased_collection(member) })
3105
+ else type
3106
+ end
3107
+ end
3108
+
3109
+ def each_answers_self?(type)
3110
+ case type
3111
+ when Type::Tuple, Type::HashShape then true
3112
+ when Type::Nominal then FOR_SELF_EACH_CLASSES.include?(type.class_name)
3113
+ when Type::Constant then type.value.is_a?(Array) || type.value.is_a?(Hash) || type.value.is_a?(Range)
3114
+ when Type::Refined, Type::Difference then each_answers_self?(type.base)
3115
+ when Type::Union then type.members.all? { |member| each_answers_self?(member) }
3116
+ else false
3117
+ end
3118
+ end
3119
+
3120
+ # The scope a `for` body enters with: the index bound to the element. Issue #1359 — a body that may set `$_` runs
3121
+ # again after it ran, as a `while` body does ({#eval_loop}).
3122
+ def for_body_entry(node, element_type, post_coll)
3123
+ body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
3124
+ body_entry = forget_guard_for_body(node, post_coll, body_entry)
3125
+ loop_content_entry(node.statements, post_coll, body_entry)
2576
3126
  end
2577
3127
 
2578
3128
  # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
@@ -2866,15 +3416,47 @@ module Rigor
2866
3416
  # u.strip`, where the raise almost always comes from `Float` after it. Neither the arm nor anything in it
2867
3417
  # is recorded or typed from there, which keeps it where it was before #1256: an ADR-5 trade of the rare
2868
3418
  # raise-before-write path for no false positive on the common one.
2869
- arm_entry = ErrorInfo.modifier_entry(join_with_nil_injection(scope, after_expression), node.rescue_expression)
2870
- after_rescue = thread_operand(node.rescue_expression, arm_entry, OperandWalk.new(nil), arm_entry)
2871
- type = OperandWalk.type_of(scope, node, tracer, walk.types(tracer))
3419
+ #
3420
+ # Issue #1839 — the arm enters with each name `expr` writes that it mentions, or that `expr` writes more
3421
+ # than once, read as `Dynamic[top]` ({#widen_clause_entry}), as a `begin`'s rescue clause does: the raise
3422
+ # may come after any of those writes. The scope past the modifier joins that entry through the arm.
3423
+ fallback = node.rescue_expression
3424
+ writes = clause_writes([node.expression])
3425
+ record_modifier_fallback(fallback, writes, walk)
3426
+ joined = join_with_nil_injection(scope, after_expression)
3427
+ widened = widen_clause_entry(joined, writes, fallback)
3428
+ arm_entry = ErrorInfo.modifier_entry(widened || joined, fallback)
3429
+ after_rescue = thread_operand(fallback, arm_entry, OperandWalk.new(nil), arm_entry)
2872
3430
  after = if branch_unconditionally_exits?(node.rescue_expression)
2873
3431
  after_expression
2874
3432
  else
2875
3433
  join_with_nil_injection(after_expression, after_rescue)
2876
3434
  end
2877
- [type, after]
3435
+ [rescue_modifier_type(node, walk, widened && arm_entry), after]
3436
+ end
3437
+
3438
+ # Issue #1839 — the walk records nothing in the fallback, so the index's walk hands it the modifier's own entry,
3439
+ # which binds each name `expr` writes as the modifier found it: `conn = nil; (conn = make; work) rescue
3440
+ # conn.size` read `conn` as `nil`. The fallback is recorded under that entry with the names `writes`
3441
+ # {ClauseWidening.widen} picks read as `Dynamic[top]`, and `$!`, `$@` and `$?` unbound as the walk leaves them,
3442
+ # when the widening changes it and the check path asks for it ({#widening_recorded?}). A name `expr` first
3443
+ # binds keeps the entry's reading, for the trade above.
3444
+ def record_modifier_fallback(fallback, writes, walk)
3445
+ return unless walk.recorder && @widen_clauses
3446
+
3447
+ widened = widen_clause_entry(scope, writes, fallback)
3448
+ walk.recorder.call(fallback, widened.forget_error_info.forget_last_status) if widened
3449
+ end
3450
+
3451
+ # Issue #1839 — the value of `expr rescue alt`: the fallback is typed from `widened_entry`, its entry with each
3452
+ # name `expr` writes read as `Dynamic[top]` ({#widen_clause_entry}), as a `begin`'s rescue clause reads it, not
3453
+ # at the entry's binding: `got = (conn = make; work) rescue conn` leaves `got` untyped, not `nil`. Nil when the
3454
+ # widening changes nothing, and the modifier is typed whole.
3455
+ def rescue_modifier_type(node, walk, widened_entry)
3456
+ return OperandWalk.type_of(scope, node, tracer, walk.types(tracer)) unless widened_entry
3457
+
3458
+ Type::Combinator.union(OperandWalk.type_of(scope, node.expression, tracer, walk.types(tracer)),
3459
+ widened_entry.type_of(node.rescue_expression, tracer: tracer))
2878
3460
  end
2879
3461
 
2880
3462
  # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
@@ -3130,7 +3712,7 @@ module Rigor
3130
3712
  # map …) that rebinds an outer local must not leave that local's pre-call binding unmodified in the continuation
3131
3713
  # scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
3132
3714
  # category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
3133
- post_scope = write_back_block_captures(node, post_scope)
3715
+ post_scope = block_capture_continuation(node, post_scope)
3134
3716
  statement_call = !call_type.nil? && !@in_operand
3135
3717
  post_scope = apply_post_return_narrowing(node, post_scope) if statement_call
3136
3718
  # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
@@ -3337,30 +3919,21 @@ module Rigor
3337
3919
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3338
3920
  # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
3339
3921
  # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3922
+ #
3923
+ # Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
3924
+ # block makes one ({InterveningIvarReset.after_block}: `[1].each { reset }`). Both widen as a condition's
3925
+ # calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
3926
+ # code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
3927
+ # (`@items << yield`, `puts(yield)`) widens every narrowed ivar ({InterveningIvarReset.after_operands}).
3340
3928
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3341
- return current_scope unless intervening_call_candidate?(call_node)
3342
-
3343
- class_name = enclosing_class_name_for(current_scope.self_type)
3344
- return current_scope if class_name.nil?
3345
-
3346
- seed = current_scope.class_ivars_for(class_name)
3347
- return current_scope if seed.empty?
3348
-
3349
- widened = current_scope
3350
- seed.each do |ivar_name, seed_type|
3351
- local_type = current_scope.ivar(ivar_name)
3352
- next if local_type.nil? || local_type == seed_type
3353
-
3354
- writes = self_call_ivar_writes(call_node)
3355
- next if writes && !writes.include?(ivar_name)
3356
-
3357
- joined = Type::Combinator.union(local_type, seed_type)
3358
- widened = widened.with_ivar(ivar_name, joined)
3359
- # Issue #1889 — an ivar a reflective write widened keeps its declaration mark, so the reset must too.
3360
- widened = widened.seed_declaration_sourced_ivar(ivar_name, joined) if
3361
- current_scope.declaration_sourced?(:ivar, ivar_name)
3362
- end
3363
- widened
3929
+ widened =
3930
+ if intervening_call_candidate?(call_node)
3931
+ mark = !InterveningIvarReset.self_call?(call_node)
3932
+ InterveningIvarReset.widen(current_scope, mark: mark) { self_call_ivar_writes(call_node) }
3933
+ else
3934
+ InterveningIvarReset.after_block(call_node, current_scope)
3935
+ end
3936
+ InterveningIvarReset.after_operands(call_node, widened)
3364
3937
  end
3365
3938
 
3366
3939
  # Issue #1596 — the ivars the implicit-self / `self.` call `call_node` may write, or nil when that is not proven
@@ -3373,10 +3946,7 @@ module Rigor
3373
3946
  end
3374
3947
 
3375
3948
  def intervening_call_candidate?(call_node)
3376
- return false unless call_node.is_a?(Prism::CallNode)
3377
-
3378
- receiver = call_node.receiver
3379
- receiver.nil? || receiver.is_a?(Prism::SelfNode)
3949
+ InterveningIvarReset.candidate?(call_node)
3380
3950
  end
3381
3951
 
3382
3952
  def enclosing_class_name_for(self_type)
@@ -3876,7 +4446,7 @@ module Rigor
3876
4446
  classification = classify_closure_escape(node)
3877
4447
  return post_scope if classification == :non_escaping
3878
4448
 
3879
- post_scope = escaping_closure_captures(node.block, post_scope)
4449
+ post_scope = escaping_closure_captures(node.block, post_scope, foreign_self: foreign_self_block?(node))
3880
4450
  post_scope.with_fact(
3881
4451
  Analysis::FactStore::Fact.new(
3882
4452
  bucket: :dynamic_origin,
@@ -3918,13 +4488,163 @@ module Rigor
3918
4488
  end
3919
4489
  end
3920
4490
 
3921
- # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
3922
- # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
3923
- # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
3924
- # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
4491
+ # The `{ name => slot }` parameters whose content the callee mutates, a slot being a positional index or a
4492
+ # keyword symbol: those it mutates itself ({#own_content_parameters}) and, issue #2104, those it passes on to a
4493
+ # self-call whose callee mutates them, through any number of such hand-offs ({CalleeMutationChain}). Each leaves
4494
+ # the caller's argument binding stale after the call, so each floors it. Memoised per def node (the closure is
4495
+ # otherwise recomputed at every call site), and across evaluators by {#resolved_census}.
3925
4496
  def callee_content_mutated_parameters(def_node)
3926
4497
  cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
3927
- cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
4498
+ cache[def_node] ||= resolved_census(:close, def_node) do
4499
+ CalleeMutationChain.close(def_node, own: method(:own_content_parameters), edges: method(:forwarding_edges))
4500
+ end
4501
+ end
4502
+
4503
+ # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callee_def}), so it
4504
+ # reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
4505
+ # resolve through. Those two key it: the index by identity, as it is immutable (one slot, as a file's analysis
4506
+ # reads one index; a scope indexed for another file replaces it), the class by name. A run builds hundreds of
4507
+ # thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
4508
+ # thirty times over (#2295's cost on the release gate).
4509
+ #
4510
+ # While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
4511
+ # and in each capture window open at the time, and a hit would leave that read out of them.
4512
+ def resolved_census(kind, def_node)
4513
+ return yield if Analysis::DependencyRecorder.active?
4514
+
4515
+ discovery = scope.discovery
4516
+ slot = Thread.current[RESOLVED_CENSUS_KEY]
4517
+ slot = Thread.current[RESOLVED_CENSUS_KEY] = [discovery, {}] unless slot&.first.equal?(discovery)
4518
+ by_class = (slot.last[kind] ||= {})
4519
+ class_name = enclosing_class_name_for(scope.self_type)
4520
+ by_def = (by_class[class_name] ||= {}.compare_by_identity)
4521
+ by_def.fetch(def_node) { by_def[def_node] = yield }
4522
+ end
4523
+
4524
+ RESOLVED_CENSUS_KEY = :rigor_callee_resolved_census
4525
+ private_constant :RESOLVED_CENSUS_KEY
4526
+
4527
+ # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
4528
+ # run later / repeatedly) AND those mutated directly in the method body during the call itself
4529
+ # (`declaration[:prefix] = v`). A property of the def's AST alone, so it is memoised per def node for the thread
4530
+ # ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
4531
+ # call into it.
4532
+ def own_content_parameters(def_node)
4533
+ def_census(:own, def_node) { escaped_content_parameters(def_node).merge(direct_content_parameters(def_node)) }
4534
+ end
4535
+
4536
+ # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
4537
+ def def_census(kind, def_node)
4538
+ table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
4539
+ entry = (table[def_node] ||= {})
4540
+ entry.fetch(kind) { entry[kind] = yield }
4541
+ end
4542
+
4543
+ DEF_CENSUS_KEY = :rigor_callee_def_census
4544
+ private_constant :DEF_CENSUS_KEY
4545
+
4546
+ # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
4547
+ # a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
4548
+ # (not a block-local shadowing it), in the method body or a block in it. The callee resolves in this evaluator's
4549
+ # class, the receiver's class at every hop of a self-call chain. Memoised per def node, and across evaluators by
4550
+ # {#resolved_census}.
4551
+ def forwarding_edges(def_node)
4552
+ cache = (@forwarding_edge_cache ||= {}.compare_by_identity)
4553
+ cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
4554
+ end
4555
+
4556
+ def compute_forwarding_edges(def_node)
4557
+ slots = content_parameter_slots(def_node)
4558
+ edges = []
4559
+ each_forwarding_call(def_node, slots) do |call_node, forwarded|
4560
+ callee = resolve_self_callee_def(call_node)
4561
+ next if callee.nil?
4562
+
4563
+ callee_slots = content_parameter_slots(callee)
4564
+ forwarded.each do |name, callee_slot|
4565
+ names = bound_parameter_names(callee_slots, callee_slot)
4566
+ next if names.empty?
4567
+
4568
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names)
4569
+ end
4570
+ end
4571
+ edges.empty? ? NO_FORWARDS : edges
4572
+ end
4573
+
4574
+ # Yields each self-call in `def_node`'s body that hands on one of `slots`' parameters, with
4575
+ # {#forwarded_parameters}' answer for it. The calls are an AST property, memoised per def node ({#def_census}).
4576
+ def each_forwarding_call(def_node, slots, &)
4577
+ return if slots.empty?
4578
+
4579
+ def_census(:forwards, def_node) { forwarding_calls(def_node, slots) }.each(&)
4580
+ end
4581
+
4582
+ def forwarding_calls(def_node, slots)
4583
+ calls = []
4584
+ each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
4585
+ next unless self_dispatch_call?(descendant)
4586
+
4587
+ forwarded = forwarded_parameters(descendant, slots, depth)
4588
+ calls << [descendant, forwarded] unless forwarded.empty?
4589
+ end
4590
+ calls.empty? ? NO_FORWARDS : calls
4591
+ end
4592
+
4593
+ NO_FORWARDS = [].freeze
4594
+ private_constant :NO_FORWARDS
4595
+
4596
+ # `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
4597
+ # `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
4598
+ # argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
4599
+ # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index.
4600
+ def forwarded_parameters(call_node, slots, depth)
4601
+ args = call_node.arguments
4602
+ return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
4603
+
4604
+ spread = false
4605
+ forwarded = []
4606
+ args.arguments.each_with_index do |argument, index|
4607
+ if argument.is_a?(Prism::KeywordHashNode)
4608
+ forwarded.concat(forwarded_keywords(argument, slots, depth))
4609
+ elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
4610
+ spread = true
4611
+ elsif (name = parameter_read_name(argument, slots, depth))
4612
+ forwarded << [name, spread ? ANY_SLOT : index]
4613
+ end
4614
+ end
4615
+ forwarded
4616
+ end
4617
+
4618
+ # The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
4619
+ ANY_SLOT = :__any_positional__
4620
+ private_constant :ANY_SLOT
4621
+
4622
+ # The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
4623
+ # may reach: the one at that slot, or every positional one for {ANY_SLOT}.
4624
+ def bound_parameter_names(callee_slots, slot)
4625
+ if slot == ANY_SLOT
4626
+ callee_slots.filter_map { |name, at| name if at.is_a?(Integer) }
4627
+ else
4628
+ callee_slots.filter_map { |name, at| name if at == slot }
4629
+ end
4630
+ end
4631
+
4632
+ def forwarded_keywords(hash_node, slots, depth)
4633
+ hash_node.elements.filter_map do |pair|
4634
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
4635
+
4636
+ name = parameter_read_name(pair.value, slots, depth)
4637
+ [name, pair.key.unescaped.to_sym] if name
4638
+ end
4639
+ end
4640
+
4641
+ # The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
4642
+ # `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
4643
+ def parameter_read_name(node, slots, depth)
4644
+ node = node.value if node.is_a?(Prism::ImplicitNode)
4645
+ return unless node.is_a?(Prism::LocalVariableReadNode) && node.depth == depth
4646
+
4647
+ node.name if slots.key?(node.name)
3928
4648
  end
3929
4649
 
3930
4650
  # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
@@ -3943,7 +4663,7 @@ module Rigor
3943
4663
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
3944
4664
  end
3945
4665
 
3946
- # The set of `[name, position]` parameters of `def_node` whose content a block in the body escape-mutates.
4666
+ # The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
3947
4667
  # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
3948
4668
  # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
3949
4669
  # non-escaping.
@@ -3953,7 +4673,7 @@ module Rigor
3953
4673
  end
3954
4674
 
3955
4675
  def compute_escaped_content_parameters(def_node)
3956
- positions = positional_parameter_positions(def_node)
4676
+ positions = content_parameter_slots(def_node)
3957
4677
  return {} if positions.empty?
3958
4678
 
3959
4679
  mutated = Set.new
@@ -3968,18 +4688,22 @@ module Rigor
3968
4688
  positions.slice(*mutated)
3969
4689
  end
3970
4690
 
3971
- # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
3972
- # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
3973
- # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
4691
+ # The `{ name => slot }` parameters whose CONTENT the callee mutates directly in its method body — a top-level
4692
+ # `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case is
4693
+ # `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
3974
4694
  # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
3975
4695
  # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
4696
+ #
4697
+ # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted: the bare-collection floor
4698
+ # it would apply turned haml's `AttributeCompiler#compile` (a `temple` array a helper fills in a `times` block,
4699
+ # returned against a declared tuple) into a `def.return-type-mismatch` (af3efef33, and again under #2104).
3976
4700
  def direct_content_parameters(def_node)
3977
4701
  cache = (@direct_param_cache ||= {}.compare_by_identity)
3978
4702
  cache[def_node] ||= compute_direct_content_parameters(def_node)
3979
4703
  end
3980
4704
 
3981
4705
  def compute_direct_content_parameters(def_node)
3982
- positions = positional_parameter_positions(def_node)
4706
+ positions = content_parameter_slots(def_node)
3983
4707
  return {} if positions.empty?
3984
4708
 
3985
4709
  mutated = Set.new
@@ -4004,6 +4728,43 @@ module Rigor
4004
4728
  end
4005
4729
  end
4006
4730
 
4731
+ # Yields every node reachable from `node` with the number of blocks it sits in: a read of the method's own local
4732
+ # there has that depth. A nested def or lambda is not entered: a def has no access to the method's locals, and a
4733
+ # lambda body is not run by the call. Nor is the block of a {#self_rebinding_block_call?}: a self-call there
4734
+ # either runs on another `self`, or runs later as a method body, so the walk leaves it out.
4735
+ def each_node_in_method_scope(node, depth, &)
4736
+ return if node.nil?
4737
+
4738
+ yield node, depth
4739
+ rebinding = self_rebinding_block_call?(node)
4740
+ node.rigor_each_child do |child|
4741
+ next if child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
4742
+
4743
+ if child.is_a?(Prism::BlockNode)
4744
+ each_node_in_method_scope(child, depth + 1, &) unless rebinding
4745
+ else
4746
+ each_node_in_method_scope(child, depth, &)
4747
+ end
4748
+ end
4749
+ end
4750
+
4751
+ # The calls whose block the hand-off walk leaves out. In an `instance_eval` / `class_exec` / … block, and in a
4752
+ # `Class.new` / `Module.new` / `Struct.new` / `Data.define` block ({AnonymousMetaClass.block_form_receiver}),
4753
+ # `self` is another object, so a self-call there is not a call on the method's receiver. A `define_method` or
4754
+ # `define_singleton_method` block keeps an instance of the class as `self`, but it is a method body that runs
4755
+ # later, if ever, so leaving it out is the conservative reading: a hand-off it holds is not one the call makes.
4756
+ SELF_REBINDING_BLOCK_CALLS = Set[
4757
+ :instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
4758
+ :define_singleton_method
4759
+ ].freeze
4760
+ private_constant :SELF_REBINDING_BLOCK_CALLS
4761
+
4762
+ def self_rebinding_block_call?(node)
4763
+ return false unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
4764
+
4765
+ SELF_REBINDING_BLOCK_CALLS.include?(node.name) || !AnonymousMetaClass.block_form_receiver(node).nil?
4766
+ end
4767
+
4007
4768
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
4008
4769
  # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
4009
4770
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
@@ -4024,18 +4785,20 @@ module Rigor
4024
4785
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
4025
4786
  end
4026
4787
 
4027
- # `{ name => position }` for the required / optional positional parameters of a def. Keyword / rest / block
4028
- # parameters are skipped — the boundary-escape idiom passes a plain positional collection.
4029
- def positional_parameter_positions(def_node)
4788
+ # `{ name => slot }` for the parameters a caller's argument binds by name or position: the required / optional
4789
+ # positional ones (slot: their index) and, issue #2104, the keyword ones (slot: the keyword symbol). Rest, post
4790
+ # and block parameters are skipped.
4791
+ def content_parameter_slots(def_node)
4030
4792
  params = def_node.parameters
4031
4793
  return {} if params.nil?
4032
4794
 
4033
4795
  ordered = (params.requireds || []) + (params.optionals || [])
4034
- positions = {}
4796
+ slots = {}
4035
4797
  ordered.each_with_index do |param, index|
4036
- positions[param.name] = index if param.respond_to?(:name)
4798
+ slots[param.name] = index if param.respond_to?(:name)
4037
4799
  end
4038
- positions
4800
+ (params.keywords || []).each { |param| slots[param.name] = param.name }
4801
+ slots
4039
4802
  end
4040
4803
 
4041
4804
  # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
@@ -4051,9 +4814,10 @@ module Rigor
4051
4814
  scope.locals.key?(arg.name)
4052
4815
  when Prism::KeywordHashNode
4053
4816
  arg.elements.any? do |pair|
4054
- pair.is_a?(Prism::AssocNode) &&
4055
- pair.value.is_a?(Prism::LocalVariableReadNode) &&
4056
- scope.locals.key?(pair.value.name)
4817
+ next false unless pair.is_a?(Prism::AssocNode)
4818
+
4819
+ value = pair.value.is_a?(Prism::ImplicitNode) ? pair.value.value : pair.value
4820
+ value.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(value.name)
4057
4821
  end
4058
4822
  else
4059
4823
  false
@@ -4180,13 +4944,80 @@ module Rigor
4180
4944
  # callback that runs later writes the very ivar the continuation reads (`@clicked = false; button.on_click {
4181
4945
  # @clicked = true }` left `if @clicked` folding always-falsey). One still on its ADR-58 declaration seed is
4182
4946
  # left alone ({CapturedLocals.writes}): that seed already holds whatever the callback stores.
4183
- def drop_captured_narrowing(block_node, base_scope)
4184
- names = CapturedLocals.writes(block_node, base_scope, ivars: true)
4947
+ def drop_captured_narrowing(block_node, base_scope, foreign_self: false)
4948
+ names = CapturedLocals.writes(block_node, base_scope, ivars: true, foreign_self: foreign_self)
4185
4949
  return base_scope if names.empty?
4186
4950
 
4187
4951
  names.reduce(base_scope) { |acc, name| bind_capture(acc, name, Type::Combinator.untyped) }
4188
4952
  end
4189
4953
 
4954
+ # The continuation {#write_back_block_captures} binds, after which the block's later-run `return`s are collected
4955
+ # ({#collect_repeated_block_returns}).
4956
+ def block_capture_continuation(call_node, post_scope)
4957
+ post_scope = write_back_block_captures(call_node, post_scope)
4958
+ collect_repeated_block_returns(call_node, post_scope)
4959
+ post_scope
4960
+ end
4961
+
4962
+ # Issue #2123 — the `return`s a repeating block reaches only on a later run. Every statement pass over a block
4963
+ # enters with a captured local or ivar the body rebinds at a binding some run can hold, but not at every such
4964
+ # binding: the statement pass enters at the call-site binding, and the ADR-56 write-back's capped fixpoint widens
4965
+ # its last assumption without running a pass from it. So
4966
+ # `seen = 0; items.each { return false if (seen += 1) > 3 }; true` read `seen` as at most `3` on every pass, the
4967
+ # `return` was never reached, the method inferred `true`, and a caller's `if` on it folded always-falsey on
4968
+ # correct code.
4969
+ #
4970
+ # While a method's return sink is active, a block that may repeat ({#repeating_block_class}), rebinds a capture,
4971
+ # and holds a `return` that exits the method ({ReturnBarrier.exits_method?}) gets one more unrecorded pass, with
4972
+ # each rebound name bound to its pre-call binding joined with its continuation `post_scope` holds. The
4973
+ # continuation covers every run count, so it covers the binding any run enters with. The pass records no
4974
+ # scope, and the method's fall-through and earlier `return`s are kept, so the inferred return type only widens.
4975
+ # The fresh `next`, `break`-value and `break`-scope sinks are defensive: every consumer of those sinks filters by
4976
+ # jump-node identity, so no example tells them apart from the outer sinks today. So is the `rescue`: a raise here
4977
+ # keeps the arms the earlier passes collected rather than taking out the method.
4978
+ #
4979
+ # Issue #2127 — a block passed to a project method whose body may run it more than once
4980
+ # ({ProjectYielder.may_run_block_repeatedly?}) gets the same pass. No write-back runs for such a call, so its
4981
+ # continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
4982
+ # so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
4983
+ def collect_repeated_block_returns(call_node, post_scope)
4984
+ return if Thread.current[RETURN_SINK_KEY].nil?
4985
+
4986
+ block = call_node.block
4987
+ return unless repeated_block_return_candidate?(call_node, block)
4988
+
4989
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
4990
+ return if names.empty?
4991
+
4992
+ bindings = names.to_h do |name|
4993
+ [name, Type::Combinator.union(*[scope, post_scope].filter_map { |s| CapturedLocals.bound_type(s, name) })]
4994
+ end
4995
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
4996
+ StatementEvaluator.with_next_sink do
4997
+ StatementEvaluator.with_break_value_sink do
4998
+ collect_break_scopes { sub_eval(block, entry, **UNRECORDED, next_scope_sink: nil) }
4999
+ end
5000
+ end
5001
+ rescue StandardError
5002
+ nil
5003
+ end
5004
+
5005
+ # Whether `block` is an ordinary block (not a return barrier or a class-body block) whose body may `return` from
5006
+ # the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, `Kernel#loop`, which
5007
+ # that rule leaves out and which runs its block until it breaks or raises, or a project method whose body may
5008
+ # run it more than once ({ProjectYielder.may_run_block_repeatedly?}, issue #2127). The barrier, refinement and
5009
+ # anonymous-class checks are defensive guards: no such call repeats under that rule today.
5010
+ def repeated_block_return_candidate?(call_node, block)
5011
+ return false unless block.is_a?(Prism::BlockNode)
5012
+ return false unless ReturnBarrier.exits_method?(block.body)
5013
+ return false if return_barrier_block?(call_node) || refined_class_context(call_node)
5014
+ return false if AnonymousMetaClass.name_for(call_node, scope.source_path)
5015
+ return true if BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
5016
+
5017
+ receiver_type = block_call_receiver_type(call_node)
5018
+ !receiver_type.nil? && ProjectYielder.may_run_block_repeatedly?(call_node, receiver_type, scope)
5019
+ end
5020
+
4190
5021
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
4191
5022
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
4192
5023
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
@@ -4211,13 +5042,27 @@ module Rigor
4211
5042
  return post_scope unless block.is_a?(Prism::BlockNode)
4212
5043
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
4213
5044
 
4214
- names = CapturedLocals.writes(block, scope, ivars: true)
5045
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
4215
5046
  return post_scope if names.empty?
4216
5047
 
4217
5048
  break_pass = block_break_pass(block)
4218
- result = converge_captures_by_kind(call_node, block, names, break_pass)
5049
+ marks = {}
5050
+ result = with_capture_exit_marks(marks) { converge_captures_by_kind(call_node, block, names, break_pass) }
4219
5051
  result = join_block_break_bindings(call_node, block, result, break_pass)
4220
- result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
5052
+ result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type, optimistic: marks[name]) }
5053
+ end
5054
+
5055
+ # Issue #1402 — runs the write-back's passes with `marks` collecting the ADR-101 optimistic mark a pass's exit
5056
+ # holds for each rebound name ({#block_exit_bindings}), as the per-element fold collects them
5057
+ # (`ExpressionTyper#captured_exit_bindings`). A later pass enters on that mark, and the continuation binds it:
5058
+ # `x = false; pairs.each { x = pairs.first&.empty? }` stored the miss's `nil` at run time, and without the mark
5059
+ # `x.nil?` folded to `false` where the inline `pairs.first&.empty?.nil?` answers `bool`.
5060
+ def with_capture_exit_marks(marks)
5061
+ outer = @capture_exit_marks
5062
+ @capture_exit_marks = marks
5063
+ yield
5064
+ ensure
5065
+ @capture_exit_marks = outer
4221
5066
  end
4222
5067
 
4223
5068
  # The {BodyFixpoint} continuation of `names`, seeded from their pre-call bindings.
@@ -4276,7 +5121,12 @@ module Rigor
4276
5121
  # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
4277
5122
  # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
4278
5123
  # rebinds no ivar leaves the locals exactly as before.
4279
- def bind_capture(scope, name, type)
5124
+ #
5125
+ # Issue #1402 — `optimistic` is a mark a write-back pass's exit held ({#with_capture_exit_marks}); a name that has
5126
+ # one binds through {CapturedLocals.bind} whatever its kind, which adds it.
5127
+ def bind_capture(scope, name, type, optimistic: nil)
5128
+ return CapturedLocals.bind(scope, name, type, optimistic: optimistic) if optimistic
5129
+
4280
5130
  CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
4281
5131
  end
4282
5132
 
@@ -5140,9 +5990,22 @@ module Rigor
5140
5990
  def block_exit_bindings(call_node, block, bindings, names)
5141
5991
  entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
5142
5992
  _type, exit_scope = sub_eval(block, entry)
5993
+ record_capture_exit_marks(exit_scope, names)
5143
5994
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
5144
5995
  end
5145
5996
 
5997
+ # Issue #1402 — records into {#with_capture_exit_marks}' table the mark `exit_scope` holds for each of `names`;
5998
+ # the first mark a name takes stays.
5999
+ def record_capture_exit_marks(exit_scope, names)
6000
+ marks = @capture_exit_marks
6001
+ return if marks.nil?
6002
+
6003
+ names.each do |name|
6004
+ mark = CapturedLocals.optimistic_mark(exit_scope, name)
6005
+ marks[name] ||= mark if mark
6006
+ end
6007
+ end
6008
+
5146
6009
  # The entry scope of one write-back pass: the block's entry with each written outer local or ivar bound to
5147
6010
  # `bindings`, and each captured local the body only mutates in place at its widening ({#capture_pass_bindings}).
5148
6011
  # That widening describes the same object, so it keeps the marks a source-level write drops (issue #1287,
@@ -5151,7 +6014,9 @@ module Rigor
5151
6014
  def block_pass_entry(call_node, block, bindings)
5152
6015
  entry = build_block_entry_scope(call_node, block)
5153
6016
  capture_pass_bindings(block, bindings).reduce(entry) do |acc, (name, type)|
5154
- bindings.key?(name) ? bind_capture(acc, name, type) : acc.with_mutated_local(name, type)
6017
+ next acc.with_mutated_local(name, type) unless bindings.key?(name)
6018
+
6019
+ bind_capture(acc, name, type, optimistic: @capture_exit_marks&.[](name))
5155
6020
  end
5156
6021
  end
5157
6022
 
@@ -5224,12 +6089,7 @@ module Rigor
5224
6089
  # receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
5225
6090
  # (`Object` at the top level).
5226
6091
  def repeating_block_class(call_node)
5227
- receiver_type =
5228
- if call_node.receiver
5229
- explicit_receiver_type(call_node)
5230
- else
5231
- operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
5232
- end
6092
+ receiver_type = block_call_receiver_type(call_node)
5233
6093
  return nil if receiver_type.nil?
5234
6094
 
5235
6095
  method_name = call_node.name
@@ -5243,6 +6103,13 @@ module Rigor
5243
6103
  nil
5244
6104
  end
5245
6105
 
6106
+ # The receiver a block-carrying call dispatches on: its explicit receiver's type, else `self`, else `Object`.
6107
+ def block_call_receiver_type(call_node)
6108
+ return explicit_receiver_type(call_node) if call_node.receiver
6109
+
6110
+ operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
6111
+ end
6112
+
5246
6113
  # The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
5247
6114
  # `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
5248
6115
  # its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
@@ -5255,7 +6122,7 @@ module Rigor
5255
6122
  def unproven_rebind_bindings(call_node, block, classification)
5256
6123
  return NO_CAPTURE_BINDINGS if call_node.receiver && classification == :non_escaping
5257
6124
 
5258
- names = CapturedLocals.writes(block, scope, ivars: true)
6125
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
5259
6126
  return NO_CAPTURE_BINDINGS if names.empty?
5260
6127
 
5261
6128
  untyped = Type::Combinator.untyped
@@ -5370,8 +6237,16 @@ module Rigor
5370
6237
  # The continuation effects of an escaping closure body: the outer locals it can rebind drop their narrowing, and
5371
6238
  # the ones it content-mutates are floored. Shared by the two spellings — a block on an escaping call
5372
6239
  # ({#record_closure_escape_if_any}) and a lambda literal ({#eval_lambda}) — so they cannot drift.
5373
- def escaping_closure_captures(closure_node, post_scope)
5374
- widen_escaping_content_captures(closure_node, drop_captured_narrowing(closure_node, post_scope))
6240
+ def escaping_closure_captures(closure_node, post_scope, foreign_self: false)
6241
+ widen_escaping_content_captures(closure_node,
6242
+ drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self))
6243
+ end
6244
+
6245
+ # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`
6246
+ # ({InterveningIvarReset.foreign_self_block?}, its receiver typed where the operands were), so that no instance
6247
+ # variable it writes is this scope's ({CapturedLocals.writes}' `foreign_self:`).
6248
+ def foreign_self_block?(call_node)
6249
+ InterveningIvarReset.foreign_self_block?(call_node, operand_scope)
5375
6250
  end
5376
6251
 
5377
6252
  # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
@@ -5822,7 +6697,7 @@ module Rigor
5822
6697
  # inferred.
5823
6698
  def eval_return(node)
5824
6699
  sink = Thread.current[RETURN_SINK_KEY]
5825
- sink << jump_value_type(node) if sink
6700
+ sink&.record(node, jump_value_type(node))
5826
6701
  [Type::Combinator.bot, scope]
5827
6702
  end
5828
6703