rigortype 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
@@ -14,7 +14,7 @@ require_relative "block_call_timing"
14
14
  require_relative "block_repetition"
15
15
  require_relative "block_parameter_binder"
16
16
  require_relative "body_fixpoint"
17
- require_relative "memo_audit"
17
+ require_relative "../memo_audit"
18
18
  require_relative "callee_mutation_chain"
19
19
  require_relative "toplevel_def_census"
20
20
  require_relative "callee_write_census"
@@ -28,6 +28,7 @@ require_relative "../analysis/check_rules/inferred_param_guard"
28
28
  require_relative "../analysis/check_rules/published_constant_guard"
29
29
  require_relative "struct_fold_safety"
30
30
  require_relative "closure_escape_analyzer"
31
+ require_relative "self_preserving_block"
31
32
  require_relative "content_join"
32
33
  require_relative "construction_escape"
33
34
  require_relative "define_method_block_self"
@@ -62,6 +63,7 @@ require_relative "unknown_store_widening"
62
63
  require_relative "untyped_rejoin"
63
64
  require_relative "version_guard"
64
65
  require_relative "../run_scoped_slots"
66
+ require_relative "../typing_state"
65
67
 
66
68
  module Rigor
67
69
  module Inference
@@ -194,13 +196,22 @@ module Rigor
194
196
  # return type. The flow value of a `return` is still `Bot` (it transfers control rather than producing a value);
195
197
  # the sink only records what the method *returns* through that edge. nil means "not collecting" — a top-level /
196
198
  # DSL-block walk, or inside a nested `def` barrier (whose returns belong to the inner method).
197
- RETURN_SINK_KEY = :rigor_return_sink
198
- private_constant :RETURN_SINK_KEY
199
+ #
200
+ # ADR-129 WD1 — the installation is a Frame and each record is a Resource touch.
201
+ RETURN_SINK = TypingState.frame(
202
+ :return_sink,
203
+ contents: :resource, reason: "installed per method body; each `return` records into it",
204
+ witness: "spec/fixtures/typing_state_witnesses/return_sink.rb"
205
+ )
206
+ private_constant :RETURN_SINK
199
207
 
200
208
  # Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
201
209
  # than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
202
- PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
203
- private_constant :PROVISIONAL_RETURNS_KEY
210
+ PROVISIONAL_RETURNS = TypingState.frame(
211
+ :provisional_returns, reason: "a flag installed around a side pass",
212
+ witness: "spec/fixtures/typing_state_witnesses/provisional_returns.rb"
213
+ )
214
+ private_constant :PROVISIONAL_RETURNS
204
215
 
205
216
  # The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
206
217
  # with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
@@ -215,8 +226,9 @@ module Rigor
215
226
  @provisional = []
216
227
  end
217
228
 
218
- def record(node, type)
219
- if Thread.current[PROVISIONAL_RETURNS_KEY]
229
+ # `provisional` is the {PROVISIONAL_RETURNS} frame the evaluator reached the `return` under.
230
+ def record(node, type, provisional)
231
+ if provisional
220
232
  @provisional << [node, type]
221
233
  else
222
234
  @types << type
@@ -234,14 +246,18 @@ module Rigor
234
246
  # Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
235
247
  # leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
236
248
  # block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
237
- # `Constant[true]` and folded the call to always-truthy — the same defect `RETURN_SINK_KEY` fixes one level up for
249
+ # `Constant[true]` and folded the call to always-truthy — the same defect `RETURN_SINK` fixes one level up for
238
250
  # a method's early `return`. The flow value of a `next` stays `Bot`.
239
251
  #
240
252
  # The sink also collects `next`s from nested blocks / loops / defs evaluated under the same installation (they do
241
253
  # not install their own), so the consumer filters by node identity against a statically computed
242
254
  # directly-targeting set, exactly as the break sink does. nil means "not collecting".
243
- NEXT_SINK_KEY = :rigor_next_sink
244
- private_constant :NEXT_SINK_KEY
255
+ NEXT_SINK = TypingState.frame(
256
+ :next_sink,
257
+ contents: :resource, reason: "installed per block body; each `next` records into it",
258
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
259
+ )
260
+ private_constant :NEXT_SINK
245
261
 
246
262
  # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the scope at each `break` reached while
247
263
  # evaluating a loop body, so `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true; break`) into
@@ -249,21 +265,33 @@ module Rigor
249
265
  # installs its own sink, restored on exit, so an inner loop's break does not leak to the outer one. A `break`
250
266
  # inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the loop's
251
267
  # {JumpTargets} set ({#loop_jumps}). See docs/notes/20260615-loop-break-binding-propagation-design.md.
252
- BREAK_SINK_KEY = :rigor_break_sink
253
- private_constant :BREAK_SINK_KEY
268
+ #
269
+ # ADR-129 WD3 — a construct reads the sink's size before it runs and again after, to find the jumps it recorded
270
+ # itself ({#restoring_error_info}, {#ensure_jump_marks}), so a read of the contents is delta-internal.
271
+ BREAK_SINK = TypingState.frame(
272
+ :break_sink,
273
+ contents: :resource, read_exemption: :delta_internal,
274
+ reason: "installed per loop body; each `break` records into it, and an enclosing `ensure` rewrites them",
275
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
276
+ )
277
+ private_constant :BREAK_SINK
254
278
 
255
279
  # Thread-local sink (an Array of `[BreakNode, Type]`) collecting the value each `break` carries out of the
256
280
  # construct it leaves. Issue #853: `break value` terminates the yielding CALL and is that call's value, so —
257
- # unlike `next`, which {NEXT_SINK_KEY} joins into the block — these arms are consumed one level up, by
281
+ # unlike `next`, which {NEXT_SINK} joins into the block — these arms are consumed one level up, by
258
282
  # `ExpressionTyper#call_dispatch_type_for`. Without them `ops.all? { |o| break false unless o; true }` folded to
259
283
  # `Constant[true]` and warned about a program that really can answer false.
260
284
  #
261
- # Separate from {BREAK_SINK_KEY}, which records SCOPES for the loop-continuation join: the two consumers want
285
+ # Separate from {BREAK_SINK}, which records SCOPES for the loop-continuation join: the two consumers want
262
286
  # different things from the same node, and a loop body evaluated under a call's collection must keep feeding its
263
287
  # own sink. Both are filtered by node identity against a statically computed directly-targeting set, so a `break`
264
288
  # that belongs to an inner loop or block never reaches the wrong consumer. nil means "not collecting".
265
- BREAK_VALUE_SINK_KEY = :rigor_break_value_sink
266
- private_constant :BREAK_VALUE_SINK_KEY
289
+ BREAK_VALUE_SINK = TypingState.frame(
290
+ :break_value_sink,
291
+ contents: :resource, reason: "installed per yielding call; each `break` records into it",
292
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
293
+ )
294
+ private_constant :BREAK_VALUE_SINK
267
295
 
268
296
  # Lexical class frame: the `name:` field is the qualified class name as it would render in Ruby (e.g.,
269
297
  # `"Foo::Bar"`); the `singleton:` field is `true` for `class << self` frames so nested defs resolve to
@@ -356,18 +384,9 @@ module Rigor
356
384
  # so a `def` evaluated inside another method's body (which itself installed a sink) does not corrupt the outer
357
385
  # one. Used by `ExpressionTyper#infer_user_method_return` to join the explicit returns into the inferred
358
386
  # method-return type.
359
- def self.with_return_sink
360
- previous = Thread.current[RETURN_SINK_KEY]
361
- previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
387
+ def self.with_return_sink(&)
362
388
  sink = ReturnSink.new
363
- Thread.current[RETURN_SINK_KEY] = sink
364
- Thread.current[PROVISIONAL_RETURNS_KEY] = nil
365
- begin
366
- result = yield
367
- ensure
368
- Thread.current[RETURN_SINK_KEY] = previous
369
- Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
370
- end
389
+ result = TypingState.current.with_pair(RETURN_SINK, sink, PROVISIONAL_RETURNS, nil, &)
371
390
  [result, sink.types]
372
391
  end
373
392
 
@@ -375,48 +394,54 @@ module Rigor
375
394
  # on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
376
395
  # `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
377
396
  # although the statement walk of the same block recorded `r` with its real binding.
378
- def self.provisional_returns
379
- previous = Thread.current[PROVISIONAL_RETURNS_KEY]
380
- Thread.current[PROVISIONAL_RETURNS_KEY] = true
381
- begin
382
- yield
383
- ensure
384
- Thread.current[PROVISIONAL_RETURNS_KEY] = previous
385
- end
397
+ def self.provisional_returns(&)
398
+ TypingState.current.with(PROVISIONAL_RETURNS, true, &)
386
399
  end
387
400
 
388
401
  # Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
389
402
  # caller. Stacks like the return sink, and for the same reason: a block body evaluated inside another block body's
390
403
  # evaluation must not spill its arms into the outer collection. Used by `ExpressionTyper#type_block_body` to join
391
404
  # the `next` arms into the block's value type.
392
- def self.with_next_sink
393
- previous = Thread.current[NEXT_SINK_KEY]
405
+ def self.with_next_sink(&)
394
406
  sink = []
395
- Thread.current[NEXT_SINK_KEY] = sink
396
- begin
397
- result = yield
398
- ensure
399
- Thread.current[NEXT_SINK_KEY] = previous
400
- end
407
+ result = TypingState.current.with(NEXT_SINK, sink, &)
401
408
  [result, sink]
402
409
  end
403
410
 
411
+ # Runs `block` with none of {CALLER_SINKS} installed: a user-method body re-typed for its return starts outside
412
+ # its caller's constructs (ADR-129 WD1: the return memo forces these Frames at its entry). Installs only when one
413
+ # is set, read with `forcing_read`, so the common entry costs no frame.
414
+ def self.outside_caller_sinks(&)
415
+ state = TypingState.current
416
+ return yield if CALLER_SINKS.all? { |slot| state.forcing_read(slot).nil? }
417
+
418
+ state.without(CALLER_SINKS, &)
419
+ end
420
+
404
421
  # Runs `block` with a fresh `break`-value sink installed, then yields the collected `[BreakNode, Type]` pairs to
405
422
  # the caller. Stacks like the other sinks: a block body evaluated inside another block body's evaluation must not
406
423
  # spill its arms into the outer collection. Used by `ExpressionTyper#call_dispatch_type_for` to union the `break`
407
424
  # arms into the yielding call's type (issue #853).
408
- def self.with_break_value_sink
409
- previous = Thread.current[BREAK_VALUE_SINK_KEY]
425
+ def self.with_break_value_sink(&)
410
426
  sink = []
411
- Thread.current[BREAK_VALUE_SINK_KEY] = sink
412
- begin
413
- result = yield
414
- ensure
415
- Thread.current[BREAK_VALUE_SINK_KEY] = previous
416
- end
427
+ result = TypingState.current.with(BREAK_VALUE_SINK, sink, &)
417
428
  [result, sink]
418
429
  end
419
430
 
431
+ # Issue #1246 — enters only `call`'s block, from the receiver scope, as the statement form enters it
432
+ # ({#evaluate_block_if_present}): the per-node scope index's evaluation of a block a value-position call holds
433
+ # ({ScopeIndexer#evaluate_unentered_block}). The call's receiver and arguments are left to the index walk.
434
+ #
435
+ # Such a block enters with the unknown-`self` mark only ({Scope#with_block_self_unknown}), as the walk entered
436
+ # it, not with the statement form's opaque-`self` mark ({Scope#entering_opaque_block}): issue #1717 keeps the
437
+ # latter, which gates binding a call to a cross-file top-level `def`, away from value-position blocks.
438
+ def evaluate_block_of(call)
439
+ @value_position_block = call.block
440
+ evaluate_block_if_present(call)
441
+ ensure
442
+ @value_position_block = nil
443
+ end
444
+
420
445
  # Evaluate `node` under the receiver scope. Returns `[type, scope']` where `type` is the value the node produces
421
446
  # and `scope'` is the scope observable after the node has run. The receiver scope is never mutated.
422
447
  def evaluate(node)
@@ -810,7 +835,9 @@ module Rigor
810
835
  memo = unrecorded_hand_off_memo(written)
811
836
  key = [constructed, call_node.name]
812
837
  unless memo.key?(key)
813
- return memo[key] = compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
838
+ return memo[key] = MemoAudit.key_audit("unrecorded hand-off") do
839
+ compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
840
+ end
814
841
  end
815
842
 
816
843
  MemoAudit.check("unrecorded hand-off", memo[key], written, key) do
@@ -832,14 +859,18 @@ module Rigor
832
859
  # the class `self` is and its side (a constant's ancestor lookup differs between the class object and an
833
860
  # instance), and the called name, not on the def it sits in.
834
861
  def unrecorded_hand_off_memo(written)
835
- table = (Thread.current[UNRECORDED_HAND_OFF_KEY] ||= ObjectSpace::WeakKeyMap.new)
862
+ table = typing_state.weak_key_map(UNRECORDED_HAND_OFFS)
836
863
  self_type = scope.self_type
837
864
  by_side = ((table[scope.discovery] ||= {})[self_type.is_a?(Type::Singleton)] ||= {})
838
865
  (by_side[enclosing_class_name_for(self_type)] ||= {})[written] ||= {}
839
866
  end
840
867
 
841
- UNRECORDED_HAND_OFF_KEY = RunScopedSlots.register(:rigor_callee_unrecorded_hand_offs)
842
- private_constant :UNRECORDED_HAND_OFF_KEY
868
+ UNRECORDED_HAND_OFFS = TypingState.memo(
869
+ :callee_unrecorded_hand_offs,
870
+ run_scoped: true, audit: "unrecorded hand-off",
871
+ reason: "keyed by discovery, side, class and spelling, all its answer reads"
872
+ )
873
+ private_constant :UNRECORDED_HAND_OFFS
843
874
 
844
875
  # The scopes {#unrecorded_hand_off_callees} types a receiver written as `written` in. A rooted constant and a
845
876
  # top-level def are typed once.
@@ -1131,9 +1162,11 @@ module Rigor
1131
1162
  def instance_def_owners(name)
1132
1163
  return scope.user_instance_def_owners(name) if MemoAudit.suspended?
1133
1164
 
1134
- table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
1165
+ table = typing_state.weak_key_map(INSTANCE_DEF_INDEX)
1135
1166
  by_name = (table[scope.discovery] ||= {})
1136
- return by_name[name] = scope.user_instance_def_owners(name) unless by_name.key?(name)
1167
+ unless by_name.key?(name)
1168
+ return by_name[name] = MemoAudit.key_audit("instance def owners") { scope.user_instance_def_owners(name) }
1169
+ end
1137
1170
 
1138
1171
  record_instance_def_owner_reads(name, by_name[name]) if Analysis::DependencyRecorder.active?
1139
1172
  MemoAudit.check("instance def owners", by_name[name], name) { scope.user_instance_def_owners(name) }
@@ -1144,9 +1177,9 @@ module Rigor
1144
1177
  def subclassing_opaque?
1145
1178
  return scope.subclassing_opacity.first if MemoAudit.suspended?
1146
1179
 
1147
- table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
1180
+ table = typing_state.weak_key_map(INSTANCE_DEF_INDEX)
1148
1181
  by_name = (table[scope.discovery] ||= {})
1149
- entry = by_name[OPACITY_ENTRY] ||= scope.subclassing_opacity
1182
+ entry = by_name[OPACITY_ENTRY] ||= MemoAudit.key_audit("subclassing opacity") { scope.subclassing_opacity }
1150
1183
  Analysis::DependencyRecorder.read_keys(entry.last) if Analysis::DependencyRecorder.active?
1151
1184
  MemoAudit.check("subclassing opacity", entry, OPACITY_ENTRY) { scope.subclassing_opacity }.first
1152
1185
  end
@@ -1159,8 +1192,12 @@ module Rigor
1159
1192
  owners.map(&:first).each { |owner| Analysis::DependencyRecorder.read_last_segment(:subclass_of, owner) }
1160
1193
  end
1161
1194
 
1162
- INSTANCE_DEF_INDEX_KEY = RunScopedSlots.register(:rigor_callee_instance_def_index)
1163
- private_constant :INSTANCE_DEF_INDEX_KEY
1195
+ INSTANCE_DEF_INDEX = TypingState.memo(
1196
+ :callee_instance_def_index,
1197
+ run_scoped: true, audit: ["instance def owners", "subclassing opacity"],
1198
+ reason: "keyed by discovery and name, all its answer reads"
1199
+ )
1200
+ private_constant :INSTANCE_DEF_INDEX
1164
1201
 
1165
1202
  def constant_node?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
1166
1203
 
@@ -1298,6 +1335,13 @@ module Rigor
1298
1335
 
1299
1336
  attr_reader :scope, :tracer
1300
1337
 
1338
+ # ADR-129 WD2 — the calling thread's {TypingState::State}, cached on the evaluator the first time a slot is read.
1339
+ # An evaluator kept across threads must be kept per state instead ({ExpressionTyper#typing_state}). A frame
1340
+ # install whose block's value is the answer calls {TypingState.current} instead: Rigor's own analysis does not
1341
+ # adopt this memoised reader's type at a self-call (#525's self-purity), so the install's answer would read as
1342
+ # `Dynamic` through it.
1343
+ def typing_state = @typing_state ||= TypingState.current
1344
+
1301
1345
  # Thread the scope through every child statement in declaration order. The body's value is the type of the last
1302
1346
  # statement (or `Constant[nil]` for an empty body); intermediate statements' types are discarded, but their scope
1303
1347
  # effects are preserved.
@@ -1307,7 +1351,7 @@ module Rigor
1307
1351
  def eval_statements(node)
1308
1352
  result_type = Type::Combinator.constant_of(nil)
1309
1353
  current = scope
1310
- raising = Thread.current[RETRY_FRAMES_KEY]
1354
+ raising = typing_state[RETRY_FRAMES]
1311
1355
  node.body.each do |stmt|
1312
1356
  result_type, current = sub_eval(stmt, current)
1313
1357
  record_raise_points(raising, stmt, current) if raising
@@ -1456,7 +1500,7 @@ module Rigor
1456
1500
  def rhs_origin(value_node, scope_after_rhs, rhs_type)
1457
1501
  return nil unless rhs_type.is_a?(Type::Dynamic)
1458
1502
 
1459
- scope_after_rhs.dynamic_origins[value_node]
1503
+ scope_after_rhs.dynamic_origin(value_node)
1460
1504
  end
1461
1505
 
1462
1506
  # The scope binding the written local with ADR-58's `:local` mark when the write copies a declaration-sourced
@@ -1730,7 +1774,8 @@ module Rigor
1730
1774
  # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
1731
1775
  # post-`||=` value, not the rvalue alone.
1732
1776
  def eval_index_or_write(node)
1733
- _rhs_type, post_rhs = sub_eval(node.value, scope)
1777
+ operands = index_write_operand_scope(node)
1778
+ _rhs_type, post_rhs = sub_eval(node.value, operands)
1734
1779
  result_type = index_write_stored_type(node, scope)
1735
1780
 
1736
1781
  # A narrowing is keyed on ONE literal slot — `a[k]` — but a multi-index `||=` reads and
@@ -1738,8 +1783,7 @@ module Rigor
1738
1783
  # record `a[0]`'s type as the region answer: `a[0, 1] ||= []` would claim `a[0]` non-nil
1739
1784
  # where the store splices nothing and `a[0]` stays nil at runtime. Decline the record for
1740
1785
  # any form but the single-index one.
1741
- key_node = single_index_argument(node)
1742
- address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
1786
+ address = recordable_slot_address(node)
1743
1787
  # Issue #544 — a receiver with an untracked (Dynamic / Top) constituent can hold a caller-supplied
1744
1788
  # slot value the `||=` keeps, so the recorded default would invent a fact; decline the record.
1745
1789
  address = nil if address && !IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(node.receiver))
@@ -1748,7 +1792,9 @@ module Rigor
1748
1792
  # the reverse order would trade this feature away for the widening. The two are complementary — the shape
1749
1793
  # forgets that the collection is still empty, the narrowing remembers that THIS slot is now non-nil.
1750
1794
  post = IndexWriteWidening.widen(node: node, current_scope: post,
1751
- arg_types: index_write_arg_types(node, result_type))
1795
+ arg_types: index_write_arg_types(node, result_type, join_scope: operands))
1796
+ # Issue #1218 — a key that is not a literal (`h[k] ||= v`) may name a slot another `||=` narrowed.
1797
+ post = IndexedNarrowing.invalidate_indexed_write(node, post)
1752
1798
  post = post.with_indexed_narrowing(*address, result_type) if address
1753
1799
 
1754
1800
  # The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
@@ -1814,14 +1860,76 @@ module Rigor
1814
1860
  # expression, scope untouched — and the receiver never widened. They store through `[]=` exactly as
1815
1861
  # `eval_index_or_write` does, so they take the same widening; the stored value is the compound result —
1816
1862
  # `falsey(h[k]) | v` for `&&=`, the dispatched `h[k] + v` for `+=` — not the rvalue alone.
1863
+ #
1864
+ # Issue #1218 — the widening carries the receiver's slot narrowings across its rebind
1865
+ # ({MutationRejoin.carry_slot_narrowings}), so a `h[:e] ||= 0` narrowing outlived `h[:e] += 1` and the slot
1866
+ # kept reading `0`. The store drops the narrowing on the slot it overwrote, as a `[]=` call does
1867
+ # ({IndexedNarrowing.invalidate_indexed_write}: every narrowing on the receiver when the index is not one
1868
+ # literal key). A slot that held a narrowing then records the value the write stored, which the read it
1869
+ # performed took from that narrowing; a slot with none records nothing new.
1817
1870
  def eval_index_write(node)
1818
- _rhs_type, post_rhs = sub_eval(node.value, scope)
1871
+ operands = index_write_operand_scope(node)
1872
+ _rhs_type, post_rhs = sub_eval(node.value, operands)
1819
1873
  stored = index_write_stored_type(node, scope)
1820
1874
  widened = IndexWriteWidening.widen(node: node, current_scope: post_rhs,
1821
- arg_types: index_write_arg_types(node, stored))
1875
+ arg_types: index_write_arg_types(node, stored, join_scope: operands))
1876
+ widened = IndexedNarrowing.invalidate_indexed_write(node, widened)
1877
+ address = recordable_slot_address(node)
1878
+ widened = widened.with_indexed_narrowing(*address, stored) if address && scope.indexed_narrowing(*address)
1822
1879
  [stored, forget_rebound_specials(widened, node)]
1823
1880
  end
1824
1881
 
1882
+ # The `(receiver, key)` slot a compound index write may record its stored value on, or nil. A narrowing is
1883
+ # keyed on ONE literal slot, so a multi-index form, which reads and stores a splice region, records none. The
1884
+ # receiver's `[]=` must leave the stored value at that slot ({IndexedNarrowing.verbatim_slot?}: a `Hash` or
1885
+ # `Array`, and no negative index on an `Array`), and the rvalue must not rebind the receiver variable
1886
+ # ({#rvalue_rebinds_receiver?}), since the store went to the object it named before (issue #1218).
1887
+ def recordable_slot_address(node)
1888
+ key_node = single_index_argument(node)
1889
+ address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
1890
+ return nil if address.nil?
1891
+ return nil unless IndexedNarrowing.verbatim_slot?(scope.type_of(node.receiver, tracer: tracer), address.last)
1892
+ return nil if rvalue_rebinds_receiver?(node)
1893
+
1894
+ address
1895
+ end
1896
+
1897
+ # Whether a compound index write's rvalue may rebind its stable receiver variable: it writes the variable
1898
+ # (`h[k] += (h = {}; 1)`, `@h ||= …`, a multiple-assignment target, in a block too:
1899
+ # {OperandEffects.written_variables}), or, for an instance variable, the intervening-call reset past the
1900
+ # rvalue's self calls rebinds it ({InterveningIvarReset.after_operand}). That reset widens only a binding
1901
+ # narrower than the class-ivar seed, and only for a call not proven to leave it alone, so
1902
+ # `@h = { k: nil }; @h[:k] += (reset; 1)` with a `reset` that assigns `@h` is a rebind, and `@t[:k] ||=
1903
+ # compute(1)` on a `@t` the class binds to one shape is not. A store into another slot of the receiver
1904
+ # (`h[:a] ||= (h[:b] ||= [])`) is no rebind either.
1905
+ def rvalue_rebinds_receiver?(node)
1906
+ receiver = node.receiver
1907
+ name = receiver.name
1908
+ return true if OperandEffects.written_variables(node.value).include?(name)
1909
+ return false unless receiver.is_a?(Prism::InstanceVariableReadNode)
1910
+
1911
+ !InterveningIvarReset.after_operand(node.value, scope).ivar(name).equal?(scope.ivar(name))
1912
+ end
1913
+
1914
+ # The scope a compound index write's rvalue runs from: Ruby evaluates the receiver, then the index arguments,
1915
+ # then the rvalue, so a write in either is threaded in that order ({#thread_operand}) before the rvalue. An
1916
+ # index write takes no block-pass argument (a syntax error since Ruby 3.4).
1917
+ #
1918
+ # Issue #1272 — `(h ||= {})[k] ||= v`, `(@h ||= {})[k] ||= v` and `h[(i = 1)] ||= v` left `h`, `@h` and `i`
1919
+ # on their pre-write bindings, so a later `h.fetch(k)` on a nil-initialised `h` reported a call on `nil`. A
1920
+ # receiver or argument holding no write answers the entry scope unchanged.
1921
+ #
1922
+ # The receiver's joinability is read from this scope ({#index_write_arg_types}' `join_scope`), since the
1923
+ # binding the store widens is the one the receiver's write left (`h` is `{}`, not the entry's `nil`). The read,
1924
+ # the stored value and the widening's argument types are still typed from the entry scope (TODO #3100): the
1925
+ # receiver expression's own value (`(h ||= {})` is the hash) answers what its write produced, but an rvalue
1926
+ # reading a variable an index argument writes (`h[(i = 1)] ||= i + 1`) is typed at its pre-write binding.
1927
+ def index_write_operand_scope(node)
1928
+ walk = OperandWalk.new(walk_recorder)
1929
+ after = thread_operand(node.receiver, scope, walk, scope)
1930
+ thread_operand(node.arguments, after, walk, scope)
1931
+ end
1932
+
1825
1933
  # `[index_type..., stored_value_type]` for an index-write node, shaped exactly like a `[]=`
1826
1934
  # call's argument list so the widening seam can join it the same way (issue #560) — a
1827
1935
  # two-index compound write (`a[0, 1] += v`) keeps BOTH index arguments ahead of the stored
@@ -1835,15 +1943,16 @@ module Rigor
1835
1943
  # The index arguments are typed, and the receiver's joinability read, in `type_scope`: the
1836
1944
  # evaluator's entry scope by default; a `for` index passes its post-collection scope and a
1837
1945
  # rescue reference its arm's entry scope, the nearest the engine has to where Ruby evaluates
1838
- # them (each iteration, the moment of the catch).
1946
+ # them (each iteration, the moment of the catch). A compound index write reads the joinability alone in
1947
+ # `join_scope`, the scope its receiver and index arguments left ({#index_write_operand_scope}).
1839
1948
  # There is deliberately NO `rescue` here. `Scope#type_of` is a total query over well-formed Prism input,
1840
1949
  # so a raise is an engine bug, and swallowing it would silently downgrade a live seam to "no evidence" —
1841
1950
  # the join would quietly stop happening with nothing to show for it. Let it reach the runner's
1842
1951
  # internal-error path, where it is visible.
1843
- def index_write_arg_types(node, stored_type, type_scope: scope)
1952
+ def index_write_arg_types(node, stored_type, type_scope: scope, join_scope: type_scope)
1844
1953
  args = node.arguments
1845
1954
  return MutationWidening::NO_ARG_TYPES if args.nil? || stored_type.nil?
1846
- return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, type_scope)
1955
+ return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, join_scope)
1847
1956
 
1848
1957
  list = args.respond_to?(:arguments) ? args.arguments : args
1849
1958
  # A splat argument is marked `nil` — its expansion decides the store's arity at
@@ -1951,15 +2060,70 @@ module Rigor
1951
2060
  # receiver is an element read into a local, that element's pin inside its container
1952
2061
  # ({ElementReadWidening.widen_element_read}, issue #643) — and, issue #2499, the String a stdlib
1953
2062
  # out-buffer call fills ({OutBufferMutation}), which names no receiver of the call at all.
1954
- def widen_mutated_receivers(call_node, current_scope)
2063
+ #
2064
+ # Issue #2335 — a literal-symbol `send` (`a.send(:push, x)`) widens as the call it makes, and a mutation through a
2065
+ # receiver that may be another local's object floors every local whose object another holder may reach
2066
+ # ({AliasMutationFloor}).
2067
+ def widen_mutated_receivers(node, current_scope)
2068
+ call_node = AliasMutationFloor.literal_send_target(node) || node
1955
2069
  arg_types = mutator_arg_types(call_node, current_scope)
1956
2070
  widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
1957
2071
  arg_types: arg_types,
1958
2072
  rewritten_element: rewritten_element(call_node, current_scope))
1959
2073
  widened = ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened,
1960
2074
  arg_types: arg_types)
2075
+ if AliasMutationFloor.candidate?(call_node)
2076
+ widened = AliasMutationFloor.floor_after_call(call_node, widened) do
2077
+ named_receiver_locals(call_node.receiver)
2078
+ end
2079
+ end
1961
2080
  # Issue #2499 — a stdlib call that fills a String argument (`io.read(n, buf)`, `StringIO.new(s)`).
1962
- OutBufferMutation.floor(call_node, widened)
2081
+ floor_ivar_mutating_callee(node, OutBufferMutation.floor(call_node, widened))
2082
+ end
2083
+
2084
+ # Issue #2335 — a call on a local that may hold another local's object (`box = Box.new(a)`) to a project method
2085
+ # that mutates one of its instance variables (`def add(v) = @items << v`) may mutate that object: every local
2086
+ # whose object another holder may reach takes one unknown store ({AliasMutationFloor.floor_after_written_local}).
2087
+ def floor_ivar_mutating_callee(call_node, current_scope)
2088
+ receiver = call_node.receiver
2089
+ return current_scope if MutationWidening::SHAPE_MUTATORS.include?(call_node.name) || send_hand_off?(call_node)
2090
+ return floor_self_ivar_callee(call_node, current_scope) if self_dispatch_call?(call_node)
2091
+ return current_scope unless receiver.is_a?(Prism::LocalVariableReadNode)
2092
+ return current_scope unless callee_bearing?(current_scope.local(receiver.name))
2093
+ return current_scope unless AliasMutationFloor.reaches_other_locals?(current_scope, receiver, call_node)
2094
+
2095
+ callees = receiver_callees_in(scope, call_node)
2096
+ return current_scope unless callees.any? { |def_node, _, _| AliasMutationFloor.mutates_ivar?(def_node) }
2097
+
2098
+ AliasMutationFloor.floor_after_written_local(current_scope, receiver, call_node)
2099
+ end
2100
+
2101
+ # The same for a call on `self` (`push_it(v)`, `self.push_it(v)`): its ivars hold whatever locals were handed to
2102
+ # them (`@items = a`, `a = @q`), so every aliased local holding a literal shape takes one unknown store.
2103
+ def floor_self_ivar_callee(call_node, current_scope)
2104
+ return current_scope unless AliasMutationFloor.holder_call_reaches?(current_scope, call_node)
2105
+ return current_scope unless resolve_self_callees(call_node).any? do |def_node, _, _|
2106
+ AliasMutationFloor.mutates_ivar?(def_node)
2107
+ end
2108
+
2109
+ AliasMutationFloor.floor_after_holder_call(current_scope, call_node)
2110
+ end
2111
+
2112
+ # A binding `receiver_callees_in` can resolve a project def on: a class or instance type, or a union of them. Any
2113
+ # other binding resolves none, so the floor skips the resolution for it.
2114
+ def callee_bearing?(type) = type.is_a?(Type::Nominal) || type.is_a?(Type::Singleton) || type.is_a?(Type::Union)
2115
+
2116
+ # The locals a mutator's receiver names ({ReceiverAlias.mutated_reads}), which the named path widens, and the
2117
+ # root of an element read (`c[0].shift`), whose element {ElementReadWidening} widens.
2118
+ def named_receiver_locals(receiver)
2119
+ return nil if receiver.nil?
2120
+ return receiver.name if receiver.is_a?(Prism::LocalVariableReadNode)
2121
+
2122
+ names = ReceiverAlias.mutated_reads(receiver).filter_map do |read|
2123
+ ReceiverAlias.read_name(read) if read.is_a?(Prism::LocalVariableReadNode)
2124
+ end
2125
+ root = ElementReadWidening.element_read_path(receiver)&.first
2126
+ root ? names << root.name : names
1963
2127
  end
1964
2128
 
1965
2129
  # Issue #2427 — the element an element rewriter (`map!`, `collect!`, `flatten!`) leaves in its receiver, read
@@ -2068,14 +2232,17 @@ module Rigor
2068
2232
  [scope.type_of(node, tracer: tracer), forget_rebound_specials(widened, node)]
2069
2233
  end
2070
2234
 
2071
- # The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, and
2072
- # the receiver-wide drop of recorded `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
2235
+ # The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, the
2236
+ # drop of every method-chain narrowing rooted at the receiver that `eval_call` makes after any call on it (issue
2237
+ # #2693: `x.r ||= v`, `x.r, y = …` and `for x.r in …` call `r=`), and the receiver-wide drop of recorded
2238
+ # `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
2073
2239
  def widen_attribute_write(receiver, writer, current_scope)
2074
2240
  widened = MutationWidening.widen_receiver_aliases(receiver, writer, current_scope)
2075
2241
  stable = IndexedNarrowing.stable_receiver(receiver)
2076
- return widened unless stable && IndexedNarrowing.mutator?(writer)
2242
+ return widened unless stable
2077
2243
 
2078
- widened.without_indexed_narrowings_for(*stable)
2244
+ widened = widened.without_method_chain_narrowings_for(*stable)
2245
+ IndexedNarrowing.mutator?(writer) ? widened.without_indexed_narrowings_for(*stable) : widened
2079
2246
  end
2080
2247
 
2081
2248
  # The attribute targets of a multi-write (`h.default, x = 0, 1`), nested ones included, each widening its
@@ -2155,11 +2322,7 @@ module Rigor
2155
2322
  return [Type::Combinator.union(then_type, else_type), then_scope] \
2156
2323
  if branch_terminates?(node.subsequent, else_type) && node.statements
2157
2324
 
2158
- [
2159
- Type::Combinator.union(then_type, else_type),
2160
- UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
2161
- [node.statements, node.subsequent])
2162
- ]
2325
+ if_join(node.statements, node.subsequent, [then_type, then_scope], [else_type, else_scope], post_pred)
2163
2326
  end
2164
2327
 
2165
2328
  # `unless pred; t; else; e; end`. Same shape as `if`, but Prism exposes the else-branch as `else_clause` (no elsif
@@ -2191,11 +2354,22 @@ module Rigor
2191
2354
  return [Type::Combinator.union(then_type, else_type), then_scope] \
2192
2355
  if branch_terminates?(node.else_clause, else_type) && node.statements
2193
2356
 
2194
- [
2195
- Type::Combinator.union(then_type, else_type),
2196
- UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
2197
- [node.statements, node.else_clause])
2198
- ]
2357
+ if_join(node.statements, node.else_clause, [then_type, then_scope], [else_type, else_scope], post_pred)
2358
+ end
2359
+
2360
+ # The `[type, post_scope]` an `if` / `unless` with both branches evaluated ends with, once the arms above found no
2361
+ # terminating branch by {#branch_terminates?}.
2362
+ #
2363
+ # Issue #1230 — the then-body (`then_node`) certainly exits beside an else (`else_node`): the only surviving path
2364
+ # is the else that RAN, so the continuation is the else's exit scope, the predicate's other edge PLUS the else's
2365
+ # writes, not a join with the exited body's writes. {#branch_certainly_exits?} is stricter than
2366
+ # {#branch_terminates?}, which the arms above use: a `bot`-typed helper call does not qualify (issue #3110).
2367
+ # Otherwise both exit scopes join with nil-injection.
2368
+ def if_join(then_node, else_node, (then_type, then_scope), (else_type, else_scope), post_pred)
2369
+ type = Type::Combinator.union(then_type, else_type)
2370
+ return [type, else_scope] if else_node && branch_certainly_exits?(then_node)
2371
+
2372
+ [type, UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred, [then_node, else_node])]
2199
2373
  end
2200
2374
 
2201
2375
  # Returns the `[type, post_scope]` of the live branch when the predicate is provably truthy / falsey, else nil so
@@ -2580,9 +2754,12 @@ module Rigor
2580
2754
  # while it ran, put back as this scope binds them ({ErrorInfo.restore}): the control those carry has left every
2581
2755
  # rescue clause the construct entered.
2582
2756
  def restoring_error_info
2583
- marks = [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
2757
+ state = typing_state
2758
+ marks = [@next_scope_sink&.size, state[BREAK_SINK]&.size]
2584
2759
  type, after = yield
2585
- [[@next_scope_sink, marks.first], [Thread.current[BREAK_SINK_KEY], marks.last]].each do |sink, mark|
2760
+ break_sink = state[BREAK_SINK]
2761
+ state.touch(BREAK_SINK) if break_sink && marks.last && marks.last < break_sink.size
2762
+ [[@next_scope_sink, marks.first], [break_sink, marks.last]].each do |sink, mark|
2586
2763
  next if sink.nil? || mark.nil?
2587
2764
 
2588
2765
  (mark...sink.size).each do |index|
@@ -2605,9 +2782,12 @@ module Rigor
2605
2782
  # after it, or a rescue clause, may have set `$_` enters with it forgotten; a rescue clause runs after any
2606
2783
  # prefix of the body, and so reads it forgotten whenever the body may set it.
2607
2784
  entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
2608
- primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge)
2609
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry),
2610
- edge: edge, writes: rescue_writes(node, edge))
2785
+ rescue_entry = rescue_entry_scope(node, entry)
2786
+ collector = begin_fact_collector(node, rescue_entry, edge)
2787
+ primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge, collector: collector)
2788
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry,
2789
+ edge: edge, writes: rescue_writes(node, edge),
2790
+ raise_scopes: collector&.raise_scopes)
2611
2791
 
2612
2792
  # B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
2613
2793
  # primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
@@ -2703,7 +2883,10 @@ module Rigor
2703
2883
  # none, so an `ensure` clause inside it, nested at any depth, is evaluated once rather than once per pass of
2704
2884
  # every enclosing clause holding a jump.
2705
2885
  def jump_sinks_installed?
2706
- !@next_scope_sink.nil? || JUMP_SINK_KEYS.any? { |key| Thread.current[key] }
2886
+ return true unless @next_scope_sink.nil?
2887
+
2888
+ state = typing_state
2889
+ JUMP_SINKS.any? { |slot| state[slot] }
2707
2890
  end
2708
2891
 
2709
2892
  # Whether `node` holds a `return`, `next` or `break` outside a nested `def`.
@@ -2759,12 +2942,8 @@ module Rigor
2759
2942
  end
2760
2943
 
2761
2944
  # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
2762
- def without_jump_sinks
2763
- previous = CLAUSE_SINK_KEYS.map { |key| Thread.current[key] }
2764
- CLAUSE_SINK_KEYS.each { |key| Thread.current[key] = nil }
2765
- yield
2766
- ensure
2767
- CLAUSE_SINK_KEYS.zip(previous).each { |key, value| Thread.current[key] = value } if previous
2945
+ def without_jump_sinks(&)
2946
+ TypingState.current.without(CLAUSE_SINKS, &)
2768
2947
  end
2769
2948
 
2770
2949
  # The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
@@ -2773,6 +2952,65 @@ module Rigor
2773
2952
  forget_guard_if_rebinds(LastLine.forget_if_set(entry, node.statements), node.statements)
2774
2953
  end
2775
2954
 
2955
+ # Issue #2691 — a rescue clause runs after any prefix of the `begin` body raised, so a fact the clause's
2956
+ # entry scope holds for an indexed read (`h[:k]`) or a single-hop chain (`x.r`) survives into it only where
2957
+ # EVERY scope the body can raise from still binds the identical type object. `raise_scopes` is that set —
2958
+ # the post-scope of each statement of the body's frame and the scope it ends with
2959
+ # ({#eval_begin_primary_under}'s collector) — not the fall-through exit alone: `eval_if` and kin discard a
2960
+ # branch that terminates, so a write on `if c; @x.r = nil; raise; end` never reaches the exit scope.
2961
+ # `equal?` keeps only an untouched fact: a re-narrowed or re-joined one is a new object and drops (the
2962
+ # conservative side).
2963
+ #
2964
+ # The residual is a fact the body drops and then re-establishes as the identical (interned) type object:
2965
+ # raise points after the re-establishment keep it, which is correct, and the only unsound window would be a
2966
+ # raise point whose recorded scope still binds the object although the fact was invalidated and not yet
2967
+ # re-established. No producer reaches that today — a rebind or a clause widening answers `Dynamic[top]`,
2968
+ # never the fact's type ({ClauseWidening}), and a widened slot read floors to `Dynamic` rather than
2969
+ # reinstalling the recorded object.
2970
+ #
2971
+ # The filter covers rescue entries only: an `ensure` clause enters from the scope past the `begin` — the
2972
+ # join of the fall-through exits — so it keeps a fact a terminating-branch write voided, the pre-#2691
2973
+ # hole left open because collecting raise points for every `ensure`-only `begin` costs a frame walk.
2974
+ def clause_fact_scope(entry_scope, raise_scopes)
2975
+ return entry_scope if entry_scope.indexed_narrowings.empty? && entry_scope.method_chain_narrowings.empty?
2976
+
2977
+ indexed = entry_scope.indexed_narrowings.reduce(entry_scope) do |acc, (key, type)|
2978
+ if raise_scopes.all? { |point| point.indexed_narrowings[key].equal?(type) }
2979
+ acc
2980
+ else
2981
+ acc.without_indexed_narrowing(key.receiver_kind, key.receiver_name, key.key)
2982
+ end
2983
+ end
2984
+ indexed.method_chain_narrowings.reduce(indexed) do |acc, (key, type)|
2985
+ if raise_scopes.all? { |point| point.method_chain_narrowings[key].equal?(type) }
2986
+ acc
2987
+ else
2988
+ acc.without_method_chain_narrowing(key.receiver_kind, key.receiver_name, key.method_name)
2989
+ end
2990
+ end
2991
+ end
2992
+
2993
+ # Issue #2691 — the {BodyRaiseScopes} {#eval_begin_primary_under} fills while evaluating `node`'s primary
2994
+ # body, or nil when {#clause_fact_scope} has nothing to filter: no rescue clause, no body, or a `clause_entry`
2995
+ # (the scope the chain enters with — `rescue_entry_scope`'s result, or the retry-widened entry under an
2996
+ # edge) holding no indexed or method-chain fact. The frame walk is skipped unless a fact could drop, so a
2997
+ # `begin` whose entry binds none pays nothing.
2998
+ def begin_fact_collector(node, clause_entry, edge)
2999
+ return nil unless node.rescue_clause && node.statements
3000
+ return nil if clause_entry.indexed_narrowings.empty? && clause_entry.method_chain_narrowings.empty?
3001
+
3002
+ frame = edge ? edge.frame : primary_body_frame(node.statements)
3003
+ BodyRaiseScopes.new(frame: frame, raise_scopes: [])
3004
+ end
3005
+
3006
+ # The set {#walk_primary_frame} collects for a primary body — the nodes whose post-statement scopes are the
3007
+ # body's raise points — without the write-name pass a retry edge also needs.
3008
+ def primary_body_frame(statements)
3009
+ frame = Set.new.compare_by_identity
3010
+ walk_primary_frame(statements, true, frame, Set.new)
3011
+ frame
3012
+ end
3013
+
2776
3014
  # Rescue arms that never fall through contribute neither a type fragment NOR a scope to the post-begin flow —
2777
3015
  # control left the `begin` via that arm. That is an arm ending in `return`, `next`, `break`, `raise`, `throw`,
2778
3016
  # `exit`, `abort` or `fail`, and one whose type is `bot` ({#branch_terminates?}): an arm ending in `retry`, or in
@@ -2791,7 +3029,7 @@ module Rigor
2791
3029
  def ensure_jump_marks(node)
2792
3030
  return nil unless node.ensure_clause
2793
3031
 
2794
- [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
3032
+ [@next_scope_sink&.size, typing_state[BREAK_SINK]&.size]
2795
3033
  end
2796
3034
 
2797
3035
  # A `next` or `break` inside `begin … ensure` leaves only once the `ensure` clause has run, so each jump scope the
@@ -2801,7 +3039,10 @@ module Rigor
2801
3039
  def carry_jumps_through_ensure(ensure_clause, marks)
2802
3040
  next_mark, break_mark = marks
2803
3041
  carry_through_ensure(ensure_clause, @next_scope_sink, next_mark)
2804
- carry_through_ensure(ensure_clause, Thread.current[BREAK_SINK_KEY], break_mark)
3042
+ state = typing_state
3043
+ break_sink = state[BREAK_SINK]
3044
+ state.touch(BREAK_SINK) if break_sink && break_mark && break_mark < break_sink.size
3045
+ carry_through_ensure(ensure_clause, break_sink, break_mark)
2805
3046
  end
2806
3047
 
2807
3048
  def carry_through_ensure(ensure_clause, sink, mark)
@@ -2820,13 +3061,23 @@ module Rigor
2820
3061
  # `edge`, when given, collects every scope the primary body could raise from: the scope after each statement of
2821
3062
  # its frame ({#record_raise_points}), and the scope it ends with. The else-clause is not among them: what it
2822
3063
  # raises is not rescued here.
2823
- def eval_begin_primary_under(node, entry_scope, edge: nil)
3064
+ #
3065
+ # `collector`, when given, is a {BodyRaiseScopes} the same recording fills for {#clause_fact_scope} — built
3066
+ # only when the clause entry still holds an indexed or method-chain fact a raise-point write could
3067
+ # invalidate ({#begin_fact_collector}). A retry `edge` collects the same scopes for its own widening; the
3068
+ # collector is separate so each evaluation pass filters against exactly its own raise points.
3069
+ #
3070
+ # Returns `[type, scope]`: the construct's value and exit scope (the else-clause's when present). The scope the
3071
+ # primary body alone ended with is recorded as a raise point before the `else` runs — the `else` runs only when
3072
+ # nothing raised, so its effects are no raise point's.
3073
+ def eval_begin_primary_under(node, entry_scope, edge: nil, collector: nil)
2824
3074
  body_type, body_scope =
2825
3075
  if node.statements
2826
- with_raise_frame(edge) { sub_eval(node.statements, entry_scope) }
3076
+ with_raise_frame(edge, collector) { sub_eval(node.statements, entry_scope) }
2827
3077
  else
2828
3078
  [Type::Combinator.constant_of(nil), entry_scope]
2829
3079
  end
3080
+ collector.raise_scopes << body_scope if collector
2830
3081
  edge.raise_scopes << body_scope if edge
2831
3082
 
2832
3083
  if node.else_clause
@@ -2850,6 +3101,13 @@ module Rigor
2850
3101
  end
2851
3102
  private_constant :RetryEdge
2852
3103
 
3104
+ # Issue #2691 — the raise-point scopes one evaluation of a `begin` primary body collects for
3105
+ # {#clause_fact_scope}: {RetryEdge}'s `frame` / `raise_scopes` pair without the retry bookkeeping, so
3106
+ # {#record_raise_points} records into it exactly as into an edge. `frame` is the body's own frame — shared
3107
+ # with the retry edge when one exists, since both walks collect the same nodes.
3108
+ BodyRaiseScopes = Data.define(:frame, :raise_scopes)
3109
+ private_constant :BodyRaiseScopes
3110
+
2853
3111
  # The entry and edge one widening reads, whether it widens to the Nominal envelope or keeps literals, and, per
2854
3112
  # name, the rebinds it has already weighed: a statement-by-statement body shares one binding object across every
2855
3113
  # scope until the name is rebound, and weighing each copy again is what made a long body quadratic.
@@ -2858,17 +3116,29 @@ module Rigor
2858
3116
  end
2859
3117
  private_constant :RetryWidening
2860
3118
 
2861
- # The thread-local stack of the {RetryEdge}s whose primary body is being evaluated, innermost last, or nil.
2862
- RETRY_FRAMES_KEY = :rigor_retry_frames
2863
- private_constant :RETRY_FRAMES_KEY
3119
+ # The thread-local stack of the raise-point frames whose primary body is being evaluated, innermost last, or
3120
+ # nil: a {RetryEdge} and/or a {BodyRaiseScopes}. The
3121
+ # frozen stack is the Frame; a statement's raise point recorded into a frame is a Resource touch
3122
+ # ({#record_raise_points}).
3123
+ RETRY_FRAMES = TypingState.frame(
3124
+ :retry_frames,
3125
+ contents: :resource, reason: "installed per retrying body; each raise point records into it",
3126
+ witness: "spec/fixtures/typing_state_witnesses/retry_frames.rb"
3127
+ )
3128
+ private_constant :RETRY_FRAMES
2864
3129
 
2865
3130
  # The thread-local sinks a widened clause pass ({#without_jump_sinks}) leaves uninstalled.
2866
- CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
2867
- private_constant :CLAUSE_SINK_KEYS
3131
+ CLAUSE_SINKS = [RETURN_SINK, NEXT_SINK, BREAK_SINK, BREAK_VALUE_SINK, RETRY_FRAMES].freeze
3132
+ private_constant :CLAUSE_SINKS
2868
3133
 
2869
3134
  # The thread-local sinks a `return`, `next` or `break` is collected into.
2870
- JUMP_SINK_KEYS = (CLAUSE_SINK_KEYS - [RETRY_FRAMES_KEY]).freeze
2871
- private_constant :JUMP_SINK_KEYS
3135
+ JUMP_SINKS = (CLAUSE_SINKS - [RETRY_FRAMES]).freeze
3136
+ private_constant :JUMP_SINKS
3137
+
3138
+ # The sinks a caller's construct installs and a callee's body must not record into: a `next`, `break` or raise
3139
+ # point in the callee belongs to the callee's own constructs, never to the caller's block, loop or retrying body.
3140
+ CALLER_SINKS = [NEXT_SINK, BREAK_SINK, BREAK_VALUE_SINK, RETRY_FRAMES].freeze
3141
+ private_constant :CALLER_SINKS
2872
3142
 
2873
3143
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
2874
3144
  # no `retry` targets.
@@ -2946,8 +3216,11 @@ module Rigor
2946
3216
  end
2947
3217
 
2948
3218
  def eval_begin_paths(node, entry, edge)
2949
- primary = eval_begin_primary_under(node, entry, edge: edge)
2950
- [*primary, collect_rescue_chain_results(node.rescue_clause, entry, edge: edge)]
3219
+ collector = begin_fact_collector(node, entry, edge)
3220
+ primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge, collector: collector)
3221
+ [primary_type, primary_scope,
3222
+ collect_rescue_chain_results(node.rescue_clause, entry,
3223
+ edge: edge, raise_scopes: collector&.raise_scopes)]
2951
3224
  end
2952
3225
 
2953
3226
  # B2.1 — the entry scope widened by what crosses the retry edge, or nil when nothing does. A local or ivar bound
@@ -2972,28 +3245,33 @@ module Rigor
2972
3245
  widened
2973
3246
  end
2974
3247
 
2975
- # Runs `block` with `edge` on top of the thread-local stack {#record_raise_points} reads.
2976
- def with_raise_frame(edge)
2977
- return yield unless edge
3248
+ # Runs `block` with `frames` — a retry edge and/or a {BodyRaiseScopes}, nils skipped — on top of the
3249
+ # thread-local stack {#record_raise_points} reads.
3250
+ def with_raise_frame(first, second, &)
3251
+ return yield if first.nil? && second.nil?
2978
3252
 
2979
- previous = Thread.current[RETRY_FRAMES_KEY]
2980
- Thread.current[RETRY_FRAMES_KEY] = previous ? [*previous, edge] : [edge]
2981
- begin
2982
- yield
2983
- ensure
2984
- Thread.current[RETRY_FRAMES_KEY] = previous
2985
- end
3253
+ frames = first && second ? [first, second] : [first || second]
3254
+ state = TypingState.current
3255
+ previous = state[RETRY_FRAMES]
3256
+ state.with(RETRY_FRAMES, (previous ? previous + frames : frames).freeze, &)
2986
3257
  end
2987
3258
 
2988
- # The scope after each statement of a retrying primary body's frame is a point the body can raise from, the next
2989
- # statement's being the one after. Together they carry every rebind a retry can re-enter with, including one on a
2990
- # branch that then raises and so never reaches the body's exit scope (`if bad; tries += 1; raise; end`), and a
2991
- # write threaded into the raising call's own operands (`raise Retry.new(tries += 1) if flaky?`). A statement of a
2992
- # nested block or lambda body is not in the frame (a block parameter can shadow the counter); the block's effect
3259
+ # The scope after each statement of a primary body's frame is a point the body can raise from, the next
3260
+ # statement's being the one after — into every frame on the stack: a retrying begin's edge, which carries
3261
+ # every rebind a retry can re-enter with, and a {BodyRaiseScopes}, which {#clause_fact_scope} intersects
3262
+ # over. Both see a rebind on a branch that then raises and so never reaches the body's exit scope
3263
+ # (`if bad; tries += 1; raise; end`), and a write threaded into the raising call's own operands
3264
+ # (`raise Retry.new(tries += 1) if flaky?`). A statement of a nested block or lambda body is not in the
3265
+ # frame (a block parameter can shadow the counter); the block's effect
2993
3266
  # on this frame shows in the post-scope of the statement holding it. Recording from the evaluator rather than
2994
3267
  # `on_enter` keeps a statement reached with the index recorder off (a threaded operand, a loop fixpoint pass).
2995
3268
  def record_raise_points(frames, stmt, stmt_scope)
2996
- frames.each { |edge| edge.raise_scopes << stmt_scope if edge.frame.include?(stmt) }
3269
+ frames.each do |collector|
3270
+ next unless collector.frame.include?(stmt)
3271
+
3272
+ typing_state.touch(RETRY_FRAMES)
3273
+ collector.raise_scopes << stmt_scope
3274
+ end
2997
3275
  end
2998
3276
 
2999
3277
  # An `on_enter` that records, besides forwarding to the installed one, the entry scope of each `retry` of `edge`
@@ -3150,7 +3428,14 @@ module Rigor
3150
3428
  # Issue #1231 — `writes` widens the scope each clause enters with ({#widen_clause_entry}) before its
3151
3429
  # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
3152
3430
  # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
3153
- def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
3431
+ # `raise_scopes`, when given, is every scope the primary body can raise from — the post-scope of each
3432
+ # statement of its frame and the scope it ends with ({#eval_begin_primary_under}'s collector): the rescue
3433
+ # entry keeps only the indexed and method-chain facts every one of them still binds verbatim
3434
+ # ({#clause_fact_scope}).
3435
+ def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil, raise_scopes: nil)
3436
+ return [] if rescue_node.nil?
3437
+
3438
+ entry_scope = clause_fact_scope(entry_scope, raise_scopes) if raise_scopes
3154
3439
  on_enter = edge ? retry_scope_recorder(edge) : @on_enter
3155
3440
  results = []
3156
3441
  current = rescue_node
@@ -3198,7 +3483,7 @@ module Rigor
3198
3483
  # True when the clause's scope `after` still binds `$_` to the `Dynamic[top]` the entry gave it and the clause
3199
3484
  # holds nothing that may set it ({LastLine.may_set?}).
3200
3485
  def last_line_kept?(after, node)
3201
- after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
3486
+ Type::Combinator.untyped?(after.global(:$_)) && !LastLine.may_set?(node.statements, scope)
3202
3487
  end
3203
3488
 
3204
3489
  # Issue #1889 — {#eval_for} with a literal-name `instance_variable_set` in the body joining its value into the
@@ -3472,15 +3757,9 @@ module Rigor
3472
3757
  # Installs a fresh thread-local break sink around `yield` (a loop-body evaluation), returning `[collected,
3473
3758
  # yield_result]`. Stacks: the previous sink is restored on exit so a nested loop's breaks do not leak to the
3474
3759
  # enclosing loop.
3475
- def collect_break_scopes
3476
- previous = Thread.current[BREAK_SINK_KEY]
3760
+ def collect_break_scopes(&)
3477
3761
  sink = []
3478
- Thread.current[BREAK_SINK_KEY] = sink
3479
- begin
3480
- result = yield
3481
- ensure
3482
- Thread.current[BREAK_SINK_KEY] = previous
3483
- end
3762
+ result = TypingState.current.with(BREAK_SINK, sink, &)
3484
3763
  [sink, result]
3485
3764
  end
3486
3765
 
@@ -3557,7 +3836,7 @@ module Rigor
3557
3836
  # from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
3558
3837
  # caller's test on it folded always-truthy on correct code.
3559
3838
  def later_pass_returns?(statements)
3560
- !Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
3839
+ !typing_state[RETURN_SINK].nil? && ReturnBarrier.exits_method?(statements)
3561
3840
  end
3562
3841
 
3563
3842
  # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
@@ -3569,12 +3848,11 @@ module Rigor
3569
3848
  def join_break_scopes(continuation, breaks, names)
3570
3849
  return continuation if breaks.empty? || names.empty?
3571
3850
 
3572
- floor = Type::Combinator.untyped
3573
3851
  breaks.reduce(continuation) do |cont, break_scope|
3574
3852
  names.reduce(cont) do |acc, name|
3575
3853
  break_value = CapturedLocals.bound_type(break_scope, name)
3576
3854
  current = CapturedLocals.bound_type(acc, name)
3577
- next acc if break_value.nil? || current == floor
3855
+ next acc if break_value.nil? || Type::Combinator.untyped?(current)
3578
3856
 
3579
3857
  bind_capture(acc, name, current ? Type::Combinator.union(current, break_value) : break_value)
3580
3858
  end
@@ -3965,7 +4243,7 @@ module Rigor
3965
4243
  # signature, unknown receiver) the structural answer stands.
3966
4244
  def for_iteration_element_type(coll_type)
3967
4245
  structural = collection_element_type(coll_type)
3968
- return structural unless structural.equal?(Type::Combinator.untyped)
4246
+ return structural unless Type::Combinator.untyped?(structural)
3969
4247
 
3970
4248
  block_params = MethodDispatcher.expected_block_param_types(
3971
4249
  receiver_type: coll_type,
@@ -3999,13 +4277,17 @@ module Rigor
3999
4277
  scope.with_ivar(index_node.name, element_type).without_ivar_guard(index_node.name)
4000
4278
  when Prism::GlobalVariableTargetNode
4001
4279
  scope.with_global(index_node.name, element_type)
4280
+ when Prism::ClassVariableTargetNode
4281
+ scope.with_cvar(index_node.name, element_type)
4002
4282
  when Prism::IndexTargetNode
4003
4283
  widen_index_target(index_node, element_type, scope, type_scope: scope)
4284
+ when Prism::CallTargetNode
4285
+ widen_attribute_write(index_node.receiver, index_node.name, scope)
4004
4286
  when Prism::MultiTargetNode
4005
4287
  bound = MultiTargetBinder.bind_marked(index_node, element_type, scope: scope)
4006
- widen_index_targets(bound, bound.apply_to(scope), type_scope: scope)
4288
+ widen_attribute_targets(index_node, widen_index_targets(bound, bound.apply_to(scope), type_scope: scope))
4007
4289
  when Prism::SplatNode
4008
- bind_for_splat_index(index_node, scope)
4290
+ bind_for_splat_index(index_node, element_type, scope)
4009
4291
  else
4010
4292
  scope
4011
4293
  end
@@ -4013,13 +4295,29 @@ module Rigor
4013
4295
 
4014
4296
  # `for *h[:a] in pairs` — Prism gives a bare splat index as a `SplatNode`, not a `MultiTargetNode`, so the
4015
4297
  # binder never sees it. The store is `*h[:a] = element`, an array of the element's `to_ary` parts; the receiver
4016
- # widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. A bare `*name` target stays
4017
- # unbound here, as before.
4018
- def bind_for_splat_index(splat, scope)
4298
+ # widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. `for *x.r in pairs` calls `r=`
4299
+ # and widens as `for x.r in` does (issue #2693).
4300
+ #
4301
+ # Issue #1217 — a bare `*a` / `*@a` / `*@@a` / `*$a` binds the name to the rest
4302
+ # {MultiTargetBinder.decompose_slots} reads off the element: a `Tuple` element exactly, an `Array[T]` element as
4303
+ # `Array[T]`, and anything else — a union, a value Ruby would wrap as `[element]` — the `Dynamic[top]` floor.
4304
+ # Left unbound, a read after the loop folded on the pre-loop binding.
4305
+ def bind_for_splat_index(splat, element_type, scope)
4019
4306
  target = splat.expression
4020
- return scope unless target.is_a?(Prism::IndexTargetNode)
4021
-
4022
- widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
4307
+ case target
4308
+ when Prism::CallTargetNode
4309
+ widen_attribute_write(target.receiver, target.name, scope)
4310
+ when Prism::IndexTargetNode
4311
+ widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
4312
+ when Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
4313
+ Prism::ClassVariableTargetNode, Prism::GlobalVariableTargetNode
4314
+ _, rest, = MultiTargetBinder.decompose_slots(
4315
+ element_type, front_count: 0, back_count: 0, rest_present: true, scope: scope
4316
+ )
4317
+ bind_for_index(target, rest, scope)
4318
+ else
4319
+ scope
4320
+ end
4023
4321
  end
4024
4322
 
4025
4323
  # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
@@ -4356,13 +4654,9 @@ module Rigor
4356
4654
  # false positives in `prism-1.9.0`'s auto-generated `copy` methods alone. A nested `def` is a return barrier:
4357
4655
  # its body's `return`s belong to the inner method, not the one currently being inferred. Suspend the return sink
4358
4656
  # across the nested body so `eval_return` does not record them into the outer method's return type.
4359
- outer_sink = Thread.current[RETURN_SINK_KEY]
4360
- Thread.current[RETURN_SINK_KEY] = nil
4361
- begin
4657
+ typing_state.with(RETURN_SINK, nil) do
4362
4658
  sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
4363
4659
  sub_eval(node.body, body_scope, class_context: @class_context) if node.body
4364
- ensure
4365
- Thread.current[RETURN_SINK_KEY] = outer_sink
4366
4660
  end
4367
4661
  [Type::Combinator.constant_of(node.name), scope]
4368
4662
  end
@@ -5156,8 +5450,15 @@ module Rigor
5156
5450
  narrowed = Narrowing.safe_navigation_block_scope(node, scope)
5157
5451
  return enter_call_block(node, block) if narrowed.equal?(scope)
5158
5452
 
5159
- evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
5160
- .send(:enter_call_block, node, block)
5453
+ narrowed_evaluator = evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
5454
+ narrowed_evaluator.send(:enter_call_block_carrying, node, block, @value_position_block)
5455
+ end
5456
+
5457
+ # The safe-navigation arm of {#evaluate_block_if_present}: the narrowed evaluator enters the block carrying the
5458
+ # value-position mark {#evaluate_block_of} set on this one, which {#block_self_entry} reads.
5459
+ def enter_call_block_carrying(node, block, value_position_block)
5460
+ @value_position_block = value_position_block
5461
+ enter_call_block(node, block)
5161
5462
  end
5162
5463
 
5163
5464
  def enter_call_block(node, block)
@@ -5215,14 +5516,8 @@ module Rigor
5215
5516
  end
5216
5517
 
5217
5518
  # Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
5218
- def without_return_sink
5219
- outer_sink = Thread.current[RETURN_SINK_KEY]
5220
- Thread.current[RETURN_SINK_KEY] = nil
5221
- begin
5222
- yield
5223
- ensure
5224
- Thread.current[RETURN_SINK_KEY] = outer_sink
5225
- end
5519
+ def without_return_sink(&)
5520
+ TypingState.current.with(RETURN_SINK, nil, &)
5226
5521
  end
5227
5522
 
5228
5523
  # Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
@@ -5693,12 +5988,13 @@ module Rigor
5693
5988
  # binds to it floors: the body reads it anywhere but as a splat argument of a call (`args.first[:k] = 1`), or
5694
5989
  # hands it on (`b(*args)`) to a call that resolves to a def which writes a positional parameter or has a rest
5695
5990
  # parameter of its own that it hands on. A rest handed to a call that resolves to no user def hands off nothing,
5696
- # as for any hand-off. A callee reached through an inherited lookup ({FLOOR_ALL}) reads as written, as its
5697
- # parameters do.
5991
+ # as for any hand-off. A callee reached through an inherited lookup ({FLOOR_ALL}) or one that may not be the def
5992
+ # the call runs ({FLOOR_EVERY}) reads as written, as its parameters do: neither owner is a {CalleeOwner}, so its
5993
+ # body's hand-offs cannot be read under it.
5698
5994
  def rest_parameter_written?(def_node, owner)
5699
5995
  return true if def_node.nil?
5700
5996
  return false unless variable_arity?(def_node)
5701
- return true if owner == FLOOR_ALL
5997
+ return true if [FLOOR_ALL, FLOOR_EVERY].include?(owner)
5702
5998
 
5703
5999
  other, calls = def_census(:rest_uses, def_node) { rest_uses(def_node) }
5704
6000
  return true if other
@@ -5774,12 +6070,15 @@ module Rigor
5774
6070
  end
5775
6071
 
5776
6072
  # `base_scope` with each local `node` passes to a parameter of `mutated` rebound to what the block answers for
5777
- # its binding; a nil answer, or an answer equal to the binding, leaves it.
6073
+ # its binding; a nil answer, or an answer equal to the binding, leaves it. Issue #2335 — an argument that may be
6074
+ # another local's object (a block parameter, a copy) floors every local another holder may reach
6075
+ # ({AliasMutationFloor.floor_after_written_local}), a block-local one included.
5778
6076
  def floor_arguments(node, mutated, base_scope, offset, climbing = nil)
5779
6077
  arguments = arguments_for_slots(node, mutated, offset)
5780
6078
  return base_scope if arguments.empty?
5781
6079
 
5782
6080
  arguments.reduce(base_scope) do |acc, argument|
6081
+ acc = AliasMutationFloor.floor_after_written_local(acc, argument, node)
5783
6082
  next acc unless acc.locals.key?(argument.name)
5784
6083
  next acc if climbing && argument.depth <= climbing
5785
6084
 
@@ -5814,15 +6113,19 @@ module Rigor
5814
6113
  def callee_chain_store
5815
6114
  return NO_CHAIN_STORE if hand_off_memo_bypassed?
5816
6115
 
5817
- table = (Thread.current[CHAIN_STORE_KEY] ||= ObjectSpace::WeakKeyMap.new)
6116
+ table = typing_state.weak_key_map(CHAIN_STORE)
5818
6117
  self_type = scope.self_type
5819
6118
  { store: (table[scope.discovery] ||= {}.compare_by_identity),
5820
6119
  ctx: [self_type.is_a?(Type::Singleton), enclosing_class_name_for(self_type)] }
5821
6120
  end
5822
6121
 
5823
- CHAIN_STORE_KEY = RunScopedSlots.register(:rigor_callee_chain_successors)
6122
+ CHAIN_STORE = TypingState.memo(
6123
+ :callee_chain_successors,
6124
+ run_scoped: true, audit: "successor",
6125
+ reason: "graph facts keyed by discovery, the same whichever census reads them"
6126
+ )
5824
6127
  NO_CHAIN_STORE = {}.freeze
5825
- private_constant :CHAIN_STORE_KEY, :NO_CHAIN_STORE
6128
+ private_constant :CHAIN_STORE, :NO_CHAIN_STORE
5826
6129
 
5827
6130
  # Issue #2296 — the `{ name => slot }` parameters the callee mutates inside a block in its body
5828
6131
  # ({#own_block_content_parameters}), closed over hand-offs as {#callee_content_mutated_parameters} is.
@@ -5858,22 +6161,47 @@ module Rigor
5858
6161
  # survive `with_self_type` and change what a constant in the def's body types as, so the census is no longer
5859
6162
  # the def's alone, and the first caller's narrowing must not become every later caller's answer.
5860
6163
  def resolved_census(kind, def_node, &)
6164
+ # An audit recomputation bypasses the memo but still opens the site's declaration (ADR-129 WD1).
6165
+ return MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &) if MemoAudit.suspended?
5861
6166
  return yield if hand_off_memo_bypassed?
5862
6167
 
5863
6168
  discovery = scope.discovery
5864
- table = (Thread.current[RESOLVED_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
6169
+ table = typing_state.weak_key_map(RESOLVED_CENSUS)
5865
6170
  slot = (table[discovery] ||= [{}, {}])
5866
6171
  self_type = scope.self_type
5867
6172
  by_class = (slot[self_type.is_a?(Type::Singleton) ? 1 : 0][kind] ||= {})
5868
6173
  class_name = enclosing_class_name_for(self_type)
5869
6174
  by_def = (by_class[class_name] ||= {}.compare_by_identity)
5870
- return by_def[def_node] = yield unless by_def.key?(def_node)
6175
+ unless by_def.key?(def_node)
6176
+ return by_def[def_node] = MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &)
6177
+ end
5871
6178
 
5872
- MemoAudit.check("resolved census", by_def[def_node], kind, class_name, &)
6179
+ MemoAudit.check("resolved census", by_def[def_node], kind, class_name) do
6180
+ MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &)
6181
+ end
5873
6182
  end
5874
6183
 
5875
- RESOLVED_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_resolved_census)
5876
- private_constant :RESOLVED_CENSUS_KEY
6184
+ RESOLVED_CENSUS = TypingState.memo(
6185
+ :callee_resolved_census,
6186
+ run_scoped: true, audit: "resolved census",
6187
+ reason: "keyed by discovery, side, kind, class and def, all its answer reads; audited by MemoAudit"
6188
+ )
6189
+ private_constant :RESOLVED_CENSUS
6190
+
6191
+ # ADR-129 WD1's key audit of a resolved-census miss. A census types a callee's receivers ({#receiver_callees_in})
6192
+ # under the frames its caller installed, and keys on none of them: a known gap, #2867.
6193
+ CENSUS_CALLER_FRAMES = %i[
6194
+ threaded_block_body key_presence_walk return_sink next_sink break_sink break_value_sink retry_frames
6195
+ interface_erasure interface_dispatch_off
6196
+ ].freeze
6197
+ private_constant :CENSUS_CALLER_FRAMES
6198
+ RESOLVED_CENSUS_KEY_AUDIT = MemoAudit.key_declaration(
6199
+ gaps: CENSUS_CALLER_FRAMES.to_h do |name|
6200
+ [name, "#2867: the census types a callee's receivers under the caller's frames, and its key carries " \
6201
+ "none of them"]
6202
+ end
6203
+ ).freeze
6204
+ private_constant :RESOLVED_CENSUS_KEY_AUDIT
5877
6205
 
5878
6206
  # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
5879
6207
  # run later / repeatedly) AND those mutated directly in the method body during the call itself
@@ -6128,15 +6456,22 @@ module Rigor
6128
6456
 
6129
6457
  # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
6130
6458
  def def_census(kind, def_node, &)
6131
- table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
6459
+ # An audit recomputation bypasses the memo, so it reads no stored entry (ADR-127 WD3, #3071).
6460
+ return MemoAudit.key_audit("def census", &) if MemoAudit.suspended?
6461
+
6462
+ table = typing_state.weak_key_map(DEF_CENSUS)
6132
6463
  entry = (table[def_node] ||= {})
6133
- return entry[kind] = yield unless entry.key?(kind)
6464
+ return entry[kind] = MemoAudit.key_audit("def census", &) unless entry.key?(kind)
6134
6465
 
6135
6466
  MemoAudit.check("def census", entry[kind], kind, &)
6136
6467
  end
6137
6468
 
6138
- DEF_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_def_census)
6139
- private_constant :DEF_CENSUS_KEY
6469
+ DEF_CENSUS = TypingState.memo(
6470
+ :callee_def_census,
6471
+ run_scoped: true, audit: "def census",
6472
+ reason: "keyed by def node, the only input it reads; audited by MemoAudit"
6473
+ )
6474
+ private_constant :DEF_CENSUS
6140
6475
 
6141
6476
  # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
6142
6477
  # a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
@@ -6748,14 +7083,19 @@ module Rigor
6748
7083
  # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
6749
7084
  # the current scope — a cheap pre-filter so the def resolution / body scan only runs for calls that could actually
6750
7085
  # floor something.
7086
+ # A local bound in the current scope, or (issue #2335) one whose object another local may share, which the
7087
+ # alias floor reads a callee's write through ({AliasMutationFloor.reaches_other_locals?}).
7088
+ def passed_local?(read, node)
7089
+ scope.locals.key?(read.name) || AliasMutationFloor.reaches_other_locals?(scope, read, node)
7090
+ end
7091
+
6751
7092
  def call_passes_local_argument?(node)
6752
7093
  args = node.arguments
6753
7094
  return false unless args.respond_to?(:arguments)
6754
7095
 
6755
7096
  args.arguments.any? do |arg|
6756
7097
  case arg
6757
- when Prism::LocalVariableReadNode
6758
- scope.locals.key?(arg.name)
7098
+ when Prism::LocalVariableReadNode then passed_local?(arg, node)
6759
7099
  when Prism::SplatNode
6760
7100
  arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
6761
7101
  when Prism::KeywordHashNode
@@ -6781,7 +7121,7 @@ module Rigor
6781
7121
  while receiver.is_a?(Prism::CallNode)
6782
7122
  if receiver.block.is_a?(Prism::BlockNode) &&
6783
7123
  classify_closure_escape(receiver) != :non_escaping
6784
- acc = widen_escaping_content_captures(receiver.block, acc)
7124
+ acc = widen_escaping_content_captures(receiver.block, acc, foreign_self: foreign_self_block?(receiver))
6785
7125
  end
6786
7126
  receiver = receiver.receiver
6787
7127
  end
@@ -6799,7 +7139,14 @@ module Rigor
6799
7139
  # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String -> `String`. The seed's element/key/value
6800
7140
  # precision is forgotten; only the carrier survives. Read-only captures and locals the block merely rebinds
6801
7141
  # (already floored by `drop_captured_narrowing`) are untouched.
6802
- def widen_escaping_content_captures(block_node, post_scope)
7142
+ #
7143
+ # An instance variable of this scope's `self` the body mutates in place is floored too (issue #1208): a callback
7144
+ # that runs `@opts[:format] = "json"` later left `@opts[:format]` folding to `"text"`, and so are a class variable
7145
+ # and a global (issue #3096). Each floors member by member ({#member_content_floor}, issue #3095), so a lazily
7146
+ # initialised `@buf = flag ? [1] : nil` keeps its `nil`. An instance variable under a block that runs on another
7147
+ # object is that object's, and so is every one when `foreign_self` says the closure itself does; a class
7148
+ # variable resolves lexically and a global is global, so neither depends on `self`.
7149
+ def widen_escaping_content_captures(block_node, post_scope, foreign_self: false)
6803
7150
  body = block_node.body
6804
7151
  return post_scope if body.nil?
6805
7152
 
@@ -6807,15 +7154,85 @@ module Rigor
6807
7154
  # `local[k] = v` write, which `collect_content_mutations` cannot see (see below).
6808
7155
  post_scope = floor_block_body_callee_escaped_args(body, post_scope)
6809
7156
 
6810
- mutations = collect_content_mutations(body)
6811
- return post_scope if mutations.empty?
7157
+ post_scope = collect_content_mutations(body).keys.reduce(post_scope) do |acc, name|
7158
+ floored = content_floor_for(CapturedLocals.bound_type(acc, name))
7159
+ floored.nil? ? acc : bind_mutated_capture(acc, name, floored)
7160
+ end
7161
+
7162
+ floor_non_local_contents(escaping_non_local_content_mutations(body, post_scope, foreign_self), post_scope)
7163
+ end
6812
7164
 
6813
- mutations.keys.reduce(post_scope) do |acc, name|
6814
- floored = content_floor_for(acc.local(name))
6815
- floored.nil? ? acc : acc.with_mutated_local(name, floored)
7165
+ # `scope` with each instance variable, class variable or global in `names` floored member by member.
7166
+ def floor_non_local_contents(names, scope)
7167
+ names.reduce(scope) do |acc, name|
7168
+ bound = CapturedLocals.bound_type(acc, name)
7169
+ floored = member_content_floor(bound)
7170
+ floored.equal?(bound) ? acc : CapturedLocals.bind(acc, name, floored)
6816
7171
  end
6817
7172
  end
6818
7173
 
7174
+ # `type` with each member {#content_floor_for} recognises floored to its bare carrier and every other member
7175
+ # kept (issue #3095): `Array[1]?` reads `Array[Dynamic[top]]?` and `3 | [1]` reads `3 | Array[Dynamic[top]]`,
7176
+ # so the floor neither drops the `nil` a callback that never runs leaves nor narrows the binding. `type` itself
7177
+ # when no member floors.
7178
+ def member_content_floor(type)
7179
+ members = type.is_a?(Type::Union) ? type.members : [type]
7180
+ floored = members.map { |member| content_floor_for(member) || member }
7181
+ return type if floored.each_with_index.all? { |member, index| member.equal?(members[index]) }
7182
+
7183
+ Type::Combinator.union(*floored)
7184
+ end
7185
+
7186
+ # The instance variables of `scope`'s `self`, the class variables and the globals an escaping body mutates in
7187
+ # place, on the terms {#capturable_non_local?} reads against `scope` — the continuation the floor writes into.
7188
+ def escaping_non_local_content_mutations(body, scope, foreign_self)
7189
+ names = nil
7190
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
7191
+ name, content = non_local_shape_mutation(node)
7192
+ next if name.nil?
7193
+ next unless (content && capturable_non_local?(name, ancestors, scope, foreign_self: foreign_self)) ||
7194
+ block_defined_method_capture?(name, ancestors, scope, foreign_self)
7195
+
7196
+ (names ||= []) << name
7197
+ end
7198
+ names ? names.uniq : NO_REWRITES
7199
+ end
7200
+
7201
+ # `[name, content]` when `node` mutates an instance variable, class variable or global in place, else nil.
7202
+ # `content` says a content scan counts the mutation ({CONTENT_MUTATORS}, or an index write). A method the block
7203
+ # defines counts every name the straight-line widening responds to as well ({CALLEE_WRITE_MUTATORS}: `merge!`,
7204
+ # `map!`, `clear`, …), because its floor reads no evidence from the call (issue #3163).
7205
+ def non_local_shape_mutation(node)
7206
+ content = index_write?(node)
7207
+ unless content
7208
+ return nil unless node.is_a?(Prism::CallNode) && CALLEE_WRITE_MUTATORS.include?(node.name)
7209
+
7210
+ content = CONTENT_MUTATORS.include?(node.name)
7211
+ end
7212
+ receiver = node.receiver
7213
+ NON_LOCAL_READ_NODES.include?(receiver.class) ? [receiver.name, content] : nil
7214
+ end
7215
+
7216
+ # Issue #3163 — `post_scope` with every instance variable, class variable and global floored member by member
7217
+ # ({#member_content_floor}) that a method defined in `call_node`'s non-escaping block mutates in place
7218
+ # ({#block_defined_method_capture?}). The block, or anything it calls, may call that method before it returns:
7219
+ # `$buf = []; [1].each { def h; $buf << 1; end; h }` leaves `$buf` as `[1]`, and the content join reads no
7220
+ # evidence from a method body, which it types in no scope of the block's. No call is followed, so a method the
7221
+ # block defines and nothing calls floors too. An escaping block floors the same names
7222
+ # ({#escaping_non_local_content_mutations}).
7223
+ def floor_block_defined_method_mutations(call_node, block, post_scope)
7224
+ foreign = foreign_self_block?(call_node)
7225
+ names = nil
7226
+ Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
7227
+ name, = non_local_shape_mutation(node)
7228
+ # Read against the continuation: a variable the block itself first binds has no pre-call binding.
7229
+ next if name.nil? || !block_defined_method_capture?(name, ancestors, post_scope, foreign)
7230
+
7231
+ (names ||= []) << name
7232
+ end
7233
+ names.nil? ? post_scope : floor_non_local_contents(names.uniq, post_scope)
7234
+ end
7235
+
6819
7236
  # Inside an ESCAPING block body, a captured outer local can be content- mutated transitively: the body is (or
6820
7237
  # contains) a self-call that escape-mutates one of its arguments. The canonical shape is the CLI's own
6821
7238
  # `OptionParser.new { |opts| define_options(opts, options) }` — the block body is a bare `define_options(opts,
@@ -6939,7 +7356,7 @@ module Rigor
6939
7356
  # continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
6940
7357
  # so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
6941
7358
  def collect_repeated_block_returns(call_node, post_scope)
6942
- return if Thread.current[RETURN_SINK_KEY].nil?
7359
+ return if typing_state[RETURN_SINK].nil?
6943
7360
 
6944
7361
  block = call_node.block
6945
7362
  return unless repeated_block_return_candidate?(call_node, block)
@@ -7076,16 +7493,16 @@ module Rigor
7076
7493
  end
7077
7494
  end
7078
7495
 
7079
- # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
7080
- # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
7081
- # rebinds no ivar leaves the locals exactly as before.
7496
+ # Binds a name from `CapturedLocals.writes`. An ivar (or any other non-local) goes through
7497
+ # {CapturedLocals.bind}, which keeps an ivar's issue #286 optimistic mark. A local keeps the plain `with_local`
7498
+ # these seams have always used, so a block that rebinds no ivar leaves the locals exactly as before.
7082
7499
  #
7083
7500
  # Issue #1402 — `optimistic` is a mark a write-back pass's exit held ({#with_capture_exit_marks}); a name that has
7084
7501
  # one binds through {CapturedLocals.bind} whatever its kind, which adds it.
7085
7502
  def bind_capture(scope, name, type, optimistic: nil)
7086
7503
  return CapturedLocals.bind(scope, name, type, optimistic: optimistic) if optimistic
7087
7504
 
7088
- CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
7505
+ CapturedLocals.local_name?(name) ? scope.with_local(name, type) : CapturedLocals.bind(scope, name, type)
7089
7506
  end
7090
7507
 
7091
7508
  # A block-level `break` ends the CALL, so the binding it leaves with starts no further iteration and is no input
@@ -7113,9 +7530,8 @@ module Rigor
7113
7530
  end
7114
7531
  return converged if arms.empty?
7115
7532
 
7116
- floor = Type::Combinator.untyped
7117
7533
  converged.to_h do |name, type|
7118
- next [name, type] if type == floor
7534
+ next [name, type] if Type::Combinator.untyped?(type)
7119
7535
 
7120
7536
  [name, Type::Combinator.union(type, *arms.filter_map { |arm| CapturedLocals.bound_type(arm, name) })]
7121
7537
  end
@@ -7174,22 +7590,48 @@ module Rigor
7174
7590
  block = call_node.block
7175
7591
  return post_scope unless block.is_a?(Prism::BlockNode)
7176
7592
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
7593
+ return post_scope if block.body.nil?
7177
7594
 
7178
- body = block.body
7179
- return post_scope if body.nil?
7595
+ post_scope = join_block_content_captures(call_node, block, post_scope, seed_scope)
7596
+ # Issue #3163 — a method the block defines may run during the block, so its in-place mutations of an instance
7597
+ # variable, class variable or global floor that binding. The join reads no evidence from a method body.
7598
+ floor_block_defined_method_mutations(call_node, block, post_scope)
7599
+ end
7180
7600
 
7601
+ # The join {#content_writeback_block_captures} documents, for a non-escaping `block` with a body.
7602
+ def join_block_content_captures(call_node, block, post_scope, seed_scope)
7603
+ body = block.body
7181
7604
  shadows = {}.compare_by_identity
7182
- mutations = captured_content_mutations(block, shadows)
7605
+ mutations = captured_content_mutations(block, shadows, call_node)
7183
7606
  return post_scope if mutations.empty?
7184
7607
 
7185
7608
  seeds = mutations.to_h do |name, _calls|
7186
- [name, lookup_mutated_seed(body, name, seed_scope.local(name)) { |depth, nesting| depth > nesting }]
7609
+ seed = CapturedLocals.bound_type(seed_scope, name)
7610
+ [name, lookup_mutated_seed(body, name, seed) { |depth, nesting| depth > nesting }]
7187
7611
  end
7188
- shadow_rebound_reads(block, mutations, seeds, shadows)
7612
+ shadow_rebound_reads(block, mutations, seeds, shadows, call_node)
7189
7613
  joined = join_content_to_fixpoint(mutations, seeds, build_block_entry_scope(call_node, block), shadows)
7190
7614
  rewrites = local_rewrites(block.body) { |receiver, ancestors| receiver.depth > scope_nesting(ancestors) }
7191
7615
  joined.reduce(post_scope) do |acc, (name, type)|
7192
- acc.with_mutated_local(name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
7616
+ bind_mutated_capture(acc, name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
7617
+ end
7618
+ end
7619
+
7620
+ # `scope` with a capture the block mutated in place bound to its joined or floored content: a local through
7621
+ # `Scope#with_mutated_local`, which keeps the marks a source-level write drops, and an instance variable, class
7622
+ # variable or global through {CapturedLocals.bind}, which keeps an ivar's optimistic nil-freeness mark. The
7623
+ # binding still names the same object.
7624
+ def bind_mutated_capture(scope, name, type)
7625
+ CapturedLocals.local_name?(name) ? scope.with_mutated_local(name, type) : CapturedLocals.bind(scope, name, type)
7626
+ end
7627
+
7628
+ # `scope` with a content seam's working binding for `name` — its join so far, or `Dynamic[top]` for a read the
7629
+ # body rebinds — whatever kind of variable it is.
7630
+ def bind_capture_slot(scope, name, type)
7631
+ case CapturedLocals.variable_kind(name)
7632
+ when :local then scope.with_local(name, type)
7633
+ when :ivar then scope.with_ivar(name, type)
7634
+ else CapturedLocals.bind(scope, name, type)
7193
7635
  end
7194
7636
  end
7195
7637
 
@@ -7198,19 +7640,29 @@ module Rigor
7198
7640
 
7199
7641
  # The {RewriteMutation} names `root` calls on each local its block admits — `a.map!(&:to_s)` beside an `a << x`.
7200
7642
  # The receiver test is the one the caller's content-mutation walk applies.
7201
- def local_rewrites(root)
7643
+ def local_rewrites(root, &)
7202
7644
  rewrites = {}
7203
7645
  Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
7204
7646
  next unless node.is_a?(Prism::CallNode) && RewriteMutation.rewriter?(node.name)
7205
7647
 
7206
7648
  receiver = node.receiver
7207
- next unless receiver.is_a?(Prism::LocalVariableReadNode) && yield(receiver, ancestors)
7649
+ next unless rewritten_capture_receiver?(receiver, ancestors, &)
7208
7650
 
7209
7651
  (rewrites[receiver.name] ||= []) << node.name
7210
7652
  end
7211
7653
  rewrites
7212
7654
  end
7213
7655
 
7656
+ # A local the caller's test admits, or an instance variable, class variable or global outside a body that makes
7657
+ # it another variable ({#under_scope_body?}): the names the content walks can collect.
7658
+ def rewritten_capture_receiver?(receiver, ancestors)
7659
+ case receiver
7660
+ when Prism::LocalVariableReadNode then yield(receiver, ancestors)
7661
+ when *NON_LOCAL_READ_NODES then !under_scope_body?(ancestors, CapturedLocals.variable_kind(receiver.name))
7662
+ else false
7663
+ end
7664
+ end
7665
+
7214
7666
  # The slice-C join (and the loop seam's) rebuilds a collection from its SEED, so the rewrite the body's own
7215
7667
  # widening applied is gone from it: `a = [1]; [0].each { a.map!(&:to_s); a << "x" }` read `Array["x" | 1]`, and
7216
7668
  # `a[0] == "1"` folded always-falsey. Each rewrite the body makes on the local is re-applied here, on the seam's
@@ -7237,10 +7689,14 @@ module Rigor
7237
7689
  # - joined with the block-entry typing, it still stores exit values no store reads;
7238
7690
  # - one more walk of the body to the store inherits every gap in the engine's in-body flow.
7239
7691
  #
7692
+ # An instance variable the body rebinds is one too (issue #1235): `@total += x; out << @total` stored the
7693
+ # pre-call `@total`, and so are a class variable and a global (issue #3096). Each counts on the terms the content
7694
+ # walk takes one ({#capturable_non_local?}).
7695
+ #
7240
7696
  # See ADR-56 WD2.13.
7241
- def shadow_rebound_reads(block, sites, seeds, shadows)
7697
+ def shadow_rebound_reads(block, sites, seeds, shadows, call_node)
7242
7698
  entry_names = scope.locals.keys | CapturedLocals.introduced_locals(block).to_a
7243
- written = scope_local_writes(block) & entry_names
7699
+ written = (scope_local_writes(block) & entry_names) | rebound_capturable_non_locals(block, call_node)
7244
7700
  return if written.empty?
7245
7701
 
7246
7702
  sites.each do |name, nodes|
@@ -7254,6 +7710,23 @@ module Rigor
7254
7710
  end
7255
7711
  end
7256
7712
 
7713
+ # The instance variables, class variables and globals the block body writes that {#capturable_non_local?}
7714
+ # accepts; no instance variable for a block that runs on another object. Every self-rebinding call classifies
7715
+ # `:unknown` today, so neither content seam that calls this meets one; the guard keeps the answer right should
7716
+ # the escape analysis learn to call one non-escaping.
7717
+ def rebound_capturable_non_locals(block, call_node)
7718
+ return NO_REWRITES if block.body.nil?
7719
+
7720
+ foreign = foreign_self_block?(call_node)
7721
+ names = []
7722
+ Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
7723
+ next unless CapturedLocals::NON_LOCAL_WRITE_NODES.include?(node.class)
7724
+
7725
+ names << node.name if capturable_non_local?(node.name, ancestors, scope, foreign_self: foreign)
7726
+ end
7727
+ names.uniq
7728
+ end
7729
+
7257
7730
  # The locals a store reads to build what it stores. An Array index write's index arguments are left out, and so
7258
7731
  # is any name they read: the join classifies the store as an element or a splice from the index's type, and a
7259
7732
  # `Dynamic` index reads as both, so `grid[i] = [x, x]` would join `x` itself as a member of `grid` beside the
@@ -7292,6 +7765,29 @@ module Rigor
7292
7765
  SCOPE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
7293
7766
  private_constant :SCOPE_BODY_NODES
7294
7767
 
7768
+ CLASS_BODY_NODES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
7769
+ private_constant :CLASS_BODY_NODES
7770
+
7771
+ # The bodies under a block body where a non-local of each kind is no longer the one the block's caller sees, or
7772
+ # does not run while the block does. A `def` body runs only when called. A class or module body is a class of
7773
+ # its own for an instance variable and a class variable, but runs at once and reads the one global. `class <<
7774
+ # self` keeps the enclosing class's class variables, so only an instance variable leaves there.
7775
+ NON_LOCAL_SCOPE_BODY_NODES = {
7776
+ ivar: SCOPE_BODY_NODES,
7777
+ cvar: [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze,
7778
+ global: [Prism::DefNode].freeze
7779
+ }.freeze
7780
+ private_constant :NON_LOCAL_SCOPE_BODY_NODES
7781
+
7782
+ # True when a non-local of `kind` (`:ivar`, `:cvar` or `:global`) at `ancestors` under a block body sits in a body
7783
+ # the block defines that makes it another variable or does not run during the block
7784
+ # ({NON_LOCAL_SCOPE_BODY_NODES}): for an instance variable, any method, class or module body, where `self` is that
7785
+ # body's own.
7786
+ def under_scope_body?(ancestors, kind = :ivar)
7787
+ bodies = NON_LOCAL_SCOPE_BODY_NODES.fetch(kind)
7788
+ ancestors.any? { |a| bodies.any? { |k| a.is_a?(k) } }
7789
+ end
7790
+
7295
7791
  # True when the local `node` names lives in the scope the walk started in or an outer one.
7296
7792
  def same_scope_local?(node, ancestors)
7297
7793
  return false if ancestors.any? { |ancestor| SCOPE_BODY_NODES.any? { |klass| ancestor.is_a?(klass) } }
@@ -7308,16 +7804,37 @@ module Rigor
7308
7804
  ].freeze
7309
7805
  private_constant :LOCAL_READ_NODES
7310
7806
 
7311
- # Every local `node` reads from the scope it sits in: a read inside an inner block of a name that block
7312
- # introduces is a different variable.
7807
+ # The same for an instance variable, a class variable and a global.
7808
+ NON_LOCAL_VALUE_READ_NODES = Set[
7809
+ Prism::InstanceVariableReadNode,
7810
+ Prism::InstanceVariableOperatorWriteNode,
7811
+ Prism::InstanceVariableOrWriteNode,
7812
+ Prism::InstanceVariableAndWriteNode,
7813
+ Prism::ClassVariableReadNode,
7814
+ Prism::ClassVariableOperatorWriteNode,
7815
+ Prism::ClassVariableOrWriteNode,
7816
+ Prism::ClassVariableAndWriteNode,
7817
+ Prism::GlobalVariableReadNode,
7818
+ Prism::GlobalVariableOperatorWriteNode,
7819
+ Prism::GlobalVariableOrWriteNode,
7820
+ Prism::GlobalVariableAndWriteNode
7821
+ ].freeze
7822
+ private_constant :NON_LOCAL_VALUE_READ_NODES
7823
+
7824
+ # Every local `node` reads from the scope it sits in, and every instance variable, class variable or global
7825
+ # outside a nested method or class body: a read inside an inner block of a name that block introduces is a
7826
+ # different variable. The caller intersects the answer with the names the body writes, which
7827
+ # {#rebound_capturable_non_locals} has already filtered.
7313
7828
  def local_reads(node)
7314
7829
  return [] if node.nil?
7315
7830
 
7316
7831
  names = []
7317
7832
  Source::NodeWalker.each_with_ancestors(node) do |n, ancestors|
7318
- next unless LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
7319
-
7320
- names << n.name if same_scope_local?(n, ancestors)
7833
+ if LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
7834
+ names << n.name if same_scope_local?(n, ancestors)
7835
+ elsif NON_LOCAL_VALUE_READ_NODES.include?(n.class)
7836
+ names << n.name unless under_scope_body?(ancestors, CapturedLocals.variable_kind(n.name))
7837
+ end
7321
7838
  end
7322
7839
  names.uniq
7323
7840
  end
@@ -7403,7 +7920,7 @@ module Rigor
7403
7920
 
7404
7921
  def bind_content_joins(scope, kinds, seeds, evidence)
7405
7922
  kinds.reduce(scope) do |acc, (name, kind)|
7406
- acc.with_local(name, content_entry_binding(seeds[name], kind, name, evidence))
7923
+ bind_capture_slot(acc, name, content_entry_binding(seeds[name], kind, name, evidence))
7407
7924
  end
7408
7925
  end
7409
7926
 
@@ -7436,10 +7953,23 @@ module Rigor
7436
7953
  #
7437
7954
  # Each site nested in an inner block or lambda is recorded in `shadows` with the names those blocks bind
7438
7955
  # themselves (see {#join_content_to_fixpoint}).
7439
- def captured_content_mutations(block, shadows)
7956
+ #
7957
+ # Issue #1208 — an instance variable is a capture too: every block made in a method shares its `self`, so `@out
7958
+ # << x` in the body grows the method's `@out` exactly as `out << x` grows `out`. It counts on the terms
7959
+ # {CapturedLocals.writes} takes a rebound one: bound at the call site and not on its ADR-58 declaration seed
7960
+ # ({CapturedLocals.rebindable_ivar?}), outside a nested method or class body, and not in a block that runs on
7961
+ # another object ({CapturedLocals.under_foreign_self_block?}, or `call_node`'s own block).
7962
+ #
7963
+ # Issue #3096 — a class variable and a global are captures on the same terms, less the foreign-self one: Ruby
7964
+ # resolves a class variable lexically, and a global is the same one whatever `self` is.
7965
+ def captured_content_mutations(block, shadows, call_node)
7440
7966
  mutations = Hash.new { |h, k| h[k] = [] }
7967
+ # Every self-rebinding call classifies `:unknown` today, so this seam does not meet one; the guard keeps the
7968
+ # ivar arm right should the escape analysis learn to call one non-escaping.
7969
+ foreign = foreign_self_block?(call_node)
7441
7970
  Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
7442
7971
  name, site = content_mutation_target(node) { |receiver| receiver.depth > scope_nesting(ancestors) }
7972
+ name, site = non_local_content_mutation_target(node, ancestors, foreign) if name.nil?
7443
7973
  next if name.nil?
7444
7974
 
7445
7975
  mutations[name] << site
@@ -7448,6 +7978,89 @@ module Rigor
7448
7978
  mutations
7449
7979
  end
7450
7980
 
7981
+ # `[name, node]` when `node` mutates the content of an instance variable of this scope's `self`, a class
7982
+ # variable or a global that the content seams may join ({#captured_content_mutations}), else
7983
+ # {NO_CONTENT_MUTATION}. `foreign_self` says the seam's block runs on another object.
7984
+ def non_local_content_mutation_target(node, ancestors, foreign_self)
7985
+ is_call_mutator = node.is_a?(Prism::CallNode) && CONTENT_MUTATORS.include?(node.name)
7986
+ return NO_CONTENT_MUTATION unless is_call_mutator || index_write?(node)
7987
+
7988
+ receiver = node.receiver
7989
+ return NO_CONTENT_MUTATION unless NON_LOCAL_READ_NODES.include?(receiver.class)
7990
+ return NO_CONTENT_MUTATION unless capturable_non_local?(receiver.name, ancestors, scope, foreign_self:)
7991
+ return NO_CONTENT_MUTATION unless collection_only?(CapturedLocals.bound_type(scope, receiver.name))
7992
+
7993
+ [receiver.name, node]
7994
+ end
7995
+
7996
+ # The receiver reads of a variable the content seams take besides a local.
7997
+ NON_LOCAL_READ_NODES = Set[
7998
+ Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
7999
+ ].freeze
8000
+ private_constant :NON_LOCAL_READ_NODES
8001
+
8002
+ # True when every member of `type` is an Array, a Hash or a String carrier. The non-escaping join takes an
8003
+ # instance variable only then: it drops a seed's `nil` after a guarded mutation (#1219), so `@buf = flag ? [1] :
8004
+ # nil; [1].each { @buf << 2 if @buf }` would lose the `nil` the runtime keeps when the block never runs it. The
8005
+ # local spelling has the gap; an instance variable, the common lazily-initialised spelling, keeps its pre-call
8006
+ # binding until it closes. The escaping floor takes every instance variable ({#member_content_floor}).
8007
+ def collection_only?(type)
8008
+ return false if type.nil?
8009
+ return type.members.all? { |member| collection_only?(member) } if type.is_a?(Type::Union)
8010
+
8011
+ arrayish?(type) || hashish?(type) || UnknownStoreWidening.carrier_class(type) == "String"
8012
+ end
8013
+
8014
+ # True when the non-local `name`, read or written at `ancestors` under a block body, is one the content seams
8015
+ # take against `in_scope`: bound there ({CapturedLocals.rebindable_non_local?}, which keeps an instance
8016
+ # variable on its declaration seed out) and outside a body the block defines that makes it another variable or
8017
+ # runs later ({#under_scope_body?}).
8018
+ #
8019
+ # - An instance variable must be `self`'s: not under a block that runs on another object, nor in a seam whose
8020
+ # block does (`foreign_self`), nor in any method, class or module body.
8021
+ # - A class variable resolves lexically and a global is global, so `self` does not matter to either (issue
8022
+ # #3096). A `def` body runs only when called, so neither counts there. A class or module body runs at once:
8023
+ # it reads its own class variables but the one global, so a global counts there. `class << self` keeps the
8024
+ # enclosing class's class variables, so a class variable counts there.
8025
+ # - A global Ruby's own signatures declare (`$stdout`, `$;`, `$0`, the frame-local `$~` and `$_`, …) is left
8026
+ # alone: the interpreter owns it, and some of them read through a getter (`$0` answers a frozen copy).
8027
+ def capturable_non_local?(name, ancestors, in_scope, foreign_self: false)
8028
+ return false if under_scope_body?(ancestors, CapturedLocals.variable_kind(name))
8029
+
8030
+ non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8031
+ end
8032
+
8033
+ # Issue #3163 — true when the non-local `name`, mutated at `ancestors` under a block body, sits in a method the
8034
+ # block defines and is one the content floor takes against `in_scope`. {#capturable_non_local?} leaves a `def`
8035
+ # body out because it runs only when called, but the block may call it. The terms are that method's, less the
8036
+ # `def` boundary:
8037
+ #
8038
+ # - A class or module body or a `class << self` between the block and the mutation still leaves every kind out:
8039
+ # a method defined there is another class's, and the walks do not ask who calls it.
8040
+ # - An instance variable in a method defined on an explicit receiver other than `self` (`def other.m`) is that
8041
+ # object's. A plain `def` counts wherever it is written, so one in a class body's block, whose instance
8042
+ # variables are an instance's and not the class's, floors the class's too.
8043
+ def block_defined_method_capture?(name, ancestors, in_scope, foreign_self)
8044
+ defs = ancestors.grep(Prism::DefNode)
8045
+ return false if defs.empty? || ancestors.any? { |a| CLASS_BODY_NODES.any? { |k| a.is_a?(k) } }
8046
+
8047
+ foreign_def = defs.any? { |d| !d.receiver.nil? && !d.receiver.is_a?(Prism::SelfNode) }
8048
+ return false if foreign_def && CapturedLocals.variable_kind(name) == :ivar
8049
+
8050
+ non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8051
+ end
8052
+
8053
+ # The half of {#capturable_non_local?} that does not read the bodies between the block and the non-local.
8054
+ def non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8055
+ case CapturedLocals.variable_kind(name)
8056
+ when :ivar
8057
+ return false if foreign_self || CapturedLocals.under_foreign_self_block?(ancestors, in_scope)
8058
+ when :global
8059
+ return false if in_scope.environment.global_for_name(name, builtin: true)
8060
+ end
8061
+ CapturedLocals.rebindable_non_local?(in_scope, name)
8062
+ end
8063
+
7451
8064
  def scope_nesting(ancestors)
7452
8065
  ancestors.count { |ancestor| scope_nesting_node?(ancestor) }
7453
8066
  end
@@ -7468,7 +8081,7 @@ module Rigor
7468
8081
  names = shadows[site]
7469
8082
  return scope if names.nil?
7470
8083
 
7471
- names.reduce(scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
8084
+ names.reduce(scope) { |acc, name| bind_capture_slot(acc, name, Type::Combinator.untyped) }
7472
8085
  end
7473
8086
 
7474
8087
  # `base` binds every fixed name to its join; the moving names are rebound on each pass.
@@ -7480,7 +8093,7 @@ module Rigor
7480
8093
  widen: Type::Combinator.method(:widen_value_pinned),
7481
8094
  evaluate_body: lambda do |assumption|
7482
8095
  pass_entry = kinds.reduce(base) do |acc, (name, kind)|
7483
- acc.with_local(name, content_carrier_under(seeds[name], kind, name, assumption))
8096
+ bind_capture_slot(acc, name, content_carrier_under(seeds[name], kind, name, assumption))
7484
8097
  end
7485
8098
  content_evidence(sites, kinds, pass_entry, shadows)
7486
8099
  end
@@ -7591,11 +8204,19 @@ module Rigor
7591
8204
  type.nil? || type.is_a?(Type::Bot) ? [] : [type]
7592
8205
  end
7593
8206
 
8207
+ # A joined name is a local, an instance variable (#1208), a class variable or a global (#3096), so a store
8208
+ # reading any of them moves.
8209
+ CAPTURE_READ_NODES = Set[
8210
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
8211
+ Prism::GlobalVariableReadNode
8212
+ ].freeze
8213
+ private_constant :CAPTURE_READ_NODES
8214
+
7594
8215
  def store_arguments_read?(node, names)
7595
8216
  arguments = node.arguments
7596
8217
  return false if arguments.nil?
7597
8218
 
7598
- Source::NodeWalker.each(arguments).any? { |n| n.is_a?(Prism::LocalVariableReadNode) && names.include?(n.name) }
8219
+ Source::NodeWalker.each(arguments).any? { |n| CAPTURE_READ_NODES.include?(n.class) && names.include?(n.name) }
7599
8220
  end
7600
8221
 
7601
8222
  # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
@@ -7854,8 +8475,14 @@ module Rigor
7854
8475
  next if ancestors.any?(Prism::DefNode)
7855
8476
 
7856
8477
  receiver = node.receiver
7857
- next unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
7858
- next unless yield(receiver.depth, scope_nesting(ancestors))
8478
+ case receiver
8479
+ when Prism::LocalVariableReadNode
8480
+ next unless receiver.name == name && yield(receiver.depth, scope_nesting(ancestors))
8481
+ when Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
8482
+ next unless receiver.name == name && capturable_non_local?(name, ancestors, scope)
8483
+ else
8484
+ next
8485
+ end
7859
8486
 
7860
8487
  seed = MutationWidening.widen_for_mutator(seed, node.name) || seed
7861
8488
  end
@@ -7868,11 +8495,11 @@ module Rigor
7868
8495
  # write.
7869
8496
  def join_memo_content(call_node, memo_param, calls, pre_state, shadows)
7870
8497
  block = call_node.block
7871
- captured = captured_content_mutations(block, shadows)
7872
- seeds = captured.keys.to_h { |name| [name, scope.local(name)] }
8498
+ captured = captured_content_mutations(block, shadows, call_node)
8499
+ seeds = captured.keys.to_h { |name| [name, CapturedLocals.bound_type(scope, name)] }
7873
8500
  seeds[memo_param] = pre_state
7874
8501
  sites = captured.merge(memo_param => calls)
7875
- shadow_rebound_reads(block, sites, seeds, shadows)
8502
+ shadow_rebound_reads(block, sites, seeds, shadows, call_node)
7876
8503
  join_content_to_fixpoint(sites, seeds, build_block_entry_scope(call_node, block), shadows)[memo_param]
7877
8504
  end
7878
8505
 
@@ -8429,7 +9056,8 @@ module Rigor
8429
9056
  # ({#record_closure_escape_if_any}) and a lambda literal ({#eval_lambda}) — so they cannot drift.
8430
9057
  def escaping_closure_captures(closure_node, post_scope, foreign_self: false)
8431
9058
  widen_escaping_content_captures(closure_node,
8432
- drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self))
9059
+ drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self),
9060
+ foreign_self: foreign_self)
8433
9061
  end
8434
9062
 
8435
9063
  # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`
@@ -8439,6 +9067,30 @@ module Rigor
8439
9067
  InterveningIvarReset.foreign_self_block?(call_node, operand_scope)
8440
9068
  end
8441
9069
 
9070
+ # The scope a block body's `self` marks start from: the statement form's opaque `self` (issue #316), or the
9071
+ # walk's unknown-`self` mark alone (issue #1717) for the value-position block {#evaluate_block_of} enters and for
9072
+ # every block entered beneath it. A scope carrying the unknown-`self` mark without the opaque one comes only from
9073
+ # the index walk ({Scope#with_block_self_unknown}), so it is how a nested block knows it sits under one: the
9074
+ # opaque mark, which declines a cross-file top-level `def`, stays off the whole value-position subtree.
9075
+ #
9076
+ # Issue #2861 — a block whose call provably runs it under the caller's `self` ({SelfPreservingBlock}:
9077
+ # `rows.map { … }`) enters with no opaque mark the caller's scope does not already carry
9078
+ # ({Scope#entering_self_preserving_block}).
9079
+ def block_self_entry(block_node, call_node = nil)
9080
+ walked = block_node.equal?(@value_position_block) || scope.walked_block_self_unknown?
9081
+ return scope.with_block_self_unknown(true) if walked
9082
+ return scope.entering_self_preserving_block if self_preserving_block?(call_node)
9083
+
9084
+ scope.entering_opaque_block
9085
+ end
9086
+
9087
+ def self_preserving_block?(call_node)
9088
+ return false if call_node.nil? || call_node.receiver.nil?
9089
+
9090
+ SelfPreservingBlock.call?(call_node, explicit_receiver_type(call_node), scope,
9091
+ classification: classify_closure_escape(call_node))
9092
+ end
9093
+
8442
9094
  # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
8443
9095
  # and adds bindings for every named block parameter on top. Parameter types come from the receiving method's RBS
8444
9096
  # signature when one is available; the rest default to `Dynamic[Top]`.
@@ -8453,7 +9105,8 @@ module Rigor
8453
9105
  # yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
8454
9106
  # Issue #1358 — a body that may run a match reads the match globals an earlier iteration may have rebound
8455
9107
  # ({MatchRebinding.block_entry}).
8456
- entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
9108
+ entry = MatchRebinding.block_entry(block_self_entry(block_node, call_node), block_node, call_node)
9109
+ entry = entry.entering_block_without_gradual_marks(block_node)
8457
9110
  scope_with_params = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
8458
9111
  # ADR-16 Tier A — a plugin `block_as_methods:` entry that matches `(receiver, name)` narrows the
8459
9112
  # body's `self` to the object the DSL `instance_eval`s the block on (`params` on
@@ -8589,7 +9242,8 @@ module Rigor
8589
9242
  # def's contract; it binds its parameters as an undeclared method does.
8590
9243
  class_path: @class_context.last&.refinement ? nil : current_class_path,
8591
9244
  singleton: singleton,
8592
- source_path: scope.source_path
9245
+ source_path: scope.source_path,
9246
+ scope: scope
8593
9247
  )
8594
9248
  bindings = binder.bind(def_node)
8595
9249
  # ADR-67 WD3 — override an undeclared parameter with its call-site inferred type (precision-additive; an
@@ -8674,7 +9328,7 @@ module Rigor
8674
9328
  # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an undeclared parameter — the only
8675
9329
  # bindings ADR-67 WD3 overrides.
8676
9330
  def untyped_binding?(type)
8677
- type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
9331
+ Type::Combinator.untyped?(type)
8678
9332
  end
8679
9333
 
8680
9334
  def seed_instance_ivars(body_scope, singleton:)
@@ -8886,8 +9540,13 @@ module Rigor
8886
9540
  # nested body, so this handler only ever appends a return that genuinely exits the method currently being
8887
9541
  # inferred.
8888
9542
  def eval_return(node)
8889
- sink = Thread.current[RETURN_SINK_KEY]
8890
- sink&.record(node, jump_value_type(node))
9543
+ state = typing_state
9544
+ sink = state[RETURN_SINK]
9545
+ if sink
9546
+ type = jump_value_type(node)
9547
+ state.touch(RETURN_SINK)
9548
+ sink.record(node, type, state[PROVISIONAL_RETURNS])
9549
+ end
8891
9550
  [Type::Combinator.bot, scope]
8892
9551
  end
8893
9552
 
@@ -8901,8 +9560,13 @@ module Rigor
8901
9560
  # dead arm), so the join is flow-sensitive for free: `[1, 2].map { |x| next nil if x.nil?; x.to_s }` keeps its
8902
9561
  # exact per-element answer.
8903
9562
  def eval_next(node)
8904
- sink = Thread.current[NEXT_SINK_KEY]
8905
- sink << [node, jump_value_type(node)] if sink
9563
+ state = typing_state
9564
+ sink = state[NEXT_SINK]
9565
+ if sink
9566
+ type = jump_value_type(node)
9567
+ state.touch(NEXT_SINK)
9568
+ sink << [node, type]
9569
+ end
8906
9570
  @next_scope_sink << [node, jump_scope(node)] if @next_scope_sink
8907
9571
  [Type::Combinator.bot, scope]
8908
9572
  end
@@ -8928,10 +9592,19 @@ module Rigor
8928
9592
  # break-value sink for `ExpressionTyper#call_dispatch_type_for` to union in. Both sinks are optional and
8929
9593
  # independent — a loop body collects scopes while an enclosing call collects values from the same walk.
8930
9594
  def eval_break(node)
8931
- sink = Thread.current[BREAK_SINK_KEY]
8932
- sink << [node, jump_scope(node)] if sink
8933
- value_sink = Thread.current[BREAK_VALUE_SINK_KEY]
8934
- value_sink << [node, jump_value_type(node)] if value_sink
9595
+ state = typing_state
9596
+ sink = state[BREAK_SINK]
9597
+ if sink
9598
+ jump = jump_scope(node)
9599
+ state.touch(BREAK_SINK)
9600
+ sink << [node, jump]
9601
+ end
9602
+ value_sink = state[BREAK_VALUE_SINK]
9603
+ if value_sink
9604
+ type = jump_value_type(node)
9605
+ state.touch(BREAK_VALUE_SINK)
9606
+ value_sink << [node, type]
9607
+ end
8935
9608
  [Type::Combinator.bot, scope]
8936
9609
  end
8937
9610
 
@@ -9037,6 +9710,39 @@ module Rigor
9037
9710
  branch_type.is_a?(Type::Bot)
9038
9711
  end
9039
9712
 
9713
+ # Issue #1230 — a strict, syntactic-only exit test for the then-exits-beside-an-else arm ({#if_join}). The
9714
+ # branch's last statement is a `return` / `next` / `break`; a receiver-less `raise` / `fail` /
9715
+ # `exit` / `exit!` / `abort` / `throw` whose name no project file defines on any class or module, at the top level
9716
+ # or by `define_method` ({Scope#project_method_name?}); or an if / unless / case with an else whose every arm
9717
+ # certainly exits. A `redo` / `retry` does not: it re-runs a loop body or a `begin` that reads the branch's
9718
+ # writes. Unlike {#branch_terminates?} it reads no type, so a `bot`-typed helper call does not qualify:
9719
+ # the typer types a call to an overridable or Kernel-shadowing project method `bot` (issue #3110).
9720
+ def branch_certainly_exits?(node)
9721
+ case node
9722
+ when Prism::ReturnNode, Prism::NextNode, Prism::BreakNode then true
9723
+ when Prism::CallNode then certain_exit_call?(node)
9724
+ when Prism::StatementsNode then branch_certainly_exits?(node.body.last)
9725
+ when Prism::ParenthesesNode then branch_certainly_exits?(node.body)
9726
+ when Prism::ElseNode then branch_certainly_exits?(node.statements)
9727
+ when Prism::IfNode, Prism::UnlessNode
9728
+ branch_certainly_exits?(node.statements) && branch_certainly_exits?(node_else_branch(node))
9729
+ when Prism::CaseNode, Prism::CaseMatchNode then case_certainly_exits?(node)
9730
+ else false
9731
+ end
9732
+ end
9733
+
9734
+ def certain_exit_call?(node)
9735
+ node.receiver.nil? && CERTAIN_EXIT_NAMES.include?(node.name) && !scope.project_method_name?(node.name)
9736
+ end
9737
+
9738
+ def case_certainly_exits?(node)
9739
+ !node.else_clause.nil? && branch_certainly_exits?(node.else_clause) &&
9740
+ node.conditions.all? { |arm| branch_certainly_exits?(arm.statements) }
9741
+ end
9742
+
9743
+ CERTAIN_EXIT_NAMES = %i[raise fail exit exit! abort throw].freeze
9744
+ private_constant :CERTAIN_EXIT_NAMES
9745
+
9040
9746
  def eval_branch_or_nil(branch_node, branch_scope, on_enter: @on_enter)
9041
9747
  return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
9042
9748
 
@@ -9089,6 +9795,8 @@ module Rigor
9089
9795
  scope.with_ivar(ref.name, exception_type).without_ivar_guard(ref.name)
9090
9796
  when Prism::GlobalVariableTargetNode
9091
9797
  scope.with_global(ref.name, exception_type)
9798
+ when Prism::ClassVariableTargetNode
9799
+ scope.with_cvar(ref.name, exception_type)
9092
9800
  when Prism::IndexTargetNode
9093
9801
  widen_index_target(ref, exception_type, scope, type_scope: scope)
9094
9802
  else
@@ -9096,20 +9804,11 @@ module Rigor
9096
9804
  end
9097
9805
  end
9098
9806
 
9099
- # Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
9100
- # type is `StandardError`. When one or more exception classes are named the types are unioned. A class that
9101
- # cannot be resolved to a `Singleton` type contributes `Dynamic[top]`.
9807
+ # Derives the exception instance type for a `RescueNode` ({ErrorInfo.rescue_exception_type}): `StandardError` for
9808
+ # a bare `rescue`, else the union of the named classes' instances, with `Dynamic[top]` for one that does not
9809
+ # resolve to a `Singleton`.
9102
9810
  def rescue_exception_type(rescue_node, scope)
9103
- exceptions = rescue_node.exceptions
9104
- if exceptions.empty?
9105
- Type::Combinator.nominal_of("StandardError")
9106
- else
9107
- types = exceptions.map do |exc_node|
9108
- singleton_type, = sub_eval(exc_node, scope)
9109
- singleton_to_nominal(singleton_type)
9110
- end
9111
- Type::Combinator.union(*types)
9112
- end
9811
+ ErrorInfo.rescue_exception_type(rescue_node) { |exception_node| sub_eval(exception_node, scope).first }
9113
9812
  end
9114
9813
 
9115
9814
  # --------------------------------------------------------------- `case/in` pattern variable binding helpers
@@ -9121,7 +9820,27 @@ module Rigor
9121
9820
  # `deconstruct` / `deconstruct_keys` dispatch reads for its freshness gate.
9122
9821
  def apply_in_pattern_bindings(subject_type, subject_node, pattern, scope)
9123
9822
  bindings = collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
9124
- bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
9823
+ bound = bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
9824
+ marked = subject_node.is_a?(Prism::LocalVariableReadNode) && scope.gradual_narrowing?(subject_node.name)
9825
+ mark_gradual_class_capture(subject_type, pattern, bound, marked: marked)
9826
+ end
9827
+
9828
+ # Issue #2656 — `in C => y` over a `Dynamic` subject binds `y` to `C`'s `Nominal`, as a class guard's edge binds
9829
+ # the subject, and marks it as that edge marks a local ({Narrowing.mark_gradual_local}). Only a capture of
9830
+ # the whole subject by a class or an alternation of classes is marked.
9831
+ def mark_gradual_class_capture(subject_type, pattern, scope, marked: false)
9832
+ return scope unless pattern.is_a?(Prism::CapturePatternNode) && class_capture_value?(pattern.value)
9833
+
9834
+ name = pattern.target.name
9835
+ Narrowing.mark_gradual_local(scope, name, subject_type, scope.local(name), marked: marked)
9836
+ end
9837
+
9838
+ def class_capture_value?(value)
9839
+ case value
9840
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then true
9841
+ when Prism::AlternationPatternNode then class_capture_value?(value.left) && class_capture_value?(value.right)
9842
+ else false
9843
+ end
9125
9844
  end
9126
9845
 
9127
9846
  # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`, typed against the
@@ -9501,7 +10220,8 @@ module Rigor
9501
10220
  floor = Type::Combinator.untyped
9502
10221
  walks.first.map do |name, _type|
9503
10222
  types = walks.map { |walk| walk.assoc(name)&.last }
9504
- [name, types.any? { |type| type.nil? || type == floor } ? floor : Type::Combinator.union(*types)]
10223
+ floored = types.any? { |type| type.nil? || Type::Combinator.untyped?(type) }
10224
+ [name, floored ? floor : Type::Combinator.union(*types)]
9505
10225
  end
9506
10226
  end
9507
10227