rigortype 0.3.9 → 0.4.0

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 (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -9,16 +9,26 @@ require_relative "../source/node_walker"
9
9
  require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
11
  require_relative "anonymous_meta_class"
12
+ require_relative "block_repetition"
12
13
  require_relative "block_parameter_binder"
13
14
  require_relative "body_fixpoint"
14
15
  require_relative "captured_locals"
15
16
  require_relative "dynamic_origin"
17
+ require_relative "error_info"
18
+ require_relative "jump_targets"
19
+ require_relative "last_status"
20
+ require_relative "../analysis/check_rules/declaration_sourced_guard"
16
21
  require_relative "../analysis/check_rules/inferred_param_guard"
17
22
  require_relative "../analysis/check_rules/published_constant_guard"
18
23
  require_relative "struct_fold_safety"
19
24
  require_relative "closure_escape_analyzer"
20
25
  require_relative "content_join"
26
+ require_relative "define_method_block_self"
27
+ require_relative "macro_block_self_type"
28
+ require_relative "guard_rebinding"
29
+ require_relative "match_rebinding"
21
30
  require_relative "element_read_widening"
31
+ require_relative "hash_lookup_mutation"
22
32
  require_relative "indexed_narrowing"
23
33
  require_relative "index_write_widening"
24
34
  require_relative "method_dispatcher"
@@ -26,7 +36,12 @@ require_relative "method_parameter_binder"
26
36
  require_relative "multi_target_binder"
27
37
  require_relative "mutation_widening"
28
38
  require_relative "narrowing"
39
+ require_relative "operand_effects"
40
+ require_relative "operand_walk"
29
41
  require_relative "optimistic_origin"
42
+ require_relative "return_barrier"
43
+ require_relative "rewrite_mutation"
44
+ require_relative "unknown_store_widening"
30
45
  require_relative "version_guard"
31
46
 
32
47
  module Rigor
@@ -84,9 +99,16 @@ module Rigor
84
99
  Prism::IndexOrWriteNode => :eval_index_or_write,
85
100
  Prism::IndexAndWriteNode => :eval_index_write,
86
101
  Prism::IndexOperatorWriteNode => :eval_index_write,
102
+ Prism::CallOrWriteNode => :eval_attribute_compound_write,
103
+ Prism::CallAndWriteNode => :eval_attribute_compound_write,
104
+ Prism::CallOperatorWriteNode => :eval_attribute_compound_write,
87
105
  Prism::MultiWriteNode => :eval_multi_write,
88
106
  Prism::ConstantWriteNode => :eval_constant_write,
89
107
  Prism::ConstantPathWriteNode => :eval_constant_write,
108
+ # Issue #963 — `Const ||= Struct.new(:a) do … end` opens the same class body. The handler's own result is
109
+ # the default expression pair, so routing the or-writes here adds the body entry and nothing else.
110
+ Prism::ConstantOrWriteNode => :eval_constant_write,
111
+ Prism::ConstantPathOrWriteNode => :eval_constant_write,
90
112
  Prism::IfNode => :eval_if,
91
113
  Prism::UnlessNode => :eval_unless,
92
114
  Prism::ElseNode => :eval_else,
@@ -94,7 +116,7 @@ module Rigor
94
116
  Prism::CaseMatchNode => :eval_case,
95
117
  Prism::WhenNode => :eval_when_or_in,
96
118
  Prism::InNode => :eval_when_or_in,
97
- Prism::BeginNode => :eval_begin,
119
+ Prism::BeginNode => :eval_begin_node,
98
120
  Prism::RescueNode => :eval_rescue,
99
121
  Prism::EnsureNode => :eval_ensure,
100
122
  Prism::WhileNode => :eval_loop,
@@ -113,10 +135,41 @@ module Rigor
113
135
  Prism::ReturnNode => :eval_return,
114
136
  Prism::NextNode => :eval_next,
115
137
  Prism::BreakNode => :eval_break,
116
- Prism::MatchWriteNode => :eval_match_write
138
+ Prism::MatchWriteNode => :eval_match_write,
139
+ Prism::MatchPredicateNode => :eval_match_pattern,
140
+ Prism::MatchRequiredNode => :eval_match_pattern,
141
+ Prism::RescueModifierNode => :eval_rescue_modifier,
142
+ Prism::ArrayNode => :eval_value_container,
143
+ Prism::HashNode => :eval_value_container,
144
+ Prism::InterpolatedStringNode => :eval_value_container,
145
+ Prism::InterpolatedSymbolNode => :eval_value_container,
146
+ Prism::XStringNode => :eval_xstring,
147
+ Prism::InterpolatedXStringNode => :eval_xstring,
148
+ Prism::RangeNode => :eval_value_container
117
149
  }.freeze
118
150
  private_constant :HANDLERS
119
151
 
152
+ # Issue #1223 — the expressions that evaluate every child, in child order, before producing their value, so
153
+ # a write inside one is threaded child by child ({#thread_operand}). A construct that may skip a child is
154
+ # left out: a `rescue` modifier has its own handler, and a regexp interpolation with the `o` flag runs its
155
+ # parts once per process.
156
+ OPERAND_CONTAINERS = Set[
157
+ Prism::ArgumentsNode, Prism::KeywordHashNode, Prism::AssocNode, Prism::AssocSplatNode, Prism::SplatNode,
158
+ Prism::BlockArgumentNode, Prism::ArrayNode, Prism::HashNode, Prism::InterpolatedStringNode,
159
+ Prism::InterpolatedSymbolNode, Prism::InterpolatedXStringNode, Prism::EmbeddedStatementsNode,
160
+ Prism::RangeNode
161
+ ].freeze
162
+ private_constant :OPERAND_CONTAINERS
163
+
164
+ # Statement sequences an operand may hold, threaded statement by statement like a container ({#thread_operand}).
165
+ OPERAND_SEQUENCES = Set[Prism::StatementsNode, Prism::ParenthesesNode].freeze
166
+ private_constant :OPERAND_SEQUENCES
167
+
168
+ # The keywords of a pass whose scopes the per-node scope index must not keep — neither the nodes it
169
+ # evaluates nor the later operands an {OperandWalk} inside it takes ({#walk_recorder}).
170
+ UNRECORDED = { on_enter: nil, operand_recorder: nil }.freeze
171
+ private_constant :UNRECORDED
172
+
120
173
  # Thread-local sink (an Array) collecting the value types of explicit `return value` nodes reached while
121
174
  # evaluating a method body, so `ExpressionTyper#infer_user_method_return` can join them into the method's inferred
122
175
  # return type. The flow value of a `return` is still `Bot` (it transfers control rather than producing a value);
@@ -141,9 +194,8 @@ module Rigor
141
194
  # evaluating a loop body, so `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true; break`) into
142
195
  # the loop continuation that the fall-through would otherwise drop. Stacks like the return sink: a nested loop
143
196
  # installs its own sink, restored on exit, so an inner loop's break does not leak to the outer one. A `break`
144
- # inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the
145
- # directly-targeting break set, see {#directly_targeting_breaks}. See
146
- # docs/notes/20260615-loop-break-binding-propagation-design.md.
197
+ # inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the loop's
198
+ # {JumpTargets} set ({#loop_jumps}). See docs/notes/20260615-loop-break-binding-propagation-design.md.
147
199
  BREAK_SINK_KEY = :rigor_break_sink
148
200
  private_constant :BREAK_SINK_KEY
149
201
 
@@ -162,8 +214,14 @@ module Rigor
162
214
 
163
215
  # Lexical class frame: the `name:` field is the qualified class name as it would render in Ruby (e.g.,
164
216
  # `"Foo::Bar"`); the `singleton:` field is `true` for `class << self` frames so nested defs resolve to
165
- # singleton-method RBS lookups.
166
- ClassFrame = Data.define(:name, :singleton)
217
+ # singleton-method RBS lookups. Issue #1120 — `refinement:` is `true` for the frame a `refine X do … end`
218
+ # block is entered under: its `def`s redefine X's methods, so none of them binds its parameters from X's
219
+ # RBS signature for the name ({#build_method_entry_scope}).
220
+ ClassFrame = Data.define(:name, :singleton, :refinement) do
221
+ def initialize(name:, singleton:, refinement: false)
222
+ super
223
+ end
224
+ end
167
225
 
168
226
  # Issue #652 — Ruby's `Module.nesting` for the body currently being evaluated, innermost first, built as
169
227
  # the walk ENTERS each declaration rather than reconstructed from the qualified name afterwards. A
@@ -193,14 +251,41 @@ module Rigor
193
251
  # assumption (`result *= i` annotating `1 | 2` rather than
194
252
  # `Integer`). Display-path only — `rigor check` leaves it off,
195
253
  # keeping its diagnostics and wall-clock unchanged.
196
- def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
197
- lexical_nesting: EMPTY_NESTING, converged_loop_recording: false)
254
+ # @param next_scope_sink — the Array of `[NextNode, Scope]` pairs
255
+ # the innermost enclosing block invocation or loop body collects
256
+ # its `next` exits into ({#evaluate_invocation},
257
+ # {#loop_iteration}), or nil. The scope twin of the thread-local
258
+ # `next` VALUE sink, threaded through `sub_eval` instead so an
259
+ # evaluation `ExpressionTyper` starts elsewhere — the block-return
260
+ # pass, a recursive method's inference — can never feed it a
261
+ # `next` from another context. A `->` body's `next` still lands
262
+ # here; the consumer filters by node identity.
263
+ # @param operand_scope — the scope a call's receiver and arguments
264
+ # were typed under, when {#eval_call} runs the rest of that call
265
+ # from the scope its operands left ({#invoke_call}); nil otherwise,
266
+ # where it is the receiver scope itself ({#operand_scope}).
267
+ # @param in_operand — true for an evaluator {#thread_operand} opened, and every evaluator it opens: the
268
+ # calls it runs are inside another expression's operand, which {#invoke_call} leaves the resets of a
269
+ # statement-position call out of; the statement that holds the operand answers for its match globals.
270
+ # @param operand_recorder — the per-node scope index's recorder, for an evaluator {#thread_operand} opened
271
+ # (whose own `on_enter` is nil) and every evaluator it opens but an unrecorded pass ({UNRECORDED}), so an
272
+ # {OperandWalk} rooted inside an operand still records its later operands ({#walk_recorder}).
273
+ # @param operand_types — the later operands' own values ({OperandWalk#types}) for the evaluator that runs a
274
+ # call from the scope its operands left, read by {#type_operand}.
275
+ def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze, # rubocop:disable Metrics/ParameterLists
276
+ lexical_nesting: EMPTY_NESTING, converged_loop_recording: false, next_scope_sink: nil,
277
+ operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil)
198
278
  @scope = scope
199
279
  @tracer = tracer
200
280
  @on_enter = on_enter
201
281
  @class_context = class_context.freeze
202
282
  @lexical_nesting = lexical_nesting.freeze
203
283
  @converged_loop_recording = converged_loop_recording
284
+ @next_scope_sink = next_scope_sink
285
+ @operand_scope = operand_scope
286
+ @in_operand = in_operand
287
+ @operand_recorder = operand_recorder
288
+ @operand_types = operand_types
204
289
  end
205
290
 
206
291
  # Runs `block` with a fresh return sink installed, then yields the collected explicit-`return` value types to the
@@ -255,14 +340,77 @@ module Rigor
255
340
  # Evaluate `node` under the receiver scope. Returns `[type, scope']` where `type` is the value the node produces
256
341
  # and `scope'` is the scope observable after the node has run. The receiver scope is never mutated.
257
342
  def evaluate(node)
343
+ return evaluator_at(@scope.forget_last_line).evaluate(node) if forget_last_line_first?(node)
344
+
258
345
  @on_enter&.call(node, @scope)
259
346
 
260
347
  handler = HANDLERS[node.class]
261
- return send(handler, node) if handler
348
+ return forget_implicit_call_guards(node, send(handler, node)) if handler
262
349
 
263
350
  # Default: the node is treated as a pure expression. Type it through the existing expression typer (which
264
- # observes the current scope's locals) and leave the scope unchanged.
265
- [@scope.type_of(node, tracer: @tracer), @scope]
351
+ # observes the current scope's locals) and leave the scope unchanged, but for the match globals a call in it
352
+ # may rebind (`super(line.sub(re, ""))`, issue #1365).
353
+ [@scope.type_of(node, tracer: @tracer), forget_rebound_specials(@scope, node)]
354
+ end
355
+
356
+ # Issue #1429 — a compound write or a `for` loop calls a method its syntax does not spell (`r += r` calls `r.+`,
357
+ # `r[0] ||= 1` calls `r.[]` and `r.[]=`, `for x in r` calls `r.each`). When that method may run code that rebinds
358
+ # a global or constant, the guard narrowings past the node are restored
359
+ # ({GuardRebinding.implicit_call_may_rebind?}).
360
+ def forget_implicit_call_guards(node, result)
361
+ type, after = result
362
+ return result unless after.is_a?(Scope) && after.guard_narrowed? &&
363
+ GuardRebinding.implicit_call_node?(node) &&
364
+ GuardRebinding.implicit_call_may_rebind?(node, @scope)
365
+
366
+ [type, after.forget_guard_narrowings]
367
+ end
368
+
369
+ # Issue #1359 — the nodes whose handler runs their parts in order, each from the scope the parts before it
370
+ # left, so a `$_` reader among them forgets it where it runs ({LastLine}) and a read before it keeps the
371
+ # narrowing.
372
+ SEQUENCED_NODES = Set[
373
+ Prism::ProgramNode, Prism::StatementsNode, Prism::ParenthesesNode, Prism::IfNode, Prism::UnlessNode,
374
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::CaseNode, Prism::CaseMatchNode, Prism::BeginNode,
375
+ Prism::AndNode, Prism::OrNode, Prism::ElseNode, Prism::WhenNode, Prism::InNode, Prism::RescueNode,
376
+ Prism::EnsureNode, Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
377
+ Prism::BlockNode, Prism::LambdaNode
378
+ ].freeze
379
+ private_constant :SEQUENCED_NODES
380
+
381
+ # True when `node` may set `$_` while `$_` is narrowed, and its handler types parts of it from the scope it
382
+ # starts in rather than threading them: a call's receiver and arguments, a literal's elements, a value a write
383
+ # stores. A read of `$_` there may run after the reader (`bar(gets, $_)`, `[gets, $_]`, `gets.to_s + $_`), so
384
+ # the whole node is evaluated with `$_` forgotten, which costs only a read that runs before the reader.
385
+ def forget_last_line_first?(node)
386
+ @scope.last_line_bound? && !SEQUENCED_NODES.include?(node.class) && LastLine.may_set?(node, @scope)
387
+ end
388
+
389
+ # One invocation of `block_node`'s body, from the receiver scope (which the caller has already bound the block's
390
+ # parameters onto). Returns `[type, fall_through, exit]`: the body's tail type, the scope it falls off the end
391
+ # with, and the scope the invocation ends with.
392
+ #
393
+ # A `next` ends the invocation as surely as falling off the end does, so `exit` is `fall_through` joined
394
+ # (`Scope#join`) with the scope at every `next` that targets this block ({JumpTargets}). Without that join a
395
+ # rebind on a jumping branch (`if e.odd?; n = e; next; end`) vanished — `eval_if` carries only the arm that falls
396
+ # through — and both readers of the exit scope, ADR-56's write-back fixpoint ({#block_exit_bindings}) and issue
397
+ # #587 (b)'s per-element fold (`ExpressionTyper#captured_exit_bindings`), kept the pre-call binding. A `break` is
398
+ # NOT joined: it ends the call, so its scope feeds no further invocation ({#join_block_break_bindings}).
399
+ # `fall_through` is exposed for the fold's unmoved-pin test, which must not count what a `next` arm adds.
400
+ #
401
+ # A body with no block-level `next` pays one allocation-free scan and nothing else.
402
+ def evaluate_invocation(block_node)
403
+ body = block_node.body
404
+ return [Type::Combinator.constant_of(nil), scope, scope] if body.nil?
405
+
406
+ unless JumpTargets.any?(body, Prism::NextNode)
407
+ type, fall_through = sub_eval(body, scope, next_scope_sink: nil)
408
+ return [type, fall_through, fall_through]
409
+ end
410
+
411
+ sink = []
412
+ type, fall_through = sub_eval(body, scope, next_scope_sink: sink)
413
+ [type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
266
414
  end
267
415
 
268
416
  # ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
@@ -275,6 +423,62 @@ module Rigor
275
423
  callee_content_mutated_parameters(def_node).values.uniq.sort
276
424
  end
277
425
 
426
+ # The local-variable reads among `call_node`'s positional arguments whose matching parameter the callee
427
+ # content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's scope
428
+ # (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
429
+ # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
430
+ # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
431
+ def content_mutated_arguments(call_node)
432
+ return NO_ARGUMENT_READS unless self_dispatch_call?(call_node)
433
+ # Fast path — only a local passed as an argument can be reported, so a call with none skips the def
434
+ # resolution and the body scan entirely (the overwhelming common case).
435
+ return NO_ARGUMENT_READS unless call_passes_local_argument?(call_node)
436
+
437
+ def_node = resolve_self_callee_def(call_node)
438
+ return NO_ARGUMENT_READS if def_node.nil?
439
+
440
+ mutated = callee_content_mutated_parameters(def_node)
441
+ return NO_ARGUMENT_READS if mutated.empty?
442
+
443
+ argument_nodes = call_node.arguments.arguments
444
+ mutated.values.uniq.filter_map do |index|
445
+ argument = argument_nodes[index]
446
+ argument if argument.is_a?(Prism::LocalVariableReadNode)
447
+ end
448
+ end
449
+
450
+ NO_ARGUMENT_READS = [].freeze
451
+ private_constant :NO_ARGUMENT_READS
452
+
453
+ # The value `h[k] += v` / `h[k] ||= v` / `h[k] &&= v` evaluates to in this evaluator's scope: what it stores
454
+ # through `[]=` ({#index_write_stored_type}). The `[]=` widening and the indexed-narrowing record are scope
455
+ # effects, so they stay with {#eval_index_or_write} / {#eval_index_write}. `ExpressionTyper` types a
456
+ # value-position index compound write from here.
457
+ #
458
+ # One reading departs from the statement's: a `||=` whose `[]` read is wholly gradual (`Dynamic`, not a
459
+ # union with a `Dynamic` member) reads as the rvalue. That is the memoization idiom — `CACHE[key] ||=
460
+ # build(key)`, `(@memo ||= {})[[a, b]] ||= compute`, `@targets[name] ||= new(name)` on an ivar the method
461
+ # never writes — where the value the idiom returns is the one it stores, and `Dynamic[top] | rhs` sent
462
+ # every such method to `sig.skipped.untyped-return`. It is the variable form's optimism for an unbound `||=`
463
+ # target (`ExpressionTyper#type_of_compound_variable_write`) keyed on the slot, and no wider: `&&=` is no
464
+ # memo (`h[k] &&= v` on an absent slot is `nil`), an operator write has no such reading, and an rvalue
465
+ # with no truthy part stores nothing truthy, so the slot's own value is the answer whenever it is set:
466
+ # `opts[k] ||= raise KeyError` is a guard, never `bot`, and `@flags[n] ||= false` is `true` after an
467
+ # `@flags[n] = true` elsewhere, never provably `false`.
468
+ #
469
+ # Nor is a site a block-return pass marked (`Scope#repeated_or_write?`, {RepeatedOrWrites}): the pass types
470
+ # every run of a repeating body from one entry scope, so its slot's gradual type may be what an EARLIER run
471
+ # stored rather than an absence of evidence.
472
+ def index_compound_write_value(node)
473
+ return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
474
+
475
+ current = index_read_type(node, scope)
476
+ rhs = scope.type_of(node.value, tracer: tracer)
477
+ return rhs if memoizing_index_read?(node, current, rhs)
478
+
479
+ index_write_stored_type(node, scope, current: current, rhs: rhs)
480
+ end
481
+
278
482
  private
279
483
 
280
484
  attr_reader :scope, :tracer
@@ -282,11 +486,16 @@ module Rigor
282
486
  # Thread the scope through every child statement in declaration order. The body's value is the type of the last
283
487
  # statement (or `Constant[nil]` for an empty body); intermediate statements' types are discarded, but their scope
284
488
  # effects are preserved.
489
+ #
490
+ # Inside a retrying `begin`'s primary body, each statement's post-scope is also a point the body can raise from
491
+ # ({#record_raise_points}).
285
492
  def eval_statements(node)
286
493
  result_type = Type::Combinator.constant_of(nil)
287
494
  current = scope
495
+ raising = Thread.current[RETRY_FRAMES_KEY]
288
496
  node.body.each do |stmt|
289
497
  result_type, current = sub_eval(stmt, current)
498
+ record_raise_points(raising, stmt, current) if raising
290
499
  end
291
500
  [result_type, current]
292
501
  end
@@ -321,8 +530,8 @@ module Rigor
321
530
  # computed on the RHS *value*'s provenance — a pure ivar read of a currently declaration-sourced ivar — so it
322
531
  # survives the local copy. Any other RHS (a call result, a method-local-nil-bearing value) leaves the local
323
532
  # flow-live and the diagnostic fires as before.
324
- return post_rhs.with_declaration_sourced_local(node.name, rhs_type) if
325
- declaration_sourced_ivar_read?(node.value, post_rhs)
533
+ copied = declaration_sourced_copy(node, rhs_type, post_rhs)
534
+ return copied if copied
326
535
 
327
536
  bound = post_rhs.with_local(node.name, rhs_type)
328
537
  # ADR-67 WD6b — a local whose RHS is (transitively) rooted at an inferred parameter inherits the
@@ -335,7 +544,10 @@ module Rigor
335
544
 
336
545
  bound = bound.without_inferred_param_mark(node.name)
337
546
  bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
338
- bound.with_optimistic_local(node.name, optimistic_rhs_origin(node.value, post_rhs))
547
+ cause = optimistic_rhs_origin(node.value, post_rhs)
548
+ return bound if cause.nil?
549
+
550
+ bound.with_optimistic_local(node.name, cause, miss: optimistic_rhs_miss(node.value, post_rhs))
339
551
  end
340
552
 
341
553
  # Issue #667 — true when this write copies a value whose constancy rests on a foreign published
@@ -358,6 +570,13 @@ module Rigor
358
570
  optimistic_origin_for(value_node, scope_after_rhs)
359
571
  end
360
572
 
573
+ # Issue #1302 — what the value a marked write binds answers on a miss, recorded beside the mark so a predicate
574
+ # read through the binding widens only as far as the inline form does: `x = recv&.empty?; !x` keeps its
575
+ # `true`, while `v = h[k]; v.nil?` still widens. Asked only once {#optimistic_rhs_origin} found a mark.
576
+ def optimistic_rhs_miss(value_node, scope_after_rhs)
577
+ Inference::OptimisticOrigin.miss_answer(value_node, scope_after_rhs)
578
+ end
579
+
361
580
  # The effective optimistic-nil-free cause of an expression. {Inference::OptimisticOrigin.resolve} owns
362
581
  # the judgment — the mark on the node itself, the binding a bare local / ivar read resolves through, and
363
582
  # the predicate-fold derivation of issue #313.
@@ -375,6 +594,22 @@ module Rigor
375
594
  scope_after_rhs.dynamic_origins[value_node]
376
595
  end
377
596
 
597
+ # The scope binding the written local with ADR-58's `:local` mark when the write copies a declaration-sourced
598
+ # value, or nil. Issue #1362 — a read of a global still on its declared seed counts as an ivar read does, bare
599
+ # or parenthesised (`sep = $/`, `sep = ($/)`), and so does a bare read of a local that copies one (`s = sep`);
600
+ # the local also records the global it copies, which the consumers compare against the file's own writes to it
601
+ # ({Analysis::CheckRules::DeclarationSourcedGuard.copied_globals}).
602
+ def declaration_sourced_copy(node, rhs_type, post_rhs)
603
+ value = node.value
604
+ return post_rhs.with_declaration_sourced_local(node.name, rhs_type) if
605
+ declaration_sourced_ivar_read?(value, post_rhs)
606
+
607
+ globals = Analysis::CheckRules::DeclarationSourcedGuard.copied_globals(value, post_rhs)
608
+ return nil if globals.empty?
609
+
610
+ post_rhs.with_declaration_sourced_local(node.name, rhs_type).with_global_copy_marks(node.name, globals)
611
+ end
612
+
378
613
  # True when `value_node` is a bare instance-variable read whose binding in `scope_at_read` is currently marked
379
614
  # declaration-sourced.
380
615
  def declaration_sourced_ivar_read?(value_node, scope_at_read)
@@ -392,7 +627,8 @@ module Rigor
392
627
  rhs_type, post_rhs = sub_eval(node.value, scope)
393
628
  bound = post_rhs.with_ivar(node.name, rhs_type)
394
629
  bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
395
- bound = bound.with_optimistic_ivar(node.name, optimistic_rhs_origin(node.value, post_rhs))
630
+ cause = optimistic_rhs_origin(node.value, post_rhs)
631
+ bound = bound.with_optimistic_ivar(node.name, cause, miss: optimistic_rhs_miss(node.value, post_rhs)) if cause
396
632
  # Issue #667 — the ivar twin of the local stamp. This is the SAME-method half; the cross-method one
397
633
  # (`@mode = AppConfig::MODE` in `initialize`, read in a sibling) rides the class-ivar census and is
398
634
  # stamped by {#seed_instance_ivars}.
@@ -423,15 +659,38 @@ module Rigor
423
659
  # `attr_reader`, the very macro #319 silenced at every other position); inside a module, the module's own
424
660
  # `self` — a wrong receiver for every implicit-self call in the body.
425
661
  def eval_constant_write(node)
426
- result = [scope.type_of(node, tracer: tracer), scope]
427
- context = meta_new_constant_body_context(node)
662
+ after = forget_constant_guard(forget_rebound_specials(scope, node.value), node)
663
+ result = [scope.type_of(node, tracer: tracer), after]
664
+ call_node = meta_new_block_call(node)
665
+ return result if call_node.nil?
666
+
667
+ context = meta_new_constant_body_context(node, call_node)
428
668
  return result if context.nil?
429
669
 
430
- call_node = node.value
431
670
  enter_meta_class_body(call_node.block, build_block_entry_scope(call_node, call_node.block), context)
432
671
  result
433
672
  end
434
673
 
674
+ # Issue #1429 — a write to a constant ends a guard's narrowing of every spelling that may name it: `Foo::BAR =
675
+ # nil` inside `module Foo` writes the constant `BAR` reads, so each narrowing whose last segment is the written
676
+ # name is dropped, whatever its prefix.
677
+ def forget_constant_guard(after, node)
678
+ return after if after.constant_narrowings.empty?
679
+
680
+ target = node.respond_to?(:target) ? node.target : node
681
+ after.without_constant_narrowings_named(target.name.to_s)
682
+ end
683
+
684
+ # The rvalue call whose block is the class body, for every spelling of the write. Issue #963: the `.freeze`
685
+ # tail and the `||=` / `Const = Const || …` guard are unwrapped by {ScopeIndexer.meta_new_rvalue}, the same
686
+ # recognition the index walks under, so the two passes enter the same node or neither does. The recognition
687
+ # is still the loose one — a CallNode carrying a literal block — because the strict-argument shapes the
688
+ # index declines are entered under an anonymous name rather than dropped.
689
+ def meta_new_block_call(node)
690
+ rvalue = ScopeIndexer.meta_new_rvalue(node)
691
+ rvalue if rvalue.is_a?(Prism::CallNode) && rvalue.block.is_a?(Prism::BlockNode)
692
+ end
693
+
435
694
  # The class context a meta-new rvalue block is entered under, or nil when the rvalue is not that shape. The
436
695
  # KEY must be the one `ScopeIndexer` filed the body's defs and member layout under, so the two passes agree —
437
696
  # which is why the decision is delegated to the ScopeIndexer's own recognition rather than re-spelled here. A
@@ -440,10 +699,7 @@ module Rigor
440
699
  # — the path spelling included since [#703](https://github.com/rigortype/rigor/issues/703). A shape the
441
700
  # ScopeIndexer's stricter argument check rejects (`Const = Struct.new(*names) do … end`) is registered under
442
701
  # the call site's anonymous name and is entered under that.
443
- def meta_new_constant_body_context(node)
444
- call_node = node.value
445
- return nil unless call_node.is_a?(Prism::CallNode) && call_node.block.is_a?(Prism::BlockNode)
446
-
702
+ def meta_new_constant_body_context(node, call_node)
447
703
  constant = meta_new_constant_context(node)
448
704
  return constant if constant
449
705
 
@@ -459,9 +715,9 @@ module Rigor
459
715
  return nil unless ScopeIndexer.meta_new_block_body(node)
460
716
 
461
717
  case node
462
- when Prism::ConstantWriteNode
718
+ when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode
463
719
  @class_context + [ClassFrame.new(name: node.name.to_s, singleton: false)]
464
- when Prism::ConstantPathWriteNode
720
+ when Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode
465
721
  frame = ClassFrame.new(name: Source::ConstantPath.qualified_name(node.target), singleton: false)
466
722
  Source::ConstantPath.rooted?(node.target) ? [frame] : @class_context + [frame]
467
723
  end
@@ -584,11 +840,15 @@ module Rigor
584
840
  # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
585
841
  # post-`||=` value, not the rvalue alone.
586
842
  def eval_index_or_write(node)
587
- rhs_type, post_rhs = sub_eval(node.value, scope)
588
- current_type = scope.type_of(node, tracer: tracer)
589
- result_type = Type::Combinator.union(Narrowing.narrow_truthy(current_type), rhs_type)
590
-
591
- key_node = first_index_argument(node)
843
+ _rhs_type, post_rhs = sub_eval(node.value, scope)
844
+ result_type = index_write_stored_type(node, scope)
845
+
846
+ # A narrowing is keyed on ONE literal slot — `a[k]` — but a multi-index `||=` reads and
847
+ # stores a splice REGION (`a[0, 1] ||= v`), so keying the result on the first index would
848
+ # record `a[0]`'s type as the region answer: `a[0, 1] ||= []` would claim `a[0]` non-nil
849
+ # where the store splices nothing and `a[0]` stays nil at runtime. Decline the record for
850
+ # any form but the single-index one.
851
+ key_node = single_index_argument(node)
592
852
  address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
593
853
  # Issue #544 — a receiver with an untracked (Dynamic / Top) constituent can hold a caller-supplied
594
854
  # slot value the `||=` keeps, so the recorded default would invent a fact; decline the record.
@@ -601,36 +861,106 @@ module Rigor
601
861
  arg_types: index_write_arg_types(node, result_type))
602
862
  post = post.with_indexed_narrowing(*address, result_type) if address
603
863
 
604
- [result_type, post]
864
+ [result_type, forget_rebound_specials(post, node)]
605
865
  end
606
866
 
607
867
  # `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
608
868
  # expression, scope untouched — and the receiver never widened. They store through `[]=` exactly as
609
- # `eval_index_or_write` does, so they take the same widening; the value itself is still typed by the expression
610
- # typer (`type_of_assignment_write`), which is what the default did.
869
+ # `eval_index_or_write` does, so they take the same widening; the stored value is the compound result —
870
+ # `falsey(h[k]) | v` for `&&=`, the dispatched `h[k] + v` for `+=` — not the rvalue alone.
611
871
  def eval_index_write(node)
612
- stored = scope.type_of(node, tracer: tracer)
613
- [stored,
614
- IndexWriteWidening.widen(node: node, current_scope: scope,
615
- arg_types: index_write_arg_types(node, stored))]
616
- end
617
-
618
- # `[key_type, stored_value_type]` for an index-write node, shaped exactly like a `[]=` call's
619
- # argument list so the widening seam can join it the same way (issue #560). The stored value is
620
- # the node's OWN expression type — for `t[0] += 5` that is the compound machinery's already-computed
621
- # `t[0] + 5`, which is the whole point: it is the value the mutation put in the slot, and the one
622
- # the retained element evidence provably no longer covers. Returns `[]` when the key is unresolvable,
623
- # which reproduces the pre-join widening.
872
+ _rhs_type, post_rhs = sub_eval(node.value, scope)
873
+ stored = index_write_stored_type(node, scope)
874
+ widened = IndexWriteWidening.widen(node: node, current_scope: post_rhs,
875
+ arg_types: index_write_arg_types(node, stored))
876
+ [stored, forget_rebound_specials(widened, node)]
877
+ end
878
+
879
+ # `[index_type..., stored_value_type]` for an index-write node, shaped exactly like a `[]=`
880
+ # call's argument list so the widening seam can join it the same way (issue #560) — a
881
+ # two-index compound write (`a[0, 1] += v`) keeps BOTH index arguments ahead of the stored
882
+ # value, which is what lets the join read it as a splice (issue #1140). The stored value is
883
+ # what the write put in the slot — for a compound write {#index_write_stored_type}'s
884
+ # compound result (`t[0] += 5` stores the already-computed `t[0] + 5`), for an index target
885
+ # the value its owner stores (the slot {MultiTargetBinder} decomposed, the `for` element, the
886
+ # rescued exception) — which is the whole point: it
887
+ # is the value the retained element evidence provably no longer covers. Returns `[]` when the
888
+ # key is unresolvable, which reproduces the pre-join widening.
889
+ # The index arguments are typed, and the receiver's joinability read, in `type_scope`: the
890
+ # evaluator's entry scope by default; a `for` index passes its post-collection scope and a
891
+ # rescue reference its arm's entry scope, the nearest the engine has to where Ruby evaluates
892
+ # them (each iteration, the moment of the catch).
624
893
  # There is deliberately NO `rescue` here. `Scope#type_of` is a total query over well-formed Prism input,
625
894
  # so a raise is an engine bug, and swallowing it would silently downgrade a live seam to "no evidence" —
626
895
  # the join would quietly stop happening with nothing to show for it. Let it reach the runner's
627
896
  # internal-error path, where it is visible.
628
- def index_write_arg_types(node, stored_type)
629
- key_node = first_index_argument(node)
630
- return MutationWidening::NO_ARG_TYPES if key_node.nil? || stored_type.nil?
631
- return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, scope)
897
+ def index_write_arg_types(node, stored_type, type_scope: scope)
898
+ args = node.arguments
899
+ return MutationWidening::NO_ARG_TYPES if args.nil? || stored_type.nil?
900
+ return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, type_scope)
901
+
902
+ list = args.respond_to?(:arguments) ? args.arguments : args
903
+ # A splat argument is marked `nil` — its expansion decides the store's arity at
904
+ # runtime, which an untyped index type could not express (issue #1140).
905
+ list.map { |arg| arg.is_a?(Prism::SplatNode) ? nil : type_scope.type_of(arg, tracer: tracer) } + [stored_type]
906
+ end
907
+
908
+ # What a compound index write stores through `[]=` — `a[i] ||= v` stores `truthy(a[i]) | v`,
909
+ # `a[i] &&= v` stores `falsey(a[i]) | v`, and `a[i] op= v` stores the dispatched `a[i] op v`:
910
+ # `a[0, 1] += [2]` reads `a[0, 1] + [2]`, not `[2]` (issue #1140). It is also the node's value
911
+ # outside {#index_compound_write_value}'s memoizing `||=`. That method passes the `current` read
912
+ # and the `rhs` it already typed, so a nested `(a[i] ||= {})[j] ||= v` chain types each level's
913
+ # receiver once rather than doubling per level. Any other node falls back to its own type (an
914
+ # index target — a multi-assign slot, a `for` index, a rescue reference — keeps its untyped
915
+ # answer).
916
+ def index_write_stored_type(node, type_scope, current: nil, rhs: nil)
917
+ case node
918
+ when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode
919
+ current ||= index_read_type(node, type_scope)
920
+ narrowed = if node.is_a?(Prism::IndexOrWriteNode)
921
+ Narrowing.narrow_truthy(current)
922
+ else
923
+ Narrowing.narrow_falsey(current)
924
+ end
925
+ Type::Combinator.union(narrowed, rhs || type_scope.type_of(node.value, tracer: tracer))
926
+ when Prism::IndexOperatorWriteNode
927
+ MethodDispatcher.dispatch(
928
+ receiver_type: index_read_type(node, type_scope), method_name: node.binary_operator,
929
+ arg_types: [type_scope.type_of(node.value, tracer: tracer)],
930
+ environment: type_scope.environment
931
+ ) || Type::Combinator.untyped
932
+ else
933
+ type_scope.type_of(node, tracer: tracer)
934
+ end
935
+ end
936
+
937
+ # True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
938
+ # reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
939
+ def memoizing_index_read?(node, current, rhs)
940
+ current.is_a?(Type::Dynamic) && !Narrowing.narrow_truthy(rhs).is_a?(Type::Bot) &&
941
+ !scope.repeated_or_write?(node)
942
+ end
943
+
944
+ # The `receiver[i]` read a compound index write performs before storing — the `[]` read on the
945
+ # receiver's own type with the write's index arguments (a splat reads untyped), refined by a
946
+ # recorded indexed narrowing when the single-index form names a stable slot. The read takes the
947
+ # tiers a plain `receiver[i]` call does ({ExpressionTyper#implicit_index_read_type}), so a project
948
+ # `[]` with no signature answers from its body.
949
+ def index_read_type(node, read_scope)
950
+ receiver = read_scope.type_of(node.receiver, tracer: tracer)
951
+ args = node.arguments
952
+ list = args.respond_to?(:arguments) ? args.arguments : Array(args)
953
+ index_types = list.map do |arg|
954
+ arg.is_a?(Prism::SplatNode) ? Type::Combinator.untyped : read_scope.type_of(arg, tracer: tracer)
955
+ end
956
+
957
+ key = single_index_argument(node)
958
+ address = key && IndexedNarrowing.stable_address(node.receiver, key)
959
+ narrowed = address && read_scope.indexed_narrowing(*address)
960
+ return narrowed if narrowed
632
961
 
633
- [scope.type_of(key_node, tracer: tracer), stored_type]
962
+ typer = ExpressionTyper.new(scope: read_scope, tracer: tracer)
963
+ typer.implicit_index_read_type(node, receiver, index_types) || Type::Combinator.untyped
634
964
  end
635
965
 
636
966
  # Argument types for a straight-line content mutator (`arr << x`, `h[k] = v`).
@@ -659,22 +989,28 @@ module Rigor
659
989
  ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened, arg_types: arg_types)
660
990
  end
661
991
 
992
+ # `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
662
993
  def mutator_arg_types(call_node, current_scope)
663
- return MutationWidening::NO_ARG_TYPES unless ContentJoin::CONTENT_ADDERS.include?(call_node.name)
994
+ unless ContentJoin::CONTENT_ADDERS.include?(call_node.name) || StringMutation::TRANSLATORS.include?(call_node.name)
995
+ return MutationWidening::NO_ARG_TYPES
996
+ end
664
997
  unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
665
998
  ElementReadWidening.joinable_element_read?(call_node.receiver, current_scope)
666
999
  return MutationWidening::NO_ARG_TYPES
667
1000
  end
668
1001
 
669
- content_arg_types(call_node, scope)
1002
+ content_arg_types(call_node, operand_scope, @operand_types)
670
1003
  end
671
1004
 
672
- def first_index_argument(node)
1005
+ # The index node of an index-write when it holds exactly one index argument — the only form
1006
+ # whose stored value lands on a nameable slot (`a[k]`). Multi-index forms address a splice
1007
+ # region and answer `nil`.
1008
+ def single_index_argument(node)
673
1009
  args = node.arguments
674
1010
  return nil if args.nil?
675
1011
 
676
1012
  list = args.respond_to?(:arguments) ? args.arguments : args
677
- list.first
1013
+ list.size == 1 ? list.first : nil
678
1014
  end
679
1015
 
680
1016
  def dispatch_operator(current, rhs, operator)
@@ -690,13 +1026,94 @@ module Rigor
690
1026
  # `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring. Evaluates the right-hand side under the entry scope,
691
1027
  # then decomposes its type against the multi-write target tree (Prism::MultiWriteNode#lefts/rest/rights, including
692
1028
  # nested Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped right-hand sides produce per-slot types
693
- # element-wise; other carriers fall back to `Dynamic[Top]` per slot. The expression value is the right-hand side
694
- # type (matching Ruby's semantics: `(a, b = [1, 2])` evaluates to `[1, 2]`).
1029
+ # element-wise, an `Array[T]` binds each fixed slot to `T` with the optimistic-nil-free mark (issue #1093), a
1030
+ # union distributes over its members and a value with no implicit `to_ary` binds as `[rhs]` (issue #1094), and
1031
+ # other carriers fall back to `Dynamic[Top]` per slot. Instance-variable targets bind by the same rules, with the
1032
+ # optimistic mark recorded per ivar (issue #1110). A right-hand side that is itself optimistically nil-free
1033
+ # (`k, v = pairs.first`, or a local bound to one) marks every name it binds, and a literal one
1034
+ # (`x, y = pairs.first, 1`) marks each slot by its element: a miss binds `nil` to every such slot
1035
+ # ({Inference::OptimisticOrigin.destructuring_marks}). The expression value is the right-hand side type
1036
+ # (matching Ruby's semantics: `(a, b = [1, 2])` evaluates to `[1, 2]`).
1037
+ #
1038
+ # An index target (`h[:a], z = 1, 2`, nested or splatted too) stores its slot through `[]=`, so its receiver
1039
+ # widens here exactly as the plain store `h[:a] = 1` widens it, joining the slot's value as content evidence
1040
+ # (issue #560) — otherwise the literal survives and a later `h[:a] == 0` folds on its stale `0`. The widening
1041
+ # runs AFTER the bindings: Ruby evaluates a target's receiver before any target is assigned, so
1042
+ # `h, h[:a] = h, 1` stores into the object `h` is bound to afterwards, and widening first would let the
1043
+ # binding of `h` restore the literal. When a target rebinds the receiver's variable to another object
1044
+ # instead, widening that one only loses precision.
1045
+ #
1046
+ # The stored value is the slot the binder decomposed, softened as a local in the same position is. The
1047
+ # ADR-57 softening that drops a slot's `nil` is honest for a local because of the optimistic mark, which a
1048
+ # stored value never carries — but it does not need one here: the straight-line join always adds the
1049
+ # `Dynamic[top]` floor ({MutationWidening#gradual_floor}), so no fold can rest on the dropped `nil`. Joining
1050
+ # the `nil` instead would fire `call.possible-nil-receiver` on the correlated guard the softening exists for,
1051
+ # `r[:k], r[:v] = h.find { … }; r[:v].upcase if r[:k]`.
1052
+ #
1053
+ # Each store then drops the indexed narrowing it overwrites, through the same
1054
+ # {IndexedNarrowing.invalidate_indexed_write} a `[]=` call takes (it reads only `receiver` and `arguments`,
1055
+ # which an index target shares): the widening carries a Nominal receiver's slot narrowings across its
1056
+ # rebind, so `m[:a] ||= "d"; m[:a], y = 1, 2` would otherwise keep reading `"d"`.
695
1057
  def eval_multi_write(node)
696
1058
  rhs_type, post_rhs = sub_eval(node.value, scope)
697
- bindings = MultiTargetBinder.bind(node, rhs_type)
698
- post = bindings.reduce(post_rhs) { |acc, (name, type)| acc.with_local(name, type) }
699
- [rhs_type, post]
1059
+ marks = Inference::OptimisticOrigin.destructuring_marks(node.value, post_rhs)
1060
+ bound = MultiTargetBinder.bind_marked(node, rhs_type, optimistic: marks, scope: post_rhs)
1061
+ miss = marks == false ? nil : Inference::OptimisticOrigin.destructuring_miss(node.value, post_rhs)
1062
+ post = widen_index_targets(bound, bound.apply_to(post_rhs, miss: miss), type_scope: scope)
1063
+ [rhs_type, widen_attribute_targets(node, post)]
1064
+ end
1065
+
1066
+ # `recv.attr ||= v` / `&&=` / `op=` calls the writer `attr=` on `recv`, so a writer the mutation widening
1067
+ # responds to widens the receiver as the plain call does: `h.default ||= 0` reopens `h` as `h.default = 0`
1068
+ # does ({HashLookupMutation}). The node's value is typed as before; the widening is its only scope effect.
1069
+ def eval_attribute_compound_write(node)
1070
+ widened = widen_attribute_write(node.receiver, node.write_name, scope)
1071
+ [scope.type_of(node, tracer: tracer), forget_rebound_specials(widened, node)]
1072
+ end
1073
+
1074
+ # The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, and
1075
+ # the receiver-wide drop of recorded `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
1076
+ def widen_attribute_write(receiver, writer, current_scope)
1077
+ widened = MutationWidening.widen_receiver_aliases(receiver, writer, current_scope)
1078
+ stable = IndexedNarrowing.stable_receiver(receiver)
1079
+ return widened unless stable && IndexedNarrowing.mutator?(writer)
1080
+
1081
+ widened.without_indexed_narrowings_for(*stable)
1082
+ end
1083
+
1084
+ # The attribute targets of a multi-write (`h.default, x = 0, 1`), nested ones included, each widening its
1085
+ # receiver as the plain writer call would.
1086
+ def widen_attribute_targets(node, post)
1087
+ targets = [*node.lefts, node.rest, *node.rights]
1088
+ targets.reduce(post) do |acc, target|
1089
+ target = target.expression if target.is_a?(Prism::SplatNode)
1090
+ case target
1091
+ when Prism::CallTargetNode then widen_attribute_write(target.receiver, target.name, acc)
1092
+ when Prism::MultiTargetNode then widen_attribute_targets(target, acc)
1093
+ else acc
1094
+ end
1095
+ end
1096
+ end
1097
+
1098
+ # Widens the receiver of every index target a {MultiTargetBinder} result reports, over the scope its bindings
1099
+ # were applied to — the multi-write and the `for a, h[:k] in pairs` index share it.
1100
+ def widen_index_targets(bound, post, type_scope:)
1101
+ bound.index_targets.reduce(post) do |acc, (target, stored)|
1102
+ widen_index_target(target, stored, acc, type_scope: type_scope)
1103
+ end
1104
+ end
1105
+
1106
+ # An index target (`Prism::IndexTargetNode`) stores `stored` through `[]=` on its receiver wherever it
1107
+ # appears — a multi-assign slot, a `for` index, a rescue reference — so its receiver widens exactly as the
1108
+ # plain store `h[:a] = v` widens it, joining `stored` as content evidence (issue #560), and drops the
1109
+ # `h[:a] ||= default` narrowing on the slot it overwrote, as `eval_call` drops it after a `[]=` — the
1110
+ # widening carries slot narrowings across the rebind, so without the drop `h[:a]` keeps reading the default.
1111
+ # `type_scope` types the index arguments and gates the evidence (`joinable_receiver?`); `current_scope` is
1112
+ # the one widened.
1113
+ def widen_index_target(target, stored, current_scope, type_scope:)
1114
+ widened = IndexWriteWidening.widen(node: target, current_scope: current_scope,
1115
+ arg_types: index_write_arg_types(target, stored, type_scope: type_scope))
1116
+ IndexedNarrowing.invalidate_indexed_write(target, widened)
700
1117
  end
701
1118
 
702
1119
  # `if pred; t; (elsif/else)?` runs the predicate first (its post-scope is shared by both branches), then asks
@@ -706,26 +1123,23 @@ module Rigor
706
1123
  # phase 2 behaviour. The branches' result types are unioned; their post-scopes are joined with nil-injection on
707
1124
  # half-bound names so a name set in one branch but not the other is observable as `T | nil` after the if.
708
1125
  def eval_if(node)
709
- pred_type, post_pred = sub_eval(node.predicate, scope)
1126
+ pred_type, post_pred, truthy_scope, falsey_scope = eval_with_edges(node.predicate, scope)
710
1127
 
711
1128
  # When the predicate is a known-truthy / known-falsey type (notably `Constant[true]` / `Constant[false]` after
712
1129
  # the constant-fold tier), only the live branch contributes a type and a post-scope. The dead branch is skipped
713
1130
  # so the result type is precise (`Constant[:even]` instead of the joined `Constant[:even] | Constant[:odd]`).
714
- live = live_branch_for_if(node, pred_type, post_pred)
1131
+ live = live_branch_for_if(node, pred_type, post_pred, truthy_scope, falsey_scope)
715
1132
  if live
716
1133
  live_type, _live_scope = live
717
1134
  # When the provably-live then-branch terminates and there is no else, apply the same falsey-scope narrowing as
718
1135
  # the standard early-return path below. Without this, `return if @ivar.nil?` with an ivar seeded as
719
1136
  # Constant[nil] (making nil? = Constant[true] and the then-branch "provably live") propagates the un-narrowed
720
1137
  # nil scope past the guard instead of Bot.
721
- if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
722
- _, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
723
- return [live_type, falsey_scope]
724
- end
1138
+ return [live_type, falsey_scope] if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
1139
+
725
1140
  return live
726
1141
  end
727
1142
 
728
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
729
1143
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
730
1144
  else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
731
1145
  # Slice 7 phase 14 — early-return narrowing. When the then-branch unconditionally exits (return / next / break /
@@ -754,22 +1168,19 @@ module Rigor
754
1168
  # chain). The narrower's truthy/falsey edges are routed in swapped form because `unless` runs its body when the
755
1169
  # predicate is falsey.
756
1170
  def eval_unless(node)
757
- pred_type, post_pred = sub_eval(node.predicate, scope)
1171
+ pred_type, post_pred, truthy_scope, falsey_scope = eval_with_edges(node.predicate, scope)
758
1172
 
759
- live = live_branch_for_unless(node, pred_type, post_pred)
1173
+ live = live_branch_for_unless(node, pred_type, post_pred, truthy_scope, falsey_scope)
760
1174
  if live
761
1175
  live_type, _live_scope = live
762
1176
  # Mirror of the eval_if fix: when the provably-live unless-body terminates and there is no else, apply the
763
1177
  # truthy-scope narrowing so `return unless @ivar` with a nil-seeded ivar doesn't propagate the nil scope past
764
1178
  # the guard.
765
- if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
766
- truthy_scope, = Narrowing.predicate_scopes(node.predicate, post_pred)
767
- return [live_type, truthy_scope]
768
- end
1179
+ return [live_type, truthy_scope] if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
1180
+
769
1181
  return live
770
1182
  end
771
1183
 
772
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
773
1184
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
774
1185
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
775
1186
  # Slice 7 phase 14 — same early-return narrowing as `if`: when the body unconditionally exits and there is no
@@ -792,20 +1203,29 @@ module Rigor
792
1203
  # the caller falls through to the standard both-branch evaluation. Constant `true`/`false` is the obvious trigger;
793
1204
  # non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
794
1205
  # dead else.
795
- def live_branch_for_if(node, pred_type, post_pred)
1206
+ def live_branch_for_if(node, pred_type, post_pred, truthy_scope, falsey_scope)
1207
+ truthy_scope, falsey_scope = live_branch_scopes(node.predicate, post_pred, truthy_scope, falsey_scope)
796
1208
  case branch_certainty(node.predicate, pred_type, post_pred)
797
- when :truthy then eval_branch_or_nil(node.statements, post_pred)
798
- when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
1209
+ when :truthy then eval_branch_or_nil(node.statements, truthy_scope)
1210
+ when :falsey then eval_branch_or_nil(node.subsequent, falsey_scope)
799
1211
  end
800
1212
  end
801
1213
 
802
- def live_branch_for_unless(node, pred_type, post_pred)
1214
+ def live_branch_for_unless(node, pred_type, post_pred, truthy_scope, falsey_scope)
1215
+ truthy_scope, falsey_scope = live_branch_scopes(node.predicate, post_pred, truthy_scope, falsey_scope)
803
1216
  case branch_certainty(node.predicate, pred_type, post_pred)
804
- when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
805
- when :falsey then eval_branch_or_nil(node.statements, post_pred)
1217
+ when :truthy then eval_branch_or_nil(node.else_clause, truthy_scope)
1218
+ when :falsey then eval_branch_or_nil(node.statements, falsey_scope)
806
1219
  end
807
1220
  end
808
1221
 
1222
+ # A provably-live branch runs from the post-predicate scope, un-narrowed, except under an `&&` / `||` whose right
1223
+ # operand writes ({#eval_with_edges}): there the joined scope still reads the write as possibly `nil`, and the
1224
+ # live edge is the one on which the right operand ran (`if text && (w = text.size)` with `text` a String).
1225
+ def live_branch_scopes(predicate, post_pred, truthy_scope, falsey_scope)
1226
+ and_or_right_effects?(predicate) ? [truthy_scope, falsey_scope] : [post_pred, post_pred]
1227
+ end
1228
+
809
1229
  # ADR-47 WD5 — a decidable **version guard** answers first (#627). `RUBY_VERSION >= "3.1"` and the
810
1230
  # `Gem::Version.new(…) <cmp> Gem::Version.new(…)` spellings fold from literals the analyzer can read, so the arm
811
1231
  # that cannot run on the Ruby being checked with is elided exactly as `if false`'s is: it is never evaluated, so
@@ -843,8 +1263,12 @@ module Rigor
843
1263
  # shared with every branch (including the else); branches are evaluated independently and merged with
844
1264
  # nil-injection so half-bound names degrade to `T | nil`.
845
1265
  def eval_case(node)
846
- post_pred = node.predicate ? sub_eval(node.predicate, scope).last : scope
847
- branch_results, falsey_scope = eval_case_when_branches(node.predicate, node.conditions, post_pred)
1266
+ subject_type, post_pred = node.predicate ? sub_eval(node.predicate, scope) : [nil, scope]
1267
+ branch_results, falsey_scope = eval_case_when_branches(subject_type, node.predicate, node.conditions, post_pred)
1268
+ if pattern_case_matches_every_path?(node, branch_results)
1269
+ return unmatched_pattern_result(branch_results, node.conditions)
1270
+ end
1271
+
848
1272
  else_result = eval_case_else(node.else_clause, falsey_scope)
849
1273
 
850
1274
  all_results = [*branch_results, else_result]
@@ -855,6 +1279,21 @@ module Rigor
855
1279
  ]
856
1280
  end
857
1281
 
1282
+ # Issue #1122 — a `case/in` with no `else` has no "nothing matched" path: CRuby raises
1283
+ # `NoMatchingPatternError` when no pattern matches, so the continuation is reached only through a
1284
+ # matched clause. The shared `else` arm would inject that impossible path anyway — `Constant[nil]`
1285
+ # for the type and the entry scope for the continuation, which nil-injects every pattern-bound name.
1286
+ # `case [1, "a"] in [i, s] then i end; i + 1` then read as `i + 1` on `1 | nil` and drew a false
1287
+ # `possible nil receiver` on a name bound on every path that reaches it. A `case/when` keeps the
1288
+ # arm: a subject matching no clause really does fall through as `nil`.
1289
+ def pattern_case_matches_every_path?(node, branch_results)
1290
+ node.is_a?(Prism::CaseMatchNode) && node.else_clause.nil? && !branch_results.empty?
1291
+ end
1292
+
1293
+ def unmatched_pattern_result(branch_results, branch_nodes)
1294
+ [Type::Combinator.union(*branch_results.map(&:first)), join_case_branch_scopes(branch_results, branch_nodes)]
1295
+ end
1296
+
858
1297
  # Joins the post-scopes of every `when`/`in`/`else` branch, dropping the scope of any branch that terminates
859
1298
  # (raises / returns / throws / types to `Bot`) before the merge — control never falls through such a branch, so
860
1299
  # its half-bound locals must not nil-inject the names a live sibling branch assigned. Mirrors the
@@ -872,22 +1311,31 @@ module Rigor
872
1311
  reduce_scopes_with_nil_injection(live)
873
1312
  end
874
1313
 
875
- def eval_case_when_branches(subject, conditions, entry_scope)
1314
+ def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
876
1315
  results = []
877
1316
  falsey_scope = entry_scope
878
1317
  conditions.each do |branch|
1318
+ # Issue #1359 — a clause's conditions, or its pattern's pins and guard, run before its body and before
1319
+ # every later clause, and the walk types them without evaluating them, so a reader there forgets `$_` here.
1320
+ falsey_scope = LastLine.forget_if_set(falsey_scope, *clause_tests(branch))
879
1321
  # ADR-47 WD2 — record the scope ENTERING this clause (the subject narrowed by every earlier clause's negation)
880
1322
  # on the clause's first condition node, so `flow.unreachable-clause` can tell a prior-exhausted subject (entry
881
1323
  # already `bot`) from a per-clause-disjoint one (entry concrete, this clause disjoint). `on_enter`-only (no
882
1324
  # recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
883
1325
  # keys the node.
884
1326
  record_clause_entry_scope(branch, falsey_scope)
885
- body_scope, falsey_scope = branch_body_and_falsey_scopes(subject, branch, falsey_scope)
1327
+ body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
886
1328
  results << sub_eval(branch, body_scope)
887
1329
  end
888
1330
  [results, falsey_scope]
889
1331
  end
890
1332
 
1333
+ # What a `when` / `in` clause runs to decide whether it matches, without its body: the `when` conditions, or the
1334
+ # `in` pattern with its pins and guard.
1335
+ def clause_tests(branch)
1336
+ branch.is_a?(Prism::WhenNode) ? branch.conditions : [branch.pattern]
1337
+ end
1338
+
891
1339
  # ADR-47 WD2/WD3 — record the scope ENTERING a `when`/`in` clause on the node `flow.unreachable-clause` reads to
892
1340
  # classify a dead clause (`when`: first condition; `in`: the pattern). `on_enter`-only so no sub-expression is
893
1341
  # newly typed; `propagate` preserves it.
@@ -903,9 +1351,10 @@ module Rigor
903
1351
  # Returns `[body_scope, updated_falsey_scope]` for a single branch. `WhenNode` branches narrow through
904
1352
  # `Narrowing.case_when_scopes`. `InNode` branches narrow soundly only for a bare class pattern (`in C` / `in C =>
905
1353
  # x`, pure `is_a?`); every other pattern keeps the conservative "body = entry + bindings, falsey unchanged" shape.
906
- def branch_body_and_falsey_scopes(subject, branch, falsey_scope)
1354
+ # `subject_type` is the predicate's type, which an `in` branch's pattern decomposes to type the names it binds.
1355
+ def branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
907
1356
  if branch.is_a?(Prism::InNode)
908
- in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
1357
+ in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
909
1358
  else
910
1359
  when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
911
1360
  Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
@@ -917,12 +1366,15 @@ module Rigor
917
1366
  # falsey scope has `C` removed. Other patterns can fail to match even when a class test would pass (deconstruction
918
1367
  # arity, hash keys, ...), so removing anything from the falsey scope would be unsound — they keep the conservative
919
1368
  # shape.
920
- def in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
1369
+ def in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
921
1370
  class_node = bare_class_pattern_node(branch.pattern)
922
- return [apply_in_pattern_bindings(subject, branch.pattern, falsey_scope), falsey_scope] unless class_node
1371
+ unless class_node
1372
+ bound = apply_in_pattern_bindings(subject_type, subject, branch.pattern, falsey_scope)
1373
+ return [bound, falsey_scope]
1374
+ end
923
1375
 
924
1376
  truthy_scope, narrowed_falsey = Narrowing.case_when_scopes(subject, [class_node], falsey_scope)
925
- [apply_in_pattern_bindings(subject, branch.pattern, truthy_scope), narrowed_falsey]
1377
+ [apply_in_pattern_bindings(subject_type, subject, branch.pattern, truthy_scope), narrowed_falsey]
926
1378
  end
927
1379
 
928
1380
  # The class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is pure `is_a?`), or
@@ -949,38 +1401,73 @@ module Rigor
949
1401
  sub_eval(node.statements, scope)
950
1402
  end
951
1403
 
1404
+ # Issue #1360 — a `begin` with a rescue chain runs through {#eval_begin} with `$!` and `$@` restored as it
1405
+ # leaves ({#restoring_error_info}): Ruby restores them once the `begin` exits, however it exits, so the binding
1406
+ # its rescue clauses made ({#bind_rescue_reference}) never reaches the code after it. The retry edge carries
1407
+ # locals and instance variables only, so a retried body reads them as the `begin` found them already. A `begin`
1408
+ # without a rescue chain binds neither.
1409
+ #
1410
+ # A body a `retry` re-enters runs again after the exception it raised, which may have come while a subprocess
1411
+ # waited and so left `$?` nil, and the retry edge does not carry `$?`: such a `begin` is evaluated with `$?`
1412
+ # unbound.
1413
+ def eval_begin_node(node)
1414
+ return eval_begin(node) unless node.rescue_clause
1415
+ return evaluator_at(scope.forget_last_status).send(:eval_begin_node, node) if status_retried?(node)
1416
+
1417
+ restoring_error_info { eval_begin(node) }
1418
+ end
1419
+
1420
+ # True when `$?` is bound and a rescue clause of `node` holds a `retry` that re-enters it.
1421
+ def status_retried?(node)
1422
+ return false unless scope.global(:$?)
1423
+
1424
+ current = node.rescue_clause
1425
+ current = current.subsequent until current.nil? || collect_retries(current.statements)
1426
+ !current.nil?
1427
+ end
1428
+
1429
+ # The `[type, scope]` the block answers, for a `begin` or rescue modifier that starts from this evaluator's
1430
+ # scope, with `$!` and `$@` in that scope, and in each scope a `next` or `break` recorded into the jump sinks
1431
+ # while it ran, put back as this scope binds them ({ErrorInfo.restore}): the control those carry has left every
1432
+ # rescue clause the construct entered.
1433
+ def restoring_error_info
1434
+ marks = [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
1435
+ type, after = yield
1436
+ [[@next_scope_sink, marks.first], [Thread.current[BREAK_SINK_KEY], marks.last]].each do |sink, mark|
1437
+ next if sink.nil? || mark.nil?
1438
+
1439
+ (mark...sink.size).each do |index|
1440
+ jump, jump_scope = sink[index]
1441
+ sink[index] = [jump, ErrorInfo.restore(jump_scope, scope)]
1442
+ end
1443
+ end
1444
+ [type, ErrorInfo.restore(after, scope)]
1445
+ end
1446
+
952
1447
  # `begin; body; rescue ...; else; ensure; end`. The body and the rescue chain are alternative exit paths whose
953
1448
  # scopes are joined with nil-injection. The else-clause replaces the body's value when present (matching Ruby
954
1449
  # semantics: else runs only if the body raises no exception). The ensure-clause runs but does not contribute to
955
1450
  # the value; its scope effects are layered on the joined exit scope so locals bound exclusively in `ensure` stay
956
1451
  # observable.
957
1452
  def eval_begin(node)
958
- entry = scope
959
- primary_type, primary_scope = eval_begin_primary_under(node, entry)
960
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, entry)
961
-
962
- # B2.1 — retry-edge widening. When any rescue body contains `Prism::RetryNode`, control re-enters the primary
963
- # body with the rescue arm's rebinds visible. Today's flow loses that effect, so a counter like `tries = 0; ...;
964
- # rescue; tries += 1; retry; end` observes `tries: Constant[0]` inside the body and any `tries > 100` predicate
965
- # folds to always-falsey. The fix: widen rebound locals / ivars in any retry-emitting arm to their Nominal
966
- # envelope (Constant → Nominal[<class>], Tuple → Array, HashShape → Hash), then re-evaluate primary body AND
967
- # rescue chain once under the widened entry. Nominal envelope is the maximally widened form so the re-evaluation
968
- # converges in one step.
969
- widened_entry = widen_entry_for_retry(entry, rescue_chain)
970
- if widened_entry
971
- primary_type, primary_scope = eval_begin_primary_under(node, widened_entry)
972
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, widened_entry)
973
- end
974
-
975
- # Rescue arms whose body unconditionally exits (`return`, `next`, `break`, `raise`, `throw`, `exit`, `abort`,
976
- # `fail`) contribute neither a type fragment NOR a scope to the post-begin flow — control left the `begin` via
977
- # that arm. Mirrors the `eval_if` / `eval_unless` / `eval_and_or` early-return narrowing. Without this filter, a
978
- # `rescue ... return` on a local bound only in the primary body nil-injects that local across the join,
979
- # defeating the rescue arm's whole point of guaranteeing the primary local is in scope for downstream
980
- # statements.
981
- live_rescues = rescue_chain.reject { |_pair, arm_node| branch_unconditionally_exits?(arm_node.statements) }
982
- .map(&:first)
983
-
1453
+ jump_marks = ensure_jump_marks(node)
1454
+ edge = retry_edge_for(node)
1455
+ # Issue #1359 — `$_` is not among the bindings the retry widening below carries, so a body that runs again
1456
+ # after it, or a rescue clause, may have set `$_` enters with it forgotten; a rescue clause runs after any
1457
+ # prefix of the body, and so reads it forgotten whenever the body may set it.
1458
+ entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
1459
+ primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge)
1460
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry), edge: edge)
1461
+
1462
+ # B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
1463
+ # primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
1464
+ # the primary body's own, since it can raise after any prefix of itself (`begin; tries += 1; raise if tries < 3;
1465
+ # rescue; retry; end`). Without the widening the re-entry keeps `tries: Constant[0]` and the predicate folds.
1466
+ # {#eval_retried_begin} re-evaluates the primary body AND the rescue chain under a widened entry.
1467
+ retried = edge && eval_retried_begin(node, entry, edge)
1468
+ primary_type, primary_scope, rescue_chain = retried if retried
1469
+
1470
+ live_rescues = live_rescue_results(rescue_chain)
984
1471
  if live_rescues.empty?
985
1472
  exit_type = primary_type
986
1473
  exit_scope = primary_scope
@@ -990,6 +1477,7 @@ module Rigor
990
1477
  end
991
1478
 
992
1479
  if node.ensure_clause
1480
+ carry_jumps_through_ensure(node.ensure_clause, jump_marks)
993
1481
  _ensure_type, ensure_scope = sub_eval(node.ensure_clause, exit_scope)
994
1482
  exit_scope = ensure_scope
995
1483
  end
@@ -997,16 +1485,67 @@ module Rigor
997
1485
  [exit_type, exit_scope]
998
1486
  end
999
1487
 
1488
+ # The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
1489
+ # (issue #1359) nor a guard's narrowing of a global or constant (issue #1429) the body may rebind holds there.
1490
+ def rescue_entry_scope(node, entry)
1491
+ forget_guard_if_rebinds(LastLine.forget_if_set(entry, node.statements), node.statements)
1492
+ end
1493
+
1494
+ # Rescue arms that never fall through contribute neither a type fragment NOR a scope to the post-begin flow —
1495
+ # control left the `begin` via that arm. That is an arm ending in `return`, `next`, `break`, `raise`, `throw`,
1496
+ # `exit`, `abort` or `fail`, and one whose type is `bot` ({#branch_terminates?}): an arm ending in `retry`, or in
1497
+ # `tries < 3 ? retry : raise`, leaves back into the primary body, and joining its scope would carry the retry
1498
+ # edge's widened entry past the `begin`. Mirrors the `eval_if` / `eval_unless` / `eval_and_or` early-return
1499
+ # narrowing. Without this filter, a `rescue ... return` on a local bound only in the primary body nil-injects that
1500
+ # local across the join, defeating the rescue arm's whole point of guaranteeing the primary local is in scope for
1501
+ # downstream statements.
1502
+ def live_rescue_results(rescue_chain)
1503
+ rescue_chain.reject { |(arm_type, _), arm_node| branch_terminates?(arm_node.statements, arm_type) }
1504
+ .map(&:first)
1505
+ end
1506
+
1507
+ # The jump sinks' sizes as a `begin … ensure` starts, or nil for a `begin` without `ensure`: every `next` /
1508
+ # `break` scope recorded past these marks leaves through that `ensure`.
1509
+ def ensure_jump_marks(node)
1510
+ return nil unless node.ensure_clause
1511
+
1512
+ [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
1513
+ end
1514
+
1515
+ # A `next` or `break` inside `begin … ensure` leaves only once the `ensure` clause has run, so each jump scope the
1516
+ # clauses recorded is replaced by that scope carried through the clause: `buf = nil; next if c` under `ensure
1517
+ # buf = +"reset"` leaves with `"reset"`, never `nil`. The clause is evaluated without recording into the
1518
+ # per-node scope index, which keeps the fall-through's visit.
1519
+ def carry_jumps_through_ensure(ensure_clause, marks)
1520
+ next_mark, break_mark = marks
1521
+ carry_through_ensure(ensure_clause, @next_scope_sink, next_mark)
1522
+ carry_through_ensure(ensure_clause, Thread.current[BREAK_SINK_KEY], break_mark)
1523
+ end
1524
+
1525
+ def carry_through_ensure(ensure_clause, sink, mark)
1526
+ return if sink.nil? || mark.nil?
1527
+
1528
+ (mark...sink.size).each do |index|
1529
+ jump, jump_scope = sink[index]
1530
+ sink[index] = [jump, sub_eval(ensure_clause, jump_scope, **UNRECORDED).last]
1531
+ end
1532
+ end
1533
+
1000
1534
  # `BeginNode#statements` is the primary body; when an else-clause is present, its value replaces the body's per
1001
1535
  # Ruby semantics (the else runs only when no exception was raised), but the body's scope effects still apply
1002
1536
  # because the body did run before the else.
1003
- def eval_begin_primary_under(node, entry_scope)
1537
+ #
1538
+ # `edge`, when given, collects every scope the primary body could raise from: the scope after each statement of
1539
+ # its frame ({#record_raise_points}), and the scope it ends with. The else-clause is not among them: what it
1540
+ # raises is not rescued here.
1541
+ def eval_begin_primary_under(node, entry_scope, edge: nil)
1004
1542
  body_type, body_scope =
1005
1543
  if node.statements
1006
- sub_eval(node.statements, entry_scope)
1544
+ with_raise_frame(edge) { sub_eval(node.statements, entry_scope) }
1007
1545
  else
1008
1546
  [Type::Combinator.constant_of(nil), entry_scope]
1009
1547
  end
1548
+ edge.raise_scopes << body_scope if edge
1010
1549
 
1011
1550
  if node.else_clause
1012
1551
  else_type, else_scope = sub_eval(node.else_clause, body_scope)
@@ -1016,92 +1555,293 @@ module Rigor
1016
1555
  end
1017
1556
  end
1018
1557
 
1019
- # B2.1 — return a widened entry scope when at least one rescue arm in `rescue_chain` contains a `Prism::RetryNode`
1020
- # AND that arm rebinds at least one local or ivar relative to the original entry. Returns nil when no widening is
1021
- # needed (no retry, or no rebinds reachable across the retry edge).
1022
- #
1023
- # Always-safe: the widening can only LOSE precision; it never invents a fact (Nominal envelope is a superset of
1024
- # the Constant / shape carrier it widens from). Convergent in one step because Nominal envelope is the maximally
1025
- # widened form against the engine's current carrier set.
1026
- def widen_entry_for_retry(entry_scope, rescue_chain)
1027
- widened = nil
1028
- rescue_chain.each do |(_arm_type, arm_post_scope), arm_node|
1029
- next unless arm_contains_retry?(arm_node)
1558
+ # B2.1 — what one pass over a `begin` whose rescue chain retries collects: the `retry` nodes that target it, the
1559
+ # arms holding them, the nodes of its primary body's frame and the names that frame writes, and the scopes control
1560
+ # carries back into the primary body — at each point the body can raise from (`raise_scopes`), and at each of
1561
+ # those `retry`s together with the post-scope of each arm holding one (`retry_scopes`).
1562
+ RetryEdge = Data.define(:retries, :retrying_arms, :frame, :body_writes, :raise_scopes, :retry_scopes) do
1563
+ def fresh = with(raise_scopes: [], retry_scopes: [])
1030
1564
 
1031
- accumulator = widened || entry_scope
1032
- accumulator = absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
1033
- widened = accumulator
1565
+ def merge(other)
1566
+ with(raise_scopes: raise_scopes + other.raise_scopes, retry_scopes: retry_scopes + other.retry_scopes)
1034
1567
  end
1035
- return nil if widened.nil? || widened == entry_scope
1568
+ end
1569
+ private_constant :RetryEdge
1036
1570
 
1037
- widened
1571
+ # The entry and edge one widening reads, whether it widens to the Nominal envelope or keeps literals, and, per
1572
+ # name, the rebinds it has already weighed: a statement-by-statement body shares one binding object across every
1573
+ # scope until the name is rebound, and weighing each copy again is what made a long body quadratic.
1574
+ RetryWidening = Data.define(:entry, :edge, :envelope, :weighed) do
1575
+ def initialize(entry:, edge:, envelope:, weighed: {}) = super
1038
1576
  end
1577
+ private_constant :RetryWidening
1039
1578
 
1040
- def arm_contains_retry?(node)
1041
- return false unless node.is_a?(Prism::Node)
1042
- return true if node.is_a?(Prism::RetryNode)
1043
- # Don't descend into nested blocks / defs / classes / modules — a `retry` inside a nested method body or block
1044
- # targets its own enclosing `begin`, not this one.
1045
- return false if node.is_a?(Prism::DefNode) ||
1046
- node.is_a?(Prism::ClassNode) ||
1047
- node.is_a?(Prism::ModuleNode) ||
1048
- node.is_a?(Prism::BlockNode)
1579
+ # The thread-local stack of the {RetryEdge}s whose primary body is being evaluated, innermost last, or nil.
1580
+ RETRY_FRAMES_KEY = :rigor_retry_frames
1581
+ private_constant :RETRY_FRAMES_KEY
1049
1582
 
1050
- found = false
1051
- node.rigor_each_child do |c|
1052
- next unless arm_contains_retry?(c)
1583
+ # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1584
+ # no `retry` targets.
1585
+ def retry_edge_for(node)
1586
+ retries = nil
1587
+ retrying_arms = nil
1588
+ current = node.rescue_clause
1589
+ while current
1590
+ found = collect_retries(current.statements)
1591
+ if found
1592
+ (retries ||= Set.new.compare_by_identity).merge(found)
1593
+ (retrying_arms ||= Set.new.compare_by_identity) << current
1594
+ end
1595
+ current = current.subsequent
1596
+ end
1597
+ return nil unless retries
1598
+
1599
+ frame = Set.new.compare_by_identity
1600
+ body_writes = Set.new
1601
+ walk_primary_frame(node.statements, true, frame, body_writes) if node.statements
1602
+ RetryEdge.new(retries: retries, retrying_arms: retrying_arms, frame: frame, body_writes: body_writes,
1603
+ raise_scopes: [], retry_scopes: [])
1604
+ end
1605
+
1606
+ # The `retry` nodes under `node` that re-enter the `begin` whose rescue arm holds it, or nil for none. A nested
1607
+ # `rescue` clause, or a rescue modifier's fallback, owns the `retry`s inside it (Ruby 4.0.5 retries the modifier's
1608
+ # own expression), and a nested block, lambda, `def` or class body cannot hold one for this `begin`.
1609
+ def collect_retries(node, found = nil)
1610
+ case node
1611
+ when nil, Prism::RescueNode then found
1612
+ when Prism::RetryNode then (found || []) << node
1613
+ when Prism::RescueModifierNode then collect_retries(node.expression, found)
1614
+ else
1615
+ return found if scope_boundary?(node)
1053
1616
 
1054
- found = true
1055
- break
1617
+ node.rigor_each_child { |child| found = collect_retries(child, found) }
1618
+ found
1056
1619
  end
1057
- found
1058
1620
  end
1059
1621
 
1060
- def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
1061
- scope_acc = accumulator
1062
- # Walk every local visible in either side, compare types, widen to Nominal envelope on a difference.
1063
- local_keys = arm_post_scope.locals.keys | entry_scope.locals.keys
1064
- local_keys.each do |name|
1065
- pre = entry_scope.local(name)
1066
- post = arm_post_scope.local(name)
1067
- next if pre == post || post.nil?
1622
+ def scope_boundary?(node)
1623
+ SCOPE_NESTING_NODES.any? { |klass| node.is_a?(klass) } || SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
1624
+ end
1625
+
1626
+ RETRY_WRITE_NODES = (CapturedLocals::LOCAL_WRITE_NODES | CapturedLocals::NON_LOCAL_WRITE_NODES).freeze
1627
+ private_constant :RETRY_WRITE_NODES
1628
+
1629
+ # Collects into `frame` the nodes of the primary body that run in its own frame — a nested block or lambda keeps
1630
+ # its own locals (a block parameter can shadow the counter) — and into `writes` every variable name the body
1631
+ # writes, a block's included (it may write an outer local). A `def` or class body runs nothing here.
1632
+ def walk_primary_frame(node, in_frame, frame, writes)
1633
+ return if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
1634
+
1635
+ in_frame &&= SCOPE_NESTING_NODES.none? { |klass| node.is_a?(klass) }
1636
+ frame << node if in_frame
1637
+ writes << node.name if RETRY_WRITE_NODES.include?(node.class)
1638
+ node.rigor_each_child { |child| walk_primary_frame(child, in_frame, frame, writes) }
1639
+ end
1640
+
1641
+ # B2.1 — the primary path's `[type, scope]` and the rescue chain's results re-evaluated under an entry the retry
1642
+ # edge widens, or nil when nothing crosses the edge. The widening first keeps literals, the entry joined with each
1643
+ # rebind, and holds when one re-evaluation under it puts nothing new on the edge: `st = :ok; …; st = :retrying`
1644
+ # stays `:ok | :retrying`. A counter moves again (`0 | 1` meets `2`), so the entry is then widened to the Nominal
1645
+ # envelope of everything both passes saw, and evaluated once more; that pass is taken as converged.
1646
+ def eval_retried_begin(node, entry, edge)
1647
+ literal = widen_entry_for_retry(RetryWidening.new(entry: entry, edge: edge, envelope: false))
1648
+ return nil unless literal
1649
+
1650
+ closing = edge.fresh
1651
+ result = eval_begin_paths(node, literal, closing)
1652
+ return result unless widen_entry_for_retry(RetryWidening.new(entry: literal, edge: closing, envelope: false))
1653
+
1654
+ widened = widen_entry_for_retry(RetryWidening.new(entry: entry, edge: edge.merge(closing), envelope: true))
1655
+ eval_begin_paths(node, widened || literal, nil)
1656
+ end
1657
+
1658
+ def eval_begin_paths(node, entry, edge)
1659
+ primary = eval_begin_primary_under(node, entry, edge: edge)
1660
+ [*primary, collect_rescue_chain_results(node.rescue_clause, entry, edge: edge)]
1661
+ end
1662
+
1663
+ # B2.1 — the entry scope widened by what crosses the retry edge, or nil when nothing does. A local or ivar bound
1664
+ # in the entry widens when a scope on the edge binds it to a type the accumulated binding does not already
1665
+ # accept ({#retry_binding_accepted?}): inside `log if m == :fast` the scope holds `m: :fast`, which the entry's
1666
+ # `:fast | :slow` accepts, and widening it would turn a declared `:fast | :slow` return into `Symbol`.
1667
+ #
1668
+ # A name the entry does not bind joins the edge only from a retrying arm, and only when the primary body never
1669
+ # writes it. One the body writes reads as `Dynamic[top]` on the first entry, as it does on this pass; binding a
1670
+ # type onto the edge would claim it set even when the body raised before assigning it — the arm's `conn.close if
1671
+ # conn` would fold to always-truthy for the body's connection, or to always-falsey for the arm's own `conn = nil`.
1672
+ def widen_entry_for_retry(widening)
1673
+ widened = widening.entry
1674
+ widening.edge.retry_scopes.each do |retry_scope|
1675
+ widened = absorb_retry_rebinds(widened, retry_scope, widening)
1676
+ end
1677
+ widening.edge.raise_scopes.each do |raise_scope|
1678
+ widened = absorb_retry_rebinds(widened, raise_scope, widening, bound_on_entry: true)
1679
+ end
1680
+ return nil if widened == widening.entry
1681
+
1682
+ widened
1683
+ end
1684
+
1685
+ # Runs `block` with `edge` on top of the thread-local stack {#record_raise_points} reads.
1686
+ def with_raise_frame(edge)
1687
+ return yield unless edge
1068
1688
 
1069
- widened = retry_widened_type(pre, post)
1070
- scope_acc = scope_acc.with_local(name, widened)
1689
+ previous = Thread.current[RETRY_FRAMES_KEY]
1690
+ Thread.current[RETRY_FRAMES_KEY] = previous ? [*previous, edge] : [edge]
1691
+ begin
1692
+ yield
1693
+ ensure
1694
+ Thread.current[RETRY_FRAMES_KEY] = previous
1695
+ end
1696
+ end
1697
+
1698
+ # The scope after each statement of a retrying primary body's frame is a point the body can raise from, the next
1699
+ # statement's being the one after. Together they carry every rebind a retry can re-enter with, including one on a
1700
+ # branch that then raises and so never reaches the body's exit scope (`if bad; tries += 1; raise; end`), and a
1701
+ # write threaded into the raising call's own operands (`raise Retry.new(tries += 1) if flaky?`). A statement of a
1702
+ # nested block or lambda body is not in the frame (a block parameter can shadow the counter); the block's effect
1703
+ # on this frame shows in the post-scope of the statement holding it. Recording from the evaluator rather than
1704
+ # `on_enter` keeps a statement reached with the index recorder off (a threaded operand, a loop fixpoint pass).
1705
+ def record_raise_points(frames, stmt, stmt_scope)
1706
+ frames.each { |edge| edge.raise_scopes << stmt_scope if edge.frame.include?(stmt) }
1707
+ end
1708
+
1709
+ # An `on_enter` that records, besides forwarding to the installed one, the entry scope of each `retry` of `edge`
1710
+ # the rescue chain reaches: the scope control carries back into the primary body.
1711
+ def retry_scope_recorder(edge)
1712
+ forward = @on_enter
1713
+ lambda do |node, node_scope|
1714
+ edge.retry_scopes << node_scope if edge.retries.include?(node)
1715
+ forward&.call(node, node_scope)
1071
1716
  end
1072
- ivar_keys = arm_post_scope.ivars.keys | entry_scope.ivars.keys
1073
- ivar_keys.each do |name|
1074
- pre = entry_scope.ivar(name)
1075
- post = arm_post_scope.ivar(name)
1076
- next if pre == post || post.nil?
1717
+ end
1718
+
1719
+ # Widens against the accumulator's binding rather than the entry's, so a name rebound differently by two arms, or
1720
+ # at two points of the primary body, keeps every rebind instead of only the last one absorbed.
1721
+ def absorb_retry_rebinds(accumulator, post_scope, widening, bound_on_entry: false)
1722
+ scope_acc = absorb_retry_kind_rebinds(accumulator, post_scope, widening, :local, bound_on_entry)
1723
+ absorb_retry_kind_rebinds(scope_acc, post_scope, widening, :ivar, bound_on_entry)
1724
+ end
1077
1725
 
1078
- widened = retry_widened_type(pre, post)
1079
- scope_acc = scope_acc.with_ivar(name, widened)
1726
+ RETRY_KIND_TABLES = { local: :locals, ivar: :ivars }.freeze
1727
+ private_constant :RETRY_KIND_TABLES
1728
+
1729
+ # Walk every name of `kind` visible on either side (only the entry's under `bound_on_entry`), and widen a binding
1730
+ # the accumulated one does not already accept.
1731
+ def absorb_retry_kind_rebinds(scope_acc, post_scope, widening, kind, bound_on_entry)
1732
+ getter = VAR_KIND_GETTERS.fetch(kind)
1733
+ table = RETRY_KIND_TABLES.fetch(kind)
1734
+ names = widening.entry.public_send(table).keys
1735
+ names |= post_scope.public_send(table).keys unless bound_on_entry
1736
+ names.each do |name|
1737
+ post = post_scope.public_send(getter, name)
1738
+ next if post.nil? || retry_rebind_settled?(widening, name, widening.entry.public_send(getter, name), post)
1739
+
1740
+ current = scope_acc.public_send(getter, name)
1741
+ if current ? retry_binding_accepted?(current, post) : widening.edge.body_writes.include?(name)
1742
+ scope_acc = forget_diverged_copy(scope_acc, post_scope, kind, name)
1743
+ next
1744
+ end
1745
+
1746
+ scope_acc = rebind_retried(scope_acc, post_scope, kind, name,
1747
+ retry_widened_type(current, post, kind, widening.envelope))
1080
1748
  end
1081
1749
  scope_acc
1082
1750
  end
1083
1751
 
1084
- def retry_widened_type(pre, post)
1085
- # `pre` is nil when the local was introduced inside the rescue body. The retry edge brings it back into the
1086
- # primary body's entry — widen the post type itself.
1087
- envelope = nominal_envelope_for(post)
1088
- return envelope if pre.nil?
1752
+ # The rebind joins the binding a retry re-enters with into the accumulated one, so ADR-58's local mark stays only
1753
+ # when both scopes carry it, as `Scope#join` keeps it (issue #1287): `up(r)` in the body floors `r` in place and
1754
+ # keeps the mark, while `r = other` in the rescue arm is a write and drops it. Issue #1362 — as in `Scope#join`, a
1755
+ # copy of global seeds keeps its mark with the union of both scopes' globals, and only when both copy some.
1756
+ def rebind_retried(scope_acc, post_scope, kind, name, type)
1757
+ rebound = rebind_variable(scope_acc, kind, name, type)
1758
+ return rebound unless kind == :local && scope_acc.declaration_sourced?(:local, name) &&
1759
+ post_scope.declaration_sourced?(:local, name)
1760
+
1761
+ copies = scope_acc.declaration_sourced_global_copies(name)
1762
+ post_copies = post_scope.declaration_sourced_global_copies(name)
1763
+ return rebound unless copies.empty? == post_copies.empty?
1764
+
1765
+ rebound.with_local_declaration_mark(name).with_global_copy_marks(name, copies | post_copies)
1766
+ end
1767
+
1768
+ # Issue #1362 — a re-entered binding the accumulated one already accepts is not rebound, so ADR-58's mark would
1769
+ # stand on a local that copies a global's seed while the retried pass holds something else. As at a join, a copy
1770
+ # of other global seeds adds their globals to the record, and any other value makes the local flow-live.
1771
+ def forget_diverged_copy(scope_acc, post_scope, kind, name)
1772
+ return scope_acc unless kind == :local
1089
1773
 
1090
- nominal_envelope_for(Type::Combinator.union(pre, envelope))
1774
+ copies = scope_acc.declaration_sourced_global_copies(name)
1775
+ return scope_acc if copies.empty?
1776
+
1777
+ post_copies = post_scope.declaration_sourced_global_copies(name)
1778
+ return scope_acc.without_local_declaration_marks(name) if post_copies.empty?
1779
+
1780
+ scope_acc.with_global_copy_marks(name, post_copies)
1781
+ end
1782
+
1783
+ # Whether `post` needs no weighing: this widening has weighed it for `name` already, or it is the entry's binding.
1784
+ def retry_rebind_settled?(widening, name, pre, post)
1785
+ return true unless (widening.weighed[name] ||= Set.new.compare_by_identity).add?(post)
1786
+
1787
+ pre.equal?(post) || pre == post
1788
+ end
1789
+
1790
+ # Whether the binding `current` already covers `post`. A `post` carrying `Dynamic` anywhere could be any value,
1791
+ # which only a `current` with a `Dynamic` member covers: `Array[Integer]` gradually accepts `Array[untyped]`, but
1792
+ # keeping it would claim the elements are still Integers.
1793
+ def retry_binding_accepted?(current, post)
1794
+ return ContentJoin.union_members(current).any?(Type::Dynamic) if carries_dynamic?(post)
1795
+
1796
+ Acceptance.accepts(current, post).yes?
1797
+ end
1798
+
1799
+ def carries_dynamic?(type)
1800
+ case type
1801
+ when Type::Dynamic then true
1802
+ when Type::Union then type.members.any? { |member| carries_dynamic?(member) }
1803
+ when Type::Nominal then type.type_args.any? { |arg| carries_dynamic?(arg) }
1804
+ when Type::Tuple then type.elements.any? { |element| carries_dynamic?(element) }
1805
+ when Type::HashShape then type.pairs.each_value.any? { |value| carries_dynamic?(value) }
1806
+ when Type::Difference, Type::Refined then carries_dynamic?(type.base)
1807
+ else false
1808
+ end
1809
+ end
1810
+
1811
+ # The accumulated binding joined with a rebind, widened to the Nominal envelope when `envelope` is set.
1812
+ #
1813
+ # `current` is nil when the name was introduced inside a retrying arm. Such a local is nil on the first entry, and
1814
+ # stays nil past a `begin` whose body never raised, which reaches the exit only through the primary path (a
1815
+ # retrying arm does not join it), so the edge carries `nil` along with the rebind. An instance variable's
1816
+ # first-entry value is unknown rather than nil (another method may set it), so it takes the rebind alone.
1817
+ def retry_widened_type(current, post, kind, envelope)
1818
+ rebind = envelope ? nominal_envelope_for(post) : post
1819
+ return rebind if current.nil? && kind == :ivar
1820
+ return Type::Combinator.union(Type::Combinator.constant_of(nil), rebind) if current.nil?
1821
+
1822
+ joined = Type::Combinator.union(current, rebind)
1823
+ envelope ? nominal_envelope_for(joined) : joined
1091
1824
  end
1092
1825
 
1093
1826
  # Nominal envelope of a value type: widens Constant / Tuple / HashShape carriers to the underlying class's
1094
1827
  # `Nominal`, preserving everything else (`Nominal`, `Union` of non-shape members, `Top`, `Dynamic`, `Bot`). Union
1095
- # members are walked individually.
1828
+ # members are walked individually. `nil`, `true` and `false` are the only values of their classes, so their
1829
+ # Constant already is the envelope; `Nominal[FalseClass] | Nominal[TrueClass]` would not be accepted where `bool`
1830
+ # is declared.
1096
1831
  def nominal_envelope_for(type)
1097
1832
  members = type.is_a?(Type::Union) ? type.members : [type]
1098
1833
  widened = members.map { |m| nominal_envelope_member(m) }
1099
1834
  Type::Combinator.union(*widened)
1100
1835
  end
1101
1836
 
1837
+ SINGLE_VALUE_CONSTANTS = [nil, true, false].freeze
1838
+ private_constant :SINGLE_VALUE_CONSTANTS
1839
+
1102
1840
  def nominal_envelope_member(member)
1103
1841
  case member
1104
1842
  when Type::Constant
1843
+ return member if SINGLE_VALUE_CONSTANTS.include?(member.value)
1844
+
1105
1845
  Type::Combinator.nominal_of(member.value.class.name)
1106
1846
  when Type::Tuple
1107
1847
  MutationWidening.widen_tuple(member)
@@ -1112,12 +1852,19 @@ module Rigor
1112
1852
  end
1113
1853
  end
1114
1854
 
1115
- def collect_rescue_chain_results(rescue_node, entry_scope)
1855
+ # `edge`, when given, collects the scope at each of its `retry`s ({#retry_scope_recorder}) and the post-scope of
1856
+ # each arm holding one. The latter still carries a rebind the `retry` itself does not see: a write an `ensure`
1857
+ # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
1858
+ # types (`log(tries < 5 ? retry : :gave_up)`).
1859
+ def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil)
1860
+ on_enter = edge ? retry_scope_recorder(edge) : @on_enter
1116
1861
  results = []
1117
1862
  current = rescue_node
1118
1863
  while current
1119
1864
  rescue_scope = bind_rescue_reference(current, entry_scope)
1120
- results << [eval_branch_or_nil(current.statements, rescue_scope), current]
1865
+ arm = eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
1866
+ edge.retry_scopes << arm.last if edge&.retrying_arms&.include?(current)
1867
+ results << [arm, current]
1121
1868
  current = current.subsequent
1122
1869
  end
1123
1870
  results
@@ -1127,25 +1874,54 @@ module Rigor
1127
1874
  eval_branch_or_nil(node.statements, scope)
1128
1875
  end
1129
1876
 
1877
+ # Issue #1360 — an `ensure` clause runs after the body or a rescue clause finished, but also after one raised,
1878
+ # when `$!` is the exception in flight and a subprocess the body would have run has not set `$?`. So the clause
1879
+ # reads `$!`, `$@` and `$?` unbound, whatever it enters with. It cannot write them, so past the clause they are
1880
+ # what it entered with — the scope of a `begin` that finished, the only one the code after it runs from — unless
1881
+ # it ran a subprocess itself.
1882
+ #
1883
+ # Issue #1415 — the clause also runs after a `return`, `break` or `next` out of the body, from a scope other than
1884
+ # the one it enters with here, so a bound `$_` reads `Dynamic[top]` in it (`while gets; return $_ if …; end`
1885
+ # ensures with the line, not the loop's `nil`). Past the clause `$_` is what it entered with, but only where the
1886
+ # clause left that `Dynamic[top]` in place and cannot set `$_` itself: a call the statement rules forget `$_` for
1887
+ # (a closure of the frame, `binding`, a forwarded block) leaves it unbound, and the clause's scope then stands.
1130
1888
  def eval_ensure(node)
1131
- eval_branch_or_nil(node.statements, scope)
1889
+ entry = scope.forget_error_info.forget_last_status
1890
+ line = scope.global(:$_)
1891
+ entry = entry.untyped_last_line if line
1892
+ type, after = eval_branch_or_nil(node.statements, entry)
1893
+ after = after.with_global(:$_, line) if line && last_line_kept?(after, node)
1894
+ [type, LastStatus.restore_unless_set(ErrorInfo.restore(after, scope), scope)]
1895
+ end
1896
+
1897
+ # True when the clause's scope `after` still binds `$_` to the `Dynamic[top]` the entry gave it and the clause
1898
+ # holds nothing that may set it ({LastLine.may_set?}).
1899
+ def last_line_kept?(after, node)
1900
+ after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
1132
1901
  end
1133
1902
 
1134
1903
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
1135
1904
  # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
1136
1905
  # 1), reflecting the common case where no `break VALUE` is observed.
1137
1906
  def eval_loop(node)
1138
- _pred_type, post_pred = sub_eval(node.predicate, scope)
1907
+ _pred_type, post_pred = sub_eval(node.predicate, loop_entry_scope(node))
1908
+ post_pred = widen_predicate_pins(node, post_pred)
1139
1909
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
1140
1910
 
1141
1911
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
1142
1912
  # fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
1143
- # Tuple), body-introduced locals' nil-injection, and the loop value itself. The fixpoint then OVERLAYS only the
1144
- # rebound-local bindings it corrects.
1913
+ # Tuple), body-introduced locals' nil-injection, an instance variable's rebind, and the loop value itself. The
1914
+ # fixpoint then OVERLAYS only the rebound-local bindings it corrects.
1915
+ #
1916
+ # Like every fixpoint pass, it enters the body on the predicate's loop-entry edge ({#loop_pass_entry}): the
1917
+ # body of `while (line = gets)` reads `line` as `String`, and the body of `while gets` reads `$_` as one
1918
+ # (issue #1359), also when the body rebinds no local and this is the only pass. A `begin … end while` body runs
1919
+ # once before the predicate is first tested, so its pass enters from the post-predicate scope unnarrowed.
1145
1920
  #
1146
- # The pass runs under a break sink so a `break`-path binding (`flag = true; break`) the fall-through
1147
- # `body_scope` drops is collected for the continuation join below.
1148
- break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
1921
+ # The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
1922
+ # superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
1923
+ jumps = loop_jumps(node.statements)
1924
+ body_scope, first_breaks = single_pass(node, post_pred, jumps)
1149
1925
  base_scope = join_with_nil_injection(post_pred, body_scope)
1150
1926
 
1151
1927
  rebound, body_first = loop_body_local_writes(node.statements, post_pred)
@@ -1159,34 +1935,97 @@ module Rigor
1159
1935
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
1160
1936
  end
1161
1937
 
1162
- post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
1163
- # Recover `break`-path bindings the fall-through dropped (`flag = true; break` -> `flag` is `false | true`, not
1164
- # the stale `false`).
1165
- post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
1938
+ post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
1939
+ # A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
1940
+ # entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
1941
+ # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
1942
+ # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
1943
+ post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
1166
1944
  post_loop = narrow_loop_exit_edge(node, post_loop)
1167
1945
  [Type::Combinator.constant_of(nil), post_loop]
1168
1946
  end
1169
1947
 
1948
+ # Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
1949
+ # so neither the predicate nor any pass over the body reads a `$_` narrowing from before the loop. A `while
1950
+ # gets` predicate narrows it afresh.
1951
+ #
1952
+ # Issue #1429 — nor a guard's narrowing of a global or constant, when the body or the predicate, which run again
1953
+ # after each iteration, may rebind it by a write or a call ({#forget_guard_if_rebinds}).
1954
+ def loop_entry_scope(node)
1955
+ forget_guard_if_rebinds(LastLine.forget_if_set(scope, node.statements), node.statements, node.predicate)
1956
+ end
1957
+
1958
+ # Issue #1429 — `entry` with its guard narrowings restored when any of `nodes` may rebind a global or constant
1959
+ # ({GuardRebinding.may_rebind?}): the loop and retry back edges and a rescue clause, which runs after any prefix
1960
+ # of the body, reach the code again after such a node ran.
1961
+ def forget_guard_if_rebinds(entry, *nodes)
1962
+ return entry unless entry.guard_narrowed? && nodes.any? { |node| GuardRebinding.may_rebind?(node, entry) }
1963
+
1964
+ entry.forget_guard_narrowings
1965
+ end
1966
+
1967
+ # {#eval_loop}'s single body pass. It enters on the predicate's loop-entry edge, as every fixpoint pass does,
1968
+ # except a `begin … end while` body, which runs once before the predicate is tested, and a body a `redo`
1969
+ # targets, which runs again without the predicate being tested again.
1970
+ def single_pass(node, post_pred, jumps)
1971
+ entry =
1972
+ if node.begin_modifier? || jumps.redoes
1973
+ post_pred
1974
+ else
1975
+ loop_pass_entry(node, post_pred, NO_LOOP_BINDINGS, NO_LOOP_NAMES, jumps)
1976
+ end
1977
+ loop_iteration(node.statements, entry, jumps)
1978
+ end
1979
+
1980
+ # A `while` / `until` predicate runs before every iteration and once more to leave, but the walk evaluates it
1981
+ # once, from the scope before the loop. A variable it writes therefore holds its first evaluation's value, and a
1982
+ # value pin there is a claim about that evaluation alone when a later evaluation can store something else: when
1983
+ # the predicate reads the variable it writes (`i = 0; while (i += 1) < 3; end` pinned `i` to `1`, and the exit
1984
+ # edge `i >= 3` contradicted the pin and left `i` as `bot`), or reads one the body rebinds (`while check(k = i *
1985
+ # 2); i += 1; end`). Each such binding is widened past its value pin (`Type::Combinator.widen_value_pinned`), as
1986
+ # the loop fixpoint widens a body's rebinds; a write that reads neither stores the same answer every time and
1987
+ # keeps it (`until line = (flag ? "x" : nil)` still exits on `"x"`). Issue #1223 made the shape common: a write
1988
+ # nested in a predicate's call operand was not threaded at all before it.
1989
+ def widen_predicate_pins(node, post_pred)
1990
+ written = OperandEffects.written_variables(node.predicate)
1991
+ return post_pred if written.empty?
1992
+
1993
+ reads = OperandEffects.read_variables(node.predicate)
1994
+ body_writes = OperandEffects.written_variables(node.statements)
1995
+ varying = reads.intersect?(body_writes) ? written : written & reads
1996
+ varying.reduce(post_pred) do |acc, name|
1997
+ current = CapturedLocals.bound_type(acc, name)
1998
+ next acc if current.nil?
1999
+
2000
+ widened = Type::Combinator.widen_value_pinned(current)
2001
+ widened == current ? acc : CapturedLocals.bind(acc, name, widened)
2002
+ end
2003
+ end
2004
+
1170
2005
  # The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
1171
- # `base_scope`, then the slice-C receiver-content writeback.
1172
- def converged_loop_scope(node, post_pred, base_scope, names, body_first)
2006
+ # `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
2007
+ # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
2008
+ def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
1173
2009
  # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
1174
2010
  # single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
1175
2011
  # `1 | 2`, never reaching `4, 8`). Fold each body-written local's continuation binding through the same capped
1176
2012
  # fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
1177
2013
  # post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
1178
2014
  # degrades it to `T | nil`, matching the nil-injection treatment.
1179
- result = loop_rebind_fixpoint(node, post_pred, names, body_first)
2015
+ break_pass = jumps.breaks && { entry: nil, arms: [] }
2016
+ result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
1180
2017
  # Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
1181
2018
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
1182
2019
  # constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
1183
- record_converged_loop_body(node, post_pred, result, names, body_first)
2020
+ record_converged_loop_body(node, post_pred, result, names, body_first, jumps, break_pass)
1184
2021
  post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
1185
2022
  # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
1186
2023
  # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
1187
2024
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
1188
2025
  # from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
1189
- loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
2026
+ post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
2027
+ arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2028
+ join_break_scopes(post_loop, arms, names)
1190
2029
  end
1191
2030
 
1192
2031
  # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
@@ -1220,33 +2059,35 @@ module Rigor
1220
2059
  found
1221
2060
  end
1222
2061
 
1223
- # A `break` inside one of these nested constructs targets the inner construct (an inner loop, a block's method, a
1224
- # nested def), NOT the lexical loop — so the directly-targeting break scan does not descend into them.
1225
- BREAK_BOUNDARY_NODES = [
1226
- Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
1227
- Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
1228
- Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
1229
- ].freeze
1230
- private_constant :BREAK_BOUNDARY_NODES
1231
-
1232
- # The `BreakNode`s that lexically target THIS loop — reachable from the body without crossing a nested loop /
1233
- # block / def boundary. An identity-keyed Hash used as a membership set to filter the collected break scopes (the
1234
- # thread-local sink also collects breaks from nested blocks that did not install their own sink).
1235
- def directly_targeting_breaks(statements)
1236
- found = {}.compare_by_identity
1237
- collect_direct_breaks(statements, found)
1238
- found
1239
- end
2062
+ # The jumps that target a loop body ({JumpTargets}): its `next`s and its `break`s, each an identity-keyed Hash
2063
+ # used as a membership set, or nil when the body has none, and whether a `redo` does (issue #1359: the body then
2064
+ # runs again without the predicate being tested, {#single_pass}). The sinks also collect jumps that belong to a
2065
+ # construct evaluated under the loop's collection without installing its own (a `->` body), and the consumers
2066
+ # filter against these sets.
2067
+ LoopJumps = Data.define(:nexts, :breaks, :redoes)
2068
+ private_constant :LoopJumps
1240
2069
 
1241
- def collect_direct_breaks(node, found)
1242
- return if node.nil?
2070
+ NO_LOOP_JUMPS = LoopJumps.new(nexts: nil, breaks: nil, redoes: false)
2071
+ # In {JumpTargets.kinds}' bit order: `next` 0b001, `break` 0b010, `redo` 0b100.
2072
+ LOOP_JUMP_CLASSES = [Prism::NextNode, Prism::BreakNode, Prism::RedoNode].freeze
2073
+ private_constant :NO_LOOP_JUMPS, :LOOP_JUMP_CLASSES
1243
2074
 
1244
- found[node] = true if node.is_a?(Prism::BreakNode)
1245
- node.rigor_each_child do |child|
1246
- next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
2075
+ NO_BREAK_ARMS = [].freeze
2076
+ private_constant :NO_BREAK_ARMS
1247
2077
 
1248
- collect_direct_breaks(child, found)
1249
- end
2078
+ # The rebind assumptions and body-first names of the single body pass, which overlays none ({#eval_loop}).
2079
+ NO_LOOP_BINDINGS = {}.freeze
2080
+ NO_LOOP_NAMES = [].freeze
2081
+ private_constant :NO_LOOP_BINDINGS, :NO_LOOP_NAMES
2082
+
2083
+ # A body with no targeting jump pays two allocation-free scans.
2084
+ def loop_jumps(statements)
2085
+ kinds = JumpTargets.kinds(statements, LOOP_JUMP_CLASSES)
2086
+ return NO_LOOP_JUMPS if kinds.zero?
2087
+
2088
+ nexts = JumpTargets.of(statements, Prism::NextNode) if kinds.anybits?(0b001)
2089
+ breaks = JumpTargets.of(statements, Prism::BreakNode) if kinds.anybits?(0b010)
2090
+ LoopJumps.new(nexts: nexts, breaks: breaks, redoes: kinds.anybits?(0b100))
1250
2091
  end
1251
2092
 
1252
2093
  # Installs a fresh thread-local break sink around `yield` (a loop-body evaluation), returning `[collected,
@@ -1264,31 +2105,81 @@ module Rigor
1264
2105
  [sink, result]
1265
2106
  end
1266
2107
 
1267
- # Runs a loop body's single pass under a break sink. Returns the directly-targeting break set, the collected break
1268
- # scopes, and the fall-through body scope — the three inputs the continuation's {#join_break_scopes} needs. Shared
1269
- # by `eval_loop` and `eval_for`.
1270
- def capture_loop_body_breaks(statements, entry)
1271
- targets = directly_targeting_breaks(statements)
1272
- sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
1273
- [targets, sink, body_scope]
2108
+ # One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with, and the
2109
+ # scopes at the `break`s that target the loop ({LoopJumps}). Every reader of a loop body goes through here — the
2110
+ # single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
2111
+ # pass.
2112
+ #
2113
+ # A `next` returns to the predicate as surely as falling off the end does, so `exit` is the fall-through joined
2114
+ # with the scope at every `next` that targets the loop. Without that join a rebind on a jumping branch (`if
2115
+ # i.odd?; w = i; next; end`) vanished — `eval_if` carries only the arm that falls through — and `w` kept its
2116
+ # pre-loop binding. The join nil-injects: a local first bound on a `next` path is unbound on the fall-through, and
2117
+ # a plain `Scope#join` would drop it and leave the fixpoint only its `nil` seed.
2118
+ #
2119
+ # The `next` scopes are collected into a sink threaded through `sub_eval` ({#evaluate_invocation} does the same
2120
+ # for a block), and the `break` scopes into a thread-local one, each installed only when the body has such a
2121
+ # jump. `recorded: false` evaluates without recording into the per-node scope index.
2122
+ def loop_iteration(statements, entry, jumps, recorded: true)
2123
+ next_sink = jumps.nexts && []
2124
+ recording = recorded ? {} : UNRECORDED
2125
+ evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
2126
+ if jumps.breaks
2127
+ break_sink, fall_through = collect_break_scopes(&evaluate)
2128
+ breaks = targeted_scopes(break_sink, jumps.breaks)
2129
+ else
2130
+ fall_through = evaluate.call
2131
+ breaks = NO_BREAK_ARMS
2132
+ end
2133
+ return [fall_through, breaks] if next_sink.nil?
2134
+
2135
+ exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
2136
+ join_with_nil_injection(acc, next_scope)
2137
+ end
2138
+ [exit_scope, breaks]
2139
+ end
2140
+
2141
+ # The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
2142
+ # and is no input to the rebind fixpoint; it IS the continuation's binding on that path. The arms must come from a
2143
+ # pass whose entry is the CONVERGED binding, which contains every iteration's entry: read from the first pass
2144
+ # alone, a `break` whose branch is dead before any loop-carried rebind has moved (`break(flag = true) if i == 2`
2145
+ # while `i` is still `1`) was never reached and `flag` stayed `false`.
2146
+ #
2147
+ # The fixpoint's last pass is usually one — a fixpoint that stabilised ran it from the binding it returns — so
2148
+ # its arms are reused ({#loop_body_exit_bindings}). A capped fixpoint's widened binding was never evaluated, so
2149
+ # only then does one more pass run, without recording into the per-node scope index: that index keeps the
2150
+ # fixpoint's own last pass, which the check path's diagnostics read. On the display path the re-record pass
2151
+ # ({#record_converged_loop_body}) already ran from the converged binding and leaves its arms here, so no
2152
+ # unrecorded pass follows it. The block write-back reads its `break` arms the same way
2153
+ # ({#join_block_break_bindings}).
2154
+ #
2155
+ # The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
2156
+ # inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
2157
+ # fixpoint's own passes never evaluated.
2158
+ def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
2159
+ return NO_BREAK_ARMS if break_pass.nil?
2160
+ return break_pass[:arms] if break_pass[:entry] == converged.except(*body_first)
2161
+
2162
+ entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
2163
+ loop_iteration(node.statements, entry, jumps, recorded: false).last
1274
2164
  end
1275
2165
 
1276
- # Joins each directly-targeting break's body-written local bindings into the loop continuation, so a `break`-path
1277
- # binding the fall-through dropped is recovered (`flag = true; break` -> `flag` becomes `false | true`). Only
2166
+ # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
2167
+ # fall-through dropped is recovered (`flag = true; break` -> `flag` becomes `false | true`). Only
1278
2168
  # loop-body-written names are joined — an unchanged local unions to itself; a break-only-written local is already
1279
- # present via the fixpoint / nil-injection seed, so the union reflects its break value.
1280
- def join_break_scopes(continuation, sink, targeting, names)
1281
- return continuation if sink.empty? || names.empty?
1282
-
1283
- breaks = sink.select { |(node, _scope)| targeting.key?(node) }
1284
- breaks.reduce(continuation) do |cont, (_node, break_scope)|
2169
+ # present via the fixpoint / nil-injection seed, so the union reflects its break value. A name whose continuation
2170
+ # binding is `Dynamic[top]` — the fixpoint's floor on non-convergence, or a local that was untyped already —
2171
+ # keeps it: a precise arm unioned into it would read as knowledge the analysis does not have.
2172
+ def join_break_scopes(continuation, breaks, names)
2173
+ return continuation if breaks.empty? || names.empty?
2174
+
2175
+ floor = Type::Combinator.untyped
2176
+ breaks.reduce(continuation) do |cont, break_scope|
1285
2177
  names.reduce(cont) do |acc, name|
1286
2178
  break_value = break_scope.local(name)
1287
- next acc if break_value.nil?
1288
-
1289
2179
  current = acc.local(name)
1290
- joined = current ? Type::Combinator.union(current, break_value) : break_value
1291
- acc.with_local(name, joined)
2180
+ next acc if break_value.nil? || current == floor
2181
+
2182
+ acc.with_local(name, current ? Type::Combinator.union(current, break_value) : break_value)
1292
2183
  end
1293
2184
  end
1294
2185
  end
@@ -1324,9 +2215,14 @@ module Rigor
1324
2215
  end
1325
2216
  return post_loop if mutations.empty?
1326
2217
 
2218
+ rewrites = local_rewrites(statements) { true }
1327
2219
  mutations.reduce(post_loop) do |acc, (name, calls)|
1328
- joined = join_content_for_local(name, calls, content_seed_scope(name, acc, pre_body, rebound), post_loop)
1329
- joined.nil? ? acc : acc.with_local(name, joined)
2220
+ seed_scope = content_seed_scope(name, acc, pre_body, rebound)
2221
+ seed = lookup_mutated_seed(statements, name, seed_scope.local(name)) { |depth, nesting| depth == nesting }
2222
+ joined = join_content_for_param(calls, seed, post_loop)
2223
+ next acc if joined.nil?
2224
+
2225
+ acc.with_mutated_local(name, rewritten_capture(joined, seed, rewrites.fetch(name, NO_REWRITES)))
1330
2226
  end
1331
2227
  end
1332
2228
 
@@ -1341,26 +2237,30 @@ module Rigor
1341
2237
 
1342
2238
  # Re-evaluates the loop body once from the converged fixpoint bindings, solely for the `on_enter` side effect of
1343
2239
  # re-recording the body's per-node entry scopes. Gated behind the display-path-only `converged_loop_recording`
1344
- # flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift.
1345
- def record_converged_loop_body(node, post_pred, bindings, names, body_first)
2240
+ # flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift. The pass leaves
2241
+ # its `break` arms in `break_pass`, which {#loop_break_arms} then reuses instead of running a pass of its own.
2242
+ def record_converged_loop_body(node, post_pred, bindings, names, body_first, jumps, break_pass)
1346
2243
  return unless @converged_loop_recording && @on_enter
1347
2244
 
1348
- loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
2245
+ loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
1349
2246
  nil
1350
2247
  end
1351
2248
 
1352
2249
  # Runs the slice-B loop-body rebind fixpoint, returning the per-name continuation binding. Seed: a pre-existing
1353
2250
  # local seeds with its post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the
1354
2251
  # 0-iteration path (the body may never run) degrades it to `T | nil`, matching the historical nil-injection
1355
- # treatment.
1356
- def loop_rebind_fixpoint(node, post_pred, names, body_first)
2252
+ # treatment. `break_pass` rides along so every pass leaves its `break` arms for {#loop_break_arms}.
2253
+ def loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
1357
2254
  nil_const = Type::Combinator.constant_of(nil)
1358
2255
  seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
2256
+ evaluate_body = lambda do |bindings|
2257
+ loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
2258
+ end
1359
2259
  BodyFixpoint.converge(
1360
2260
  names: names,
1361
2261
  seed_bindings: seed,
1362
2262
  widen: Type::Combinator.method(:widen_value_pinned),
1363
- evaluate_body: ->(bindings) { loop_body_exit_bindings(node, post_pred, bindings, names, body_first) }
2263
+ evaluate_body: evaluate_body
1364
2264
  )
1365
2265
  end
1366
2266
 
@@ -1400,40 +2300,102 @@ module Rigor
1400
2300
  # exists only to model the 0-iteration path and is kept as a join constituent by {BodyFixpoint#converge}; feeding
1401
2301
  # that `nil` back into the body re-evaluation would leak it past a condition-form assignment the engine does not
1402
2302
  # thread into the branch (`if exps.size > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
1403
- def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
2303
+ #
2304
+ # The exit joins the pass's `next` exits ({#loop_iteration}), so a `next`-path rebind feeds the next iteration.
2305
+ # With a `break_pass` record the pass also leaves its entry and its `break` arms there for {#loop_break_arms};
2306
+ # `BodyFixpoint` hands every pass the same mutable assumption, and `except` copies it before the fixpoint moves
2307
+ # it.
2308
+ def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
2309
+ entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2310
+ exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
2311
+ if break_pass
2312
+ break_pass[:entry] = bindings.except(*body_first)
2313
+ break_pass[:arms] = breaks
2314
+ end
2315
+ names.to_h { |name| [name, exit_scope.local(name)] }
2316
+ end
2317
+
2318
+ # The scope one fixpoint pass enters the body with: `post_pred` overlaid with the pre-existing names' running
2319
+ # assumption, then narrowed by the predicate's loop-entry edge ({#loop_body_exit_bindings} carries the why).
2320
+ def loop_pass_entry(node, post_pred, bindings, body_first, jumps)
1404
2321
  overlaid = bindings.except(*body_first)
1405
2322
  entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
2323
+ entry = loop_content_entry(node.statements, post_pred, entry)
1406
2324
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
1407
- body_entry = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
1408
- _type, exit_scope = sub_eval(node.statements, body_entry)
1409
- names.to_h { |name| [name, exit_scope.local(name)] }
2325
+ edge = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
2326
+ # Issue #1359 — a `redo` re-enters the body without testing the predicate again, so a `$_` the predicate
2327
+ # narrowed does not hold there when the body may set it.
2328
+ return edge unless jumps.redoes
2329
+
2330
+ LastLine.forget_if_set(edge, node.statements)
2331
+ end
2332
+
2333
+ # Issue #1412 — the loop-body counterpart of {#repeating_block_entry}: `entry` with every local the body
2334
+ # mutates in place ({CapturedLocals.loop_content_mutations}, bound in `base`) at its unknown-store widening
2335
+ # ({#unknown_store_binding}). A body runs again after it ran, so a later iteration reads what an earlier one
2336
+ # stored, and every pass over it — the single pass and each fixpoint pass ({#loop_pass_entry}), and a `for`
2337
+ # body's only pass — enters this way. A name the pass also rebinds is widened over its running assumption, as
2338
+ # the block write-back widens it ({#capture_pass_bindings}); a name `entry` does not bind is left alone. The
2339
+ # widening keeps the binding's issue #1287 marks, as {#block_pass_entry} keeps them.
2340
+ def loop_content_entry(statements, base, entry)
2341
+ stores = loop_content_mutations(statements, base)
2342
+ return entry if stores.empty?
2343
+
2344
+ stores.reduce(entry) do |acc, (name, sites)|
2345
+ type = acc.local(name)
2346
+ type.nil? ? acc : acc.with_mutated_local(name, unknown_store_binding(type, sites))
2347
+ end
2348
+ end
2349
+
2350
+ # {CapturedLocals.loop_content_mutations} of a loop body against `base`, once per pair: every pass of one
2351
+ # loop asks with the same body and the same post-predicate scope. The slot is keyed on both, so a body asked
2352
+ # against another scope is scanned again.
2353
+ def loop_content_mutations(statements, base)
2354
+ memo = (@loop_content_mutations ||= {}.compare_by_identity)[statements]
2355
+ return memo[1] if memo && memo[0].equal?(base)
2356
+
2357
+ stores = CapturedLocals.loop_content_mutations(statements, base)
2358
+ @loop_content_mutations[statements] = [base, stores]
2359
+ stores
1410
2360
  end
1411
2361
 
1412
2362
  # `for index in collection; body; end`. Unlike `each {}` blocks, `for` does NOT create a new variable scope: the
1413
2363
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
1414
2364
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
1415
2365
  # `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
1416
- # expression itself types as `Constant[nil]` (the common case where no `break VALUE` is observed), matching the
1417
- # policy `eval_loop` uses for `while` / `until`.
2366
+ # expression itself types as `Constant[nil]`, the policy `eval_loop` uses for `while` / `until` — a known gap for
2367
+ # `for`, whose value in Ruby is the collection it iterated (issue #1216).
1418
2368
  def eval_for(node)
1419
2369
  coll_type, post_coll = sub_eval(node.collection, scope)
1420
2370
  element_type = for_iteration_element_type(coll_type)
1421
- body_entry = bind_for_index(node.index, element_type, post_coll)
2371
+ # Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
2372
+ body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
2373
+ body_entry = forget_guard_for_body(node, post_coll, body_entry)
2374
+ body_entry = loop_content_entry(node.statements, post_coll, body_entry)
1422
2375
 
1423
2376
  if node.statements.nil?
1424
2377
  return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
1425
2378
  end
1426
2379
 
1427
- # Run the body pass under a break sink so a `break`-path binding the fall-through drops is recovered into the
1428
- # continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single-pass join is
1429
- # the only continuation).
1430
- break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
2380
+ # The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
2381
+ # the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
2382
+ # the only continuation and the only source of `break` arms).
2383
+ jumps = loop_jumps(node.statements)
2384
+ body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
1431
2385
  continuation = join_with_nil_injection(post_coll, body_scope)
1432
2386
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
1433
- continuation = join_break_scopes(continuation, break_sink, break_targets, pre_existing + body_first)
2387
+ continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
1434
2388
  [Type::Combinator.constant_of(nil), continuation]
1435
2389
  end
1436
2390
 
2391
+ # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
2392
+ def forget_guard_for_body(node, post_coll, body_entry)
2393
+ return body_entry unless body_entry.guard_narrowed?
2394
+ return body_entry.forget_guard_narrowings if GuardRebinding.implicit_call_may_rebind?(node, post_coll)
2395
+
2396
+ forget_guard_if_rebinds(body_entry, node.statements)
2397
+ end
2398
+
1437
2399
  # `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
1438
2400
  # block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
1439
2401
  # structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
@@ -1447,7 +2409,8 @@ module Rigor
1447
2409
  receiver_type: coll_type,
1448
2410
  method_name: :each,
1449
2411
  arg_types: [],
1450
- environment: scope.environment
2412
+ environment: scope.environment,
2413
+ scope: scope
1451
2414
  )
1452
2415
  return structural if block_params.nil? || block_params.empty?
1453
2416
 
@@ -1459,18 +2422,38 @@ module Rigor
1459
2422
  # Binds the `for` index variable(s) into `scope`. A single `LocalVariableTargetNode` is bound to `element_type`
1460
2423
  # (the per-iteration value the collection yields). A `MultiTargetNode` (`for a, b in pairs`) delegates to
1461
2424
  # {MultiTargetBinder}, which decomposes a tuple-shaped element into the inner slots.
2425
+ #
2426
+ # An index target — the whole index (`for h[:a] in xs`) or a slot of a multi-target one (`for h[:a], w in
2427
+ # pairs`) — stores the element / its slot through `[]=` at the top of every iteration, so its receiver widens
2428
+ # here, before the body, exactly as a multi-assign target's does; the body then reads the widened receiver and
2429
+ # the post-loop join keeps it beside the zero-iteration literal, as it keeps a body store's `h[:a] = x`.
1462
2430
  def bind_for_index(index_node, element_type, scope)
1463
2431
  case index_node
1464
2432
  when Prism::LocalVariableTargetNode
1465
2433
  scope.with_local(index_node.name, element_type)
2434
+ when Prism::IndexTargetNode
2435
+ widen_index_target(index_node, element_type, scope, type_scope: scope)
1466
2436
  when Prism::MultiTargetNode
1467
- MultiTargetBinder.bind(index_node, element_type)
1468
- .reduce(scope) { |s, (name, type)| s.with_local(name, type) }
2437
+ bound = MultiTargetBinder.bind_marked(index_node, element_type, scope: scope)
2438
+ widen_index_targets(bound, bound.apply_to(scope), type_scope: scope)
2439
+ when Prism::SplatNode
2440
+ bind_for_splat_index(index_node, scope)
1469
2441
  else
1470
2442
  scope
1471
2443
  end
1472
2444
  end
1473
2445
 
2446
+ # `for *h[:a] in pairs` — Prism gives a bare splat index as a `SplatNode`, not a `MultiTargetNode`, so the
2447
+ # binder never sees it. The store is `*h[:a] = element`, an array of the element's `to_ary` parts; the receiver
2448
+ # widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. A bare `*name` target stays
2449
+ # unbound here, as before.
2450
+ def bind_for_splat_index(splat, scope)
2451
+ target = splat.expression
2452
+ return scope unless target.is_a?(Prism::IndexTargetNode)
2453
+
2454
+ widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
2455
+ end
2456
+
1474
2457
  # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
1475
2458
  # elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
1476
2459
  # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
@@ -1531,45 +2514,178 @@ module Rigor
1531
2514
  # `branch_terminates?`) — the post-OR / post-AND scope is the LHS-skipped edge alone: `a or raise` only survives
1532
2515
  # when `a` was truthy, so subsequent statements observe `a` narrowed to its truthy fragment; the symmetric `a and
1533
2516
  # raise` survives only when `a` was falsey. Same shape as the `eval_if` / `eval_unless` early-return narrowing.
2517
+ #
2518
+ # This is the only and/or typer: `ExpressionTyper` reads a value-position `&&` / `||` from here (issue #1016),
2519
+ # so the RHS narrowing and the constant short-circuit below cannot differ between a statement and a value.
1534
2520
  def eval_and_or(node)
1535
- left_type, left_scope = sub_eval(node.left, scope)
1536
- truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
1537
- rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
1538
- right_type, right_scope = sub_eval(node.right, rhs_entry)
2521
+ and_or_with_edges(node, edges: false)
2522
+ end
2523
+
2524
+ # {#eval_and_or}, and with `edges:` also the operator's truthy and falsey edge scopes as a predicate
2525
+ # ({#eval_with_edges}), read off the scopes its operands left rather than off the joined result.
2526
+ def and_or_with_edges(node, edges:)
2527
+ and_node = node.is_a?(Prism::AndNode)
2528
+ left_type, left_scope, truthy_left, falsey_left = eval_with_edges(node.left, scope)
2529
+ right_entry = and_node ? truthy_left : falsey_left
2530
+ right_type, right_scope, truthy_right, falsey_right =
2531
+ edges ? eval_with_edges(node.right, right_entry) : sub_eval(node.right, right_entry)
2532
+ skipped_type = and_node ? Narrowing.narrow_falsey(left_type) : Narrowing.narrow_truthy(left_type)
1539
2533
 
2534
+ # Control never reaches any statement after `a or raise` via the RHS edge — the RHS scope is discarded.
1540
2535
  if branch_terminates?(node.right, right_type)
1541
- # Control never reaches any statement after `a or raise` via the RHS edge — the RHS scope is discarded.
1542
- surviving_type =
1543
- if node.is_a?(Prism::AndNode)
1544
- Narrowing.narrow_falsey(left_type)
1545
- else
1546
- Narrowing.narrow_truthy(left_type)
1547
- end
1548
- surviving_scope = node.is_a?(Prism::AndNode) ? falsey_left : truthy_left
1549
- return [surviving_type, surviving_scope]
2536
+ skipped_scope = and_node ? falsey_left : truthy_left
2537
+ return [skipped_type, skipped_scope, *(Narrowing.predicate_scopes(node, skipped_scope) if edges)]
1550
2538
  end
1551
2539
 
1552
- skipped_type =
1553
- if node.is_a?(Prism::AndNode)
1554
- Narrowing.narrow_falsey(left_type)
1555
- else
1556
- Narrowing.narrow_truthy(left_type)
1557
- end
1558
- [
1559
- Type::Combinator.union(skipped_type, right_type),
1560
- join_with_nil_injection(left_scope, right_scope)
1561
- ]
2540
+ # A dead RHS is still evaluated and its scope still joins, so a write inside it nil-injects exactly as
2541
+ # before; only its value is dropped, because it cannot be the value of the expression.
2542
+ joined_scope = join_with_nil_injection(left_scope, right_scope)
2543
+ type = skipped_type
2544
+ type = Type::Combinator.union(skipped_type, right_type) unless right_operand_dead?(node, left_type, left_scope)
2545
+ return [type, joined_scope] unless edges
2546
+
2547
+ ran = ran_edge(node, right_scope, and_node ? truthy_right : falsey_right, and_node)
2548
+ return [type, joined_scope, ran, join_with_nil_injection(falsey_left, falsey_right)] if and_node
2549
+
2550
+ [type, joined_scope, join_with_nil_injection(truthy_left, truthy_right), ran]
2551
+ end
2552
+
2553
+ # The edge on which the right operand certainly ran — `&&`'s truthy one, `||`'s falsey one. It is the right
2554
+ # operand's own edge, which keeps every binding and provenance mark the operands' writes made, with the
2555
+ # instance variables and globals of the whole operator narrowed over the scope the right operand left laid over
2556
+ # it. A call in the right operand resets the instance variables and regex globals the left operand narrowed
2557
+ # (`@parent && (node = find_node)` read `@parent` as nilable, `$1` after `line =~ re && (k = Integer($2))` as
2558
+ # nil), and narrowing afresh puts that back as the joined-scope narrowing always did. No call resets a local, so
2559
+ # locals keep the right operand's edge: re-narrowing one the operands write can contradict its new value
2560
+ # (`x.nil? && log(x = "d") && ok` read `x` as `bot`) or drop the marks its write stamped (a published-constant
2561
+ # copy `m = AppConfig::MODE`). A variable the operands write is never overlaid, for the same reason.
2562
+ def ran_edge(node, right_scope, right_edge, and_node)
2563
+ truthy, falsey = Narrowing.predicate_scopes(node, right_scope)
2564
+ renarrowed = and_node ? truthy : falsey
2565
+ written = OperandEffects.written_variables(node)
2566
+ edge = renarrowed.ivars.reduce(right_edge) do |acc, (name, type)|
2567
+ written.include?(name) || acc.ivar(name) == type ? acc : acc.with_ivar(name, type)
2568
+ end
2569
+ renarrowed.globals.reduce(edge) do |acc, (name, type)|
2570
+ written.include?(name) || acc.global(name) == type ? acc : acc.with_global(name, type)
2571
+ end
1562
2572
  end
1563
2573
 
1564
- # `(body)`. Threads scope through the inner expression so `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
1565
- def eval_parentheses(node)
1566
- return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2574
+ # `[type, scope, truthy_edge, falsey_edge]` for `node` evaluated from `entry` as a predicate. The edges are
2575
+ # `Narrowing.predicate_scopes` of the scope `node` leaves, except for an `&&` / `||` whose right operand, or
2576
+ # the right operand of an `&&` / `||` on its left, holds a write or jump ({OperandEffects}). The scope such an
2577
+ # operator leaves joins the path where that operand ran with the path that skipped it, so a local the operand
2578
+ # binds reads `nil` there as well, and narrowing that scope cannot tell that the operand certainly ran on the
2579
+ # truthy edge of an `&&` and on the falsey edge of an `||`: `if a && xs.size > (n = f)` read `n` as `nil |
2580
+ # Integer` in the body and reported `n + 1`. Those edges are built from the scopes the operands left instead —
2581
+ # the right operand's edge under the left operand's, and the join where either path reaches the edge. Issue
2582
+ # #1223 threads such a write for every operand, not only for a statement-position one.
2583
+ def eval_with_edges(node, entry)
2584
+ unless and_or_right_effects?(node)
2585
+ type, post = sub_eval(node, entry)
2586
+ return [type, post, *Narrowing.predicate_scopes(node, post)]
2587
+ end
1567
2588
 
1568
- sub_eval(node.body, scope)
2589
+ @on_enter&.call(node, entry)
2590
+ evaluator_at(entry).send(:and_or_with_edges, node, edges: true)
1569
2591
  end
1570
2592
 
1571
- # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
1572
- # does not see the outer locals), and the StatementEvaluator pushes a new `ClassFrame` so nested `def`s know their
2593
+ def and_or_right_effects?(node)
2594
+ return false unless node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
2595
+
2596
+ OperandEffects.any?(node.right) || and_or_right_effects?(node.left)
2597
+ end
2598
+
2599
+ # Whether a genuine `Constant` left operand proves the RHS never supplies the value: `false && b` and
2600
+ # `1 || b`. The gate is `Constant`-only (issue #152 evaluated a wider one and declined it).
2601
+ #
2602
+ # Issue #313 — it MUST decline an optimistically nil-free operand. A uniform-valued literal hash reads as a lone
2603
+ # `Constant` (`UNIFORM[key]` → `1`), so without the mark `UNIFORM[key] || key` would discard the author's
2604
+ # fallback, and `MAP[key].nil? && b` would drop `b` the program runs when the lookup misses. The mark is
2605
+ # resolved against the LEFT operand's post-scope, as `branch_certainty` does for `if`, so a write in the left
2606
+ # operand (`(v = MAP[key]) || key`) is judged by the binding it just made rather than by an older one.
2607
+ def right_operand_dead?(node, left_type, left_scope)
2608
+ return false unless left_type.is_a?(Type::Constant)
2609
+ return false unless optimistic_origin_for(node.left, left_scope).nil?
2610
+
2611
+ truthy = left_type.value ? true : false
2612
+ node.is_a?(Prism::AndNode) ? !truthy : truthy
2613
+ end
2614
+
2615
+ # `(body)`. Threads scope through the inner expression so `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
2616
+ def eval_parentheses(node)
2617
+ return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2618
+
2619
+ sub_eval(node.body, scope)
2620
+ end
2621
+
2622
+ # An array or hash literal, an interpolation or a range: its value is the literal's, typed where it starts, and
2623
+ # its scope is the one its children leave in order ({#thread_operand}). Issue #1223 — `[s = 1]`, `x = [:a, s +=
2624
+ # 1]` and `"#{s = 1}"` left `s` on its pre-write binding. A literal holding no write or jump keeps the entry
2625
+ # scope and costs one scan. Issue #1256 — an element after one that wrote is typed from the scope the elements
2626
+ # before it left ({OperandWalk}), so `[n += 1, n += 1]` is `[1, 2]`.
2627
+ def eval_value_container(node)
2628
+ unless OperandEffects.any?(node)
2629
+ return [scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
2630
+ end
2631
+
2632
+ walk = OperandWalk.new(walk_recorder)
2633
+ after = thread_operand_children(node, scope, walk, scope)
2634
+ [OperandWalk.type_of(scope, node, tracer, walk.types(tracer)), forget_rebound_specials(after, node)]
2635
+ end
2636
+
2637
+ # Issue #1360 — a backtick or `%x` command runs its interpolations, then the subprocess, which leaves `$?` a
2638
+ # `Process::Status` ({LastStatus.after}).
2639
+ def eval_xstring(node)
2640
+ type, after =
2641
+ if node.is_a?(Prism::InterpolatedXStringNode)
2642
+ eval_value_container(node)
2643
+ else
2644
+ [scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
2645
+ end
2646
+ [type, LastStatus.after(node, after, scope)]
2647
+ end
2648
+
2649
+ # `expr rescue alt`. The rescue arm runs only when `expr` raised, possibly after some of its writes, so the arm
2650
+ # starts from the entry scope joined with the scope `expr` leaves, and the result joins the arm's scope with
2651
+ # the one `expr` leaves; an arm that always exits (`rescue next`) contributes no scope. Issue #1223 — `x = foo
2652
+ # rescue (s = 1)` left `s` on its pre-write binding. The value is the modifier's own, and one holding no write
2653
+ # or jump keeps the entry scope.
2654
+ #
2655
+ # Issue #1360 — the arm runs with `$!` bound to the `StandardError` it rescued, `$@` to its backtrace and `$?`
2656
+ # unbound ({ErrorInfo.modifier_entry}), and the modifier leaves, through its end or a jump in the arm, with `$!`
2657
+ # and `$@` restored to what it found. `$?` is unbound past a modifier whose arm may fall through: `expr` may have
2658
+ # raised while a subprocess waited, which leaves it nil. The threaded path gets that from the join with the arm.
2659
+ def eval_rescue_modifier(node)
2660
+ unless OperandEffects.any?(node)
2661
+ return [scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
2662
+ end
2663
+
2664
+ type, after = restoring_error_info { thread_rescue_modifier(node) }
2665
+ [type, forget_rebound_specials(after, node)]
2666
+ end
2667
+
2668
+ def thread_rescue_modifier(node)
2669
+ walk = OperandWalk.new(walk_recorder)
2670
+ after_expression = thread_operand(node.expression, scope, walk, scope)
2671
+ # The arm is threaded outside the walk: its entry nil-injects a local `expr` first binds, which is the
2672
+ # sound join (the raise may come before the write) but reads `u` as `String?` in `Float(u = s) rescue
2673
+ # u.strip`, where the raise almost always comes from `Float` after it. Neither the arm nor anything in it
2674
+ # is recorded or typed from there, which keeps it where it was before #1256: an ADR-5 trade of the rare
2675
+ # raise-before-write path for no false positive on the common one.
2676
+ arm_entry = ErrorInfo.modifier_entry(join_with_nil_injection(scope, after_expression), node.rescue_expression)
2677
+ after_rescue = thread_operand(node.rescue_expression, arm_entry, OperandWalk.new(nil), arm_entry)
2678
+ type = OperandWalk.type_of(scope, node, tracer, walk.types(tracer))
2679
+ after = if branch_unconditionally_exits?(node.rescue_expression)
2680
+ after_expression
2681
+ else
2682
+ join_with_nil_injection(after_expression, after_rescue)
2683
+ end
2684
+ [type, after]
2685
+ end
2686
+
2687
+ # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
2688
+ # does not see the outer locals), and the StatementEvaluator pushes a new `ClassFrame` so nested `def`s know their
1573
2689
  # lexical owner. The outer scope is unchanged on exit because Ruby's class definition does not bind any local in
1574
2690
  # the enclosing scope. The class body's value is the value of its last statement (`Constant[nil]` for an empty
1575
2691
  # body); we discard the body's post-scope.
@@ -1639,14 +2755,155 @@ module Rigor
1639
2755
  #
1640
2756
  # The handler still re-evaluates the block under its entry scope so the per-node scope index sees the bindings on
1641
2757
  # the `on_enter` callback path. Block effects do NOT leak into the post-call scope: a block-local write is
1642
- # observed only inside the block body. The receiver and arguments still observe the outer scope, matching Ruby
1643
- # evaluation order.
2758
+ # observed only inside the block body.
2759
+ #
2760
+ # Issue #1223 — Ruby evaluates the receiver, then the arguments, and only then runs the method, so a write in
2761
+ # an operand (`out << (n += 1)`, `puts(g = e)`, `(seen += 1) == 2`) is visible to the block and to everything
2762
+ # after the call. {#call_operand_scope} threads each operand that holds one; when none does it answers the
2763
+ # entry scope itself and the call is handled exactly as before. Otherwise the rest of the call — the block's
2764
+ # entry, its write-back and every post-call widening — runs from the scope the operands left
2765
+ # ({#invoke_call}), while the call's value is still typed where the operands started ({#operand_scope}), and
2766
+ # each operand where it was entered: `out << (n += 1)` appends `1`, not the `2` a re-typing after the write
2767
+ # reads. Issue #1256 — an operand after one that wrote is entered from the scope the operands before it left,
2768
+ # and {OperandWalk} records it into the per-node scope index and types it from there: `puts(b.unshift("s"),
2769
+ # b.first.upcase)` reads `b` as the `unshift` left it. The operands are threaded first and the call is typed
2770
+ # after, with those later operands' values in hand, so no operand is typed twice.
1644
2771
  def eval_call(node)
1645
- call_type = scope.type_of(node, tracer: tracer)
2772
+ walk = OperandWalk.new(walk_recorder)
2773
+ invoked = call_operand_scope(node, walk, scope)
2774
+ invoked = forget_operand_specials(node, invoked)
2775
+ operand_types = walk.types(tracer)
2776
+ call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
1646
2777
  # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
1647
2778
  # types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
1648
2779
  # block-param and adopt it as the call's return type.
1649
2780
  call_type = each_with_object_return(node, call_type)
2781
+ [call_type, invoke_from(node, invoked, call_type, operand_types)]
2782
+ end
2783
+
2784
+ # The scope after `node` runs as another expression's operand ({#thread_operand}), from the receiver scope.
2785
+ # Such a call is not typed, since its value is discarded and typing it re-types the whole subtree the
2786
+ # enclosing root types — once per level of an operator chain whose every operand writes (`f(a = 1) + f(a =
2787
+ # 2) + …`), and each through the callee's return inference. For the same reason it applies no post-return
2788
+ # narrowing ({#invoke_call}): each of those resolves the method by typing the receiver, the same subtree
2789
+ # again. They only narrow, and a call in an operand never applied them before #1223, so leaving them out is
2790
+ # the sound side. Its own later operands join `walk` and are typed as soon as they are all taken, so its
2791
+ # invocation reads them ({#type_operand}) and the enclosing root does not type them again.
2792
+ def call_effects(node, walk, typed_from)
2793
+ mark = walk.mark
2794
+ invoked = call_operand_scope(node, walk, typed_from)
2795
+ invoke_from(node, invoked, nil, walk.types(tracer, since: mark))
2796
+ end
2797
+
2798
+ # The rest of the call from `invoked`, the scope its operands left, with each later operand's own value in
2799
+ # `operand_types` for the helpers that type the call's operands ({#type_operand}).
2800
+ def invoke_from(node, invoked, call_type, operand_types)
2801
+ return invoke_call(node, call_type) if invoked.equal?(scope) && operand_types.nil?
2802
+
2803
+ evaluator_at(invoked, operand_scope: scope, operand_types: operand_types).send(:invoke_call, node, call_type)
2804
+ end
2805
+
2806
+ # The scope Ruby runs `node`'s method from: the receiver, then the arguments, then a block-pass argument, each
2807
+ # threaded in turn ({#thread_operand}). A safe-navigation call skips its arguments when the receiver is nil,
2808
+ # so their scope joins with the receiver's. Issue #1468 — and runs them only when it is not, so they are
2809
+ # entered with the receiver narrowed to its non-nil fragment ({Narrowing.safe_navigation_scope}). That entry
2810
+ # is not the scope the call is typed from, so {OperandWalk} records and types each of them from it. The
2811
+ # narrowing ends with the operands: when they wrote nothing the scope after them is the receiver's.
2812
+ def call_operand_scope(node, walk, typed_from)
2813
+ after_receiver = thread_operand(node.receiver, scope, walk, typed_from)
2814
+ entered = Narrowing.safe_navigation_scope(node, after_receiver)
2815
+ after_arguments = thread_operand(node.arguments, entered, walk, typed_from)
2816
+ block_pass = node.block
2817
+ if block_pass.is_a?(Prism::BlockArgumentNode)
2818
+ after_arguments = thread_operand(block_pass, after_arguments, walk, typed_from)
2819
+ end
2820
+ return after_arguments if after_arguments.equal?(after_receiver) || !node.safe_navigation?
2821
+ return after_receiver if after_arguments.equal?(entered)
2822
+
2823
+ join_with_nil_injection(after_receiver, after_arguments)
2824
+ end
2825
+
2826
+ # The scope after `node` from `entry`, for an expression otherwise typed as a pure value. One that holds no
2827
+ # write or jump ({OperandEffects}) answers `entry` itself. A call contributes its effects without its value
2828
+ # ({#call_effects}), any other node with a handler is evaluated through it, and an {OPERAND_CONTAINERS} node
2829
+ # threads its children in order.
2830
+ #
2831
+ # `typed_from` is the scope the root types `node` from: its own entry, or that of the nearest enclosing operand
2832
+ # {OperandWalk} took as a later one. When `entry` is not that scope, an earlier operand has moved it, and `walk`
2833
+ # takes `node` ({OperandWalk#later}) before anything below it, so its descendants are typed and recorded against
2834
+ # `entry` in turn. Nothing else is recorded into the per-node scope index.
2835
+ #
2836
+ # A call threaded this way is an operand, so it also leaves out the two resets a statement-position call
2837
+ # applies because it might have done anything ({#invoke_call}): the class's narrowed instance variables and the
2838
+ # regex globals. A call in an operand never applied them before #1223, and applying them only when the operand
2839
+ # happens to write made `$stdout.puts(Integer(v = $2)); $1.upcase` report where `$stdout.puts(Integer($2))`
2840
+ # does not. The same holds for every call under an operand evaluated through its handler, so the evaluator
2841
+ # carries `in_operand` into everything it opens: an in-place mutation counts as an effect, and threading
2842
+ # `opts[:k] = strict? ? queue.shift : nil` must not let the typed `strict?` inside the ternary reset
2843
+ # the regex globals or the narrowed ivars that the same line without the `shift` leaves alone. Issue #1365 —
2844
+ # the statement that holds the operand forgets the regex globals for every call in it instead, threaded or not,
2845
+ # when that call is known to match ({#forget_operand_specials}), so an operand's answer still does not
2846
+ # depend on whether it writes.
2847
+ def thread_operand(node, entry, walk, typed_from)
2848
+ return entry unless node.is_a?(Prism::Node)
2849
+
2850
+ taken = !entry.equal?(typed_from)
2851
+ slot = walk.later(node, entry) if taken
2852
+ typed_from = entry if slot
2853
+ unless OperandEffects.any?(node)
2854
+ # A taken position with no value of its own leaves its children to be taken: nothing types it whole.
2855
+ thread_operand_children(node, entry, walk, typed_from) if taken && slot.nil?
2856
+ return entry
2857
+ end
2858
+
2859
+ operand = evaluator_at(entry, on_enter: nil, in_operand: true, operand_recorder: walk.recorder)
2860
+ return operand.send(:call_effects, node, walk, typed_from) if node.is_a?(Prism::CallNode)
2861
+ # A container is threaded child by child even when it has a handler: the handler types the whole literal,
2862
+ # which a nested literal would repeat once per level of nesting. A statement list or `(…)` inside an operand
2863
+ # runs its statements in order just the same, and evaluating it would type each statement it holds.
2864
+ if OPERAND_CONTAINERS.include?(node.class) || OPERAND_SEQUENCES.include?(node.class)
2865
+ return thread_operand_children(node, entry, walk, typed_from)
2866
+ end
2867
+ return entry unless HANDLERS.key?(node.class)
2868
+
2869
+ # The handler types the operand from `entry`, which is the value a later operand takes.
2870
+ type, after = operand.evaluate(node)
2871
+ walk.resolve(slot, type) if slot
2872
+ after
2873
+ end
2874
+
2875
+ def thread_operand_children(node, entry, walk, typed_from)
2876
+ threaded = entry
2877
+ node.rigor_each_child { |child| threaded = thread_operand(child, threaded, walk, typed_from) }
2878
+ threaded
2879
+ end
2880
+
2881
+ # The recorder an {OperandWalk} rooted here records later operands with: the per-node scope index's, whether
2882
+ # this evaluator records into it itself or is an operand evaluator threading a recording one's operands. An
2883
+ # unrecorded pass ({UNRECORDED}) has neither.
2884
+ def walk_recorder
2885
+ @operand_recorder || @on_enter
2886
+ end
2887
+
2888
+ # A call operand's type, read from where the call's operands were typed ({#operand_scope}), with each later
2889
+ # operand's own value ({OperandWalk}) where the call has one.
2890
+ def type_operand(node)
2891
+ OperandWalk.type_of(operand_scope, node, tracer, @operand_types)
2892
+ end
2893
+
2894
+ # The scope this evaluator types a call's receiver and arguments under: where they were evaluated, which
2895
+ # {#invoke_call} under {#call_effects}' rebase is not the receiver scope. Every helper below that types the
2896
+ # current call's own operands reads this rather than `scope`, so it reads exactly what it read before the
2897
+ # rebase existed.
2898
+ def operand_scope
2899
+ @operand_scope || scope
2900
+ end
2901
+
2902
+ # The rest of {#eval_call}: the call's block and every effect the call leaves on the scope, from the receiver
2903
+ # scope, which is the scope its operands left. Returns the post-call scope. `call_type` is nil for a threaded
2904
+ # operand, which applies no post-return narrowing ({#call_effects}); nor does a call an operand evaluator
2905
+ # types through a handler ({#thread_operand}).
2906
+ def invoke_call(node, call_type)
1650
2907
  evaluate_block_if_present(node)
1651
2908
  # `ruby2_keywords def foo(...)` (and similar wrappers like `private def`, `public def`, `module_function def`)
1652
2909
  # parse the def as the call's positional argument; the ExpressionTyper#type_of_def handler types it as
@@ -1662,9 +2919,8 @@ module Rigor
1662
2919
  # scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
1663
2920
  # category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
1664
2921
  post_scope = write_back_block_captures(node, post_scope)
1665
- post_scope = apply_rbs_extended_assertions(node, post_scope)
1666
- post_scope = apply_plugin_assertions(node, post_scope)
1667
- post_scope = apply_rspec_matcher_narrowing(node, post_scope)
2922
+ statement_call = !call_type.nil? && !@in_operand
2923
+ post_scope = apply_post_return_narrowing(node, post_scope) if statement_call
1668
2924
  # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
1669
2925
  # (e.g. `arms << x`, `@tags << hashtag`). Stops a literal-shape carrier (`Tuple` / `HashShape`) from outliving
1670
2926
  # its justification when the value is mutated. Always-safe (loses precision, never invents facts).
@@ -1674,7 +2930,7 @@ module Rigor
1674
2930
  # sibling `s.y` stays precise. `call_type` is the setter's own result (the assigned value type). Sound only for
1675
2931
  # a fold-safe local (never aliased / escaped, straight-line setters) — the fold-safe scan is the gate.
1676
2932
  post_scope = MethodDispatcher::StructFolding.apply_setter_writeback(
1677
- call_node: node, assigned_type: call_type, scope: post_scope
2933
+ call_node: node, assigned_type: call_type || local_attribute_write_value(node), scope: post_scope
1678
2934
  )
1679
2935
  # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a self-call resolves to a user method that
1680
2936
  # CONTENT-mutates one of its parameters inside an escaping block (the `build_option_parser(opts)` idiom — the
@@ -1710,41 +2966,148 @@ module Rigor
1710
2966
  # binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
1711
2967
  # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
1712
2968
  # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
1713
- post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
2969
+ post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
1714
2970
  # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
1715
2971
  # later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
1716
- # only when the call is match-CAPABLE (a regex-matching method, or an implicit-self / unknown-receiver call
1717
- # whose body we cannot prove match-free). A call provably match-free on a known receiver — `$3.to_i`, `year <
1718
- # 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1719
- # while a genuinely interposed match still invalidates.
2972
+ # only when the call may run a match in this frame ({#rebinds_match_globals?}). A call provably match-free on a
2973
+ # known receiver — `$3.to_i`, `year < 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)`
2974
+ # stdlib idiom keeps its precision while a genuinely interposed match still invalidates.
1720
2975
  # The chain above is Scope-total by construction (every helper returns its input scope or a
1721
2976
  # combinator result); the `||=` is a runtime no-op that pins the INFERRED type back to Scope for
1722
2977
  # the negative rules when a helper's return widens to `Scope?` under call-site binding (#524).
1723
2978
  post_scope ||= scope
1724
- post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1725
- [call_type, post_scope]
2979
+ statement_call ? rebind_statement_specials(node, post_scope) : post_scope
2980
+ end
2981
+
2982
+ # `post_scope`, past a statement call, with the specials the call rebinds: the match globals and `$_` forgotten
2983
+ # when it may rebind them in this frame, and `$?` bound when it, or an operand, certainly ran a subprocess, since
2984
+ # `$?` is the thread's (issue #1360, {LastStatus.after}).
2985
+ # Issue #1429 — and a guard's narrowing of a global or constant restored when the call may run code that rebinds
2986
+ # it ({GuardRebinding.call_may_rebind?}).
2987
+ def rebind_statement_specials(node, post_scope)
2988
+ post_scope = post_scope.forget_match_globals if rebinds_match_globals?(node, post_scope)
2989
+ post_scope = post_scope.forget_last_line if rebinds_last_line?(node, post_scope)
2990
+ if post_scope.guard_narrowed? && GuardRebinding.call_may_rebind?(node, operand_scope)
2991
+ post_scope = post_scope.forget_guard_narrowings
2992
+ end
2993
+ forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
2994
+ end
2995
+
2996
+ # True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
2997
+ # its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
2998
+ # so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
2999
+ # rebinds that method's own), or the frame has made a closure that may run one whenever it is called
3000
+ # ({Scope#match_rebinding_closure?}). The receiver chain and arguments ran before the call, and
3001
+ # {#forget_operand_specials} answered for them. The scans run only while a match global is narrowed, the
3002
+ # one state a forget can drop.
3003
+ def rebinds_match_globals?(node, post_scope)
3004
+ return false unless post_scope.match_globals_bound?
3005
+ return true if match_capable_call?(node)
3006
+
3007
+ MatchRebinding.block_may_match?(node, scope) || post_scope.match_rebinding_closure?
3008
+ end
3009
+
3010
+ # Issue #1359 — whether the call rebinds `$_`, which shares the match globals' frame slot and so their call
3011
+ # rules ({LastLine.call_rebinds?}): a reader, or a block or closure of this frame that may run one, does; a
3012
+ # call into a method defined in Ruby does not. The receiver chain and arguments ran before the call, and
3013
+ # {#forget_operand_specials} answered for them. The scans run only while `$_` is narrowed.
3014
+ def rebinds_last_line?(node, post_scope)
3015
+ post_scope.last_line_bound? && LastLine.call_rebinds?(node, scope)
3016
+ end
3017
+
3018
+ # Issue #1365 — the scope a statement call runs its method from, with the match globals forgotten when its
3019
+ # receiver chain or arguments may rebind them ({MatchRebinding.operands_may_rebind?}): Ruby runs those first,
3020
+ # so the call's own block already reads the rebound globals (`s.sub(re, "").each_char { $1 }`). No call in them
3021
+ # forgot before, so each forgets here only when it is known to match ({MatchRebinding::Calls.rebinds?}), and
3022
+ # none resets by itself ({#invoke_call}), so an operand's answer does not depend on whether the evaluator
3023
+ # threads it: `$stdout.puts(Integer(v = $2))` keeps `$1` narrowed as `$stdout.puts(Integer($2))` does. `$_`
3024
+ # is forgotten the same way when they may set it (`line = gets.chomp`, issue #1359).
3025
+ def forget_operand_specials(node, invoked)
3026
+ return invoked if @in_operand
3027
+
3028
+ if invoked.match_globals_bound? && MatchRebinding.operands_may_rebind?(node, scope)
3029
+ invoked = invoked.forget_match_globals
3030
+ end
3031
+ # Issue #1429 — a guard narrowing the receiver chain or an argument may rebind.
3032
+ if invoked.guard_narrowed? && GuardRebinding.operands_may_rebind?(node, scope)
3033
+ invoked = invoked.forget_guard_narrowings
3034
+ end
3035
+ return invoked unless invoked.last_line_bound? && LastLine.operands_may_set?(node, scope)
3036
+
3037
+ invoked.forget_last_line
1726
3038
  end
1727
3039
 
1728
- # Method names that (may) run a regex match and therefore rebind the `$~` family. Conservative over-approximation
1729
- # — a few set globals only with a Regexp argument, but we do not inspect args.
1730
- MATCH_CAPABLE_METHODS = %i[
1731
- =~ match match? gsub gsub! sub sub! scan split slice slice!
1732
- [] partition rpartition index rindex === grep grep_v
1733
- ].freeze
1734
- private_constant :MATCH_CAPABLE_METHODS
1735
-
1736
- # True when `node` could rebind the regex match-data globals: a known regex-matching method by name, or an
1737
- # implicit-self / self-receiver call whose body we cannot inspect for an internal match. An explicit-receiver call
1738
- # to a non-matching method (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so the multi-statement `m
1739
- # = /…/ =~ s; …; use($2)` idiom keeps the narrowed globals. The over-approximation is one-directional: a user
1740
- # method that secretly matches on an explicit receiver is the only escape, and re-narrowing on the next real guard
1741
- # recovers — weighed against the false-positive cost, precision wins here.
3040
+ # Issue #1365 — `after` with the match globals forgotten when `node`, a value this statement types without
3041
+ # evaluating the calls in it as statements (an array, hash or interpolation literal, a `rescue` modifier, a
3042
+ # constant's value, a `super` or `yield`), may rebind them ({MatchRebinding.value_may_rebind?}), and `$_` when
3043
+ # it may set it ({LastLine.may_set?}, issue #1359). Inside an operand the statement that holds it answers
3044
+ # instead.
3045
+ def forget_rebound_specials(after, node)
3046
+ return after if @in_operand
3047
+
3048
+ if after.match_globals_bound? && MatchRebinding.value_may_rebind?(node, scope)
3049
+ after = after.forget_match_globals
3050
+ end
3051
+ after = after.forget_last_line if after.last_line_bound? && LastLine.may_set?(node, scope)
3052
+ # Issue #1429 — and a guard's narrowing of a global or constant, when a call in it may rebind one.
3053
+ after = after.forget_guard_narrowings if after.guard_narrowed? && GuardRebinding.may_rebind?(node, scope)
3054
+ forget_rescued_status(after, node)
3055
+ end
3056
+
3057
+ # Issue #1360 — `after`, past a statement, with `$?` forgotten when the statement may fall through a rescue in its
3058
+ # own frame ({#rescues_through?}): the exception rescued there may have been raised while a subprocess waited,
3059
+ # which leaves `$?` nil, and a rescue in an operand or a block the statement passes never joins its scope back.
3060
+ def forget_rescued_status(after, node)
3061
+ return after unless after.global(:$?) && rescues_through?(node)
3062
+
3063
+ after.forget_last_status
3064
+ end
3065
+
3066
+ # True when running `node` may leave a rescue clause, or the fallback of a rescue modifier that may fall
3067
+ # through, and go on: anywhere in its operands and the blocks it passes, but not in a lambda, a block a call
3068
+ # keeps to run later ({StoredBlockCall.stores_block?}, which includes a thread's) or a `def`, none of which runs
3069
+ # there.
3070
+ def rescues_through?(node)
3071
+ case node
3072
+ when Prism::RescueNode then return true
3073
+ when Prism::RescueModifierNode then return true unless branch_unconditionally_exits?(node.rescue_expression)
3074
+ when Prism::DefNode, Prism::LambdaNode then return false
3075
+ end
3076
+ kept = node.block if node.is_a?(Prism::CallNode) && StoredBlockCall.stores_block?(node)
3077
+ found = false
3078
+ node.rigor_each_child { |child| found ||= !child.equal?(kept) && rescues_through?(child) }
3079
+ found
3080
+ end
3081
+
3082
+ # The value an untyped setter call on a local stores (`foo(s.x = v)`), for the Struct member write-back; nil for
3083
+ # any other call, which that write-back ignores.
3084
+ def local_attribute_write_value(node)
3085
+ return nil unless node.attribute_write? && node.receiver.is_a?(Prism::LocalVariableReadNode)
3086
+
3087
+ type_operand(node)
3088
+ end
3089
+
3090
+ def apply_post_return_narrowing(node, post_scope)
3091
+ post_scope = apply_rbs_extended_assertions(node, post_scope)
3092
+ post_scope = apply_plugin_assertions(node, post_scope)
3093
+ apply_rspec_matcher_narrowing(node, post_scope)
3094
+ end
3095
+
3096
+ # True when `node` could rebind the regex match-data globals by itself
3097
+ # ({MatchRebinding::Calls.statement_rebinds?}): a method that matches on this frame's behalf, on any receiver,
3098
+ # read with the operands where they were typed (issue #1365: a name the old table forgot on keeps forgetting
3099
+ # unless its literal arguments prove it match-free, so `row[:name]`, `csv.split(",")` and `s.match?(re)` keep
3100
+ # the narrowing and `row[key]` does not; any other call forgets when it is known to match, as
3101
+ # `u.start_with?(/(q)/)` is); or an implicit-self / `self.` call that may reach this frame's slot. Issue #1364 —
3102
+ # a method defined in Ruby runs in a frame of its own, so a match in its body rebinds its own `$~`, never its
3103
+ # caller's, and `log("parsed"); key = $1` keeps `$1` narrowed; such a call forgets as every implicit-self call
3104
+ # did before only in a frame that hands its slot to code the analyzer does not trace, or where no body stamped
3105
+ # a frame. A call to a non-matching method (`$3.to_i`, `year < 50`, `buf << c`) is match-free, so the
3106
+ # multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the narrowed globals.
1742
3107
  def match_capable_call?(node)
1743
3108
  return true unless node.is_a?(Prism::CallNode)
1744
- return true if MATCH_CAPABLE_METHODS.include?(node.name)
1745
3109
 
1746
- receiver = node.receiver
1747
- receiver.nil? || receiver.is_a?(Prism::SelfNode)
3110
+ MatchRebinding::Calls.statement_rebinds?(node, operand_scope)
1748
3111
  end
1749
3112
 
1750
3113
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
@@ -2105,6 +3468,10 @@ module Rigor
2105
3468
  return nil if method_def.nil?
2106
3469
 
2107
3470
  arguments = call_node.arguments&.arguments || []
3471
+ # A `(?)` overload (`RBS::Types::UntypedFunction`, #1430) names no parameter and accepts any argument list,
3472
+ # so a call may reach it with the target's position holding anything: no overload proves the target.
3473
+ return nil unless method_def.method_types.all? { |mt| mt.type.respond_to?(:required_positionals) }
3474
+
2108
3475
  method_def.method_types.each do |mt|
2109
3476
  params = mt.type.required_positionals + mt.type.optional_positionals
2110
3477
  index = params.find_index { |param| param.name == target_name }
@@ -2113,11 +3480,23 @@ module Rigor
2113
3480
  nil
2114
3481
  end
2115
3482
 
3483
+ # Issue #1468 — the block of `recv&.m { … }` runs only once `recv` is non-nil, so it is entered from the scope
3484
+ # with the receiver narrowed ({Narrowing.safe_navigation_block_scope}), and every entry rule
3485
+ # {#enter_call_block} applies reads that scope as any other. The receiver's own type, which the block's
3486
+ # parameters and the repetition rules are read from, stays where the call's operands were typed.
2116
3487
  def evaluate_block_if_present(node)
2117
3488
  block = node.block
2118
3489
  return unless block.is_a?(Prism::BlockNode)
2119
3490
 
2120
- block_entry = build_block_entry_scope(node, block)
3491
+ narrowed = Narrowing.safe_navigation_block_scope(node, scope)
3492
+ return enter_call_block(node, block) if narrowed.equal?(scope)
3493
+
3494
+ evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
3495
+ .send(:enter_call_block, node, block)
3496
+ end
3497
+
3498
+ def enter_call_block(node, block)
3499
+ block_entry = narrow_define_method_block_self(node, repeating_block_entry(node, block))
2121
3500
  # #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
2122
3501
  # semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
2123
3502
  # and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
@@ -2127,12 +3506,71 @@ module Rigor
2127
3506
  # call in the body as `call.unresolved-toplevel`.
2128
3507
  #
2129
3508
  # Outer locals stay visible: unlike a `class` keyword body, the block is a closure.
3509
+ refined = refined_class_context(node)
3510
+ return enter_meta_class_body(block, block_entry, refined) if refined
3511
+
2130
3512
  anonymous = AnonymousMetaClass.name_for(node, scope.source_path)
2131
- return sub_eval(block, block_entry) if anonymous.nil?
3513
+ if anonymous.nil?
3514
+ return sub_eval(block, block_entry) unless return_barrier_block?(node)
3515
+
3516
+ return without_return_sink { sub_eval(block, block_entry) }
3517
+ end
2132
3518
 
2133
3519
  enter_meta_class_body(block, block_entry, [ClassFrame.new(name: anonymous, singleton: false)])
2134
3520
  end
2135
3521
 
3522
+ # Issue #1120 — `refine X do … end` in a module body. A `def` in the block defines an instance method of X (a
3523
+ # refined one), so its body runs with an instance of X as `self` and reads X's instance variables, exactly as
3524
+ # a `def` in a `class X` body does. Entering the block as X's class body gives it that through the ordinary
3525
+ # {#self_type_for_method_body} route. Only a constant X is modelled ({ScopeIndexer.refine_target}). One that
3526
+ # does not type as a class object (a gem class with no RBS) keeps the name as written: the body is still some
3527
+ # class's body, and leaving it on the enclosing `self` made a `refine` at the file's top level report every
3528
+ # implicit-self call in it as `call.unresolved-toplevel`.
3529
+ def refined_class_context(node)
3530
+ target = ScopeIndexer.refine_target(node)
3531
+ return nil if target.nil?
3532
+
3533
+ refined = scope.type_of(target)
3534
+ name = refined.is_a?(Type::Singleton) ? refined.class_name : Source::ConstantPath.qualified_name(target)
3535
+ return nil if name.nil?
3536
+
3537
+ [ClassFrame.new(name: name.delete_prefix("::"), singleton: false, refinement: true)]
3538
+ end
3539
+
3540
+ # The block calls whose body `return` leaves only the block ({ReturnBarrier.block_call?}). Like a `->` body
3541
+ # ({#eval_lambda}), each runs with the enclosing method's return sink suspended.
3542
+ def return_barrier_block?(node)
3543
+ ReturnBarrier.block_call?(node)
3544
+ end
3545
+
3546
+ # Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
3547
+ def without_return_sink
3548
+ outer_sink = Thread.current[RETURN_SINK_KEY]
3549
+ Thread.current[RETURN_SINK_KEY] = nil
3550
+ begin
3551
+ yield
3552
+ ensure
3553
+ Thread.current[RETURN_SINK_KEY] = outer_sink
3554
+ end
3555
+ end
3556
+
3557
+ # Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
3558
+ # the block with `self` bound to the receiving instance. Without this the block inherits the class body's
3559
+ # `Singleton[C]`, and #618's own-method veto asks the singleton side of a name the instance side answers.
3560
+ # {DefineMethodBlockSelf} owns the match; a non-match leaves the entry scope exactly as it was.
3561
+ #
3562
+ # The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
3563
+ # method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
3564
+ # still carries the singleton frame although its `self` is the class object, and it is an instance method
3565
+ # the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
3566
+ # same exclusion, which has no frame stack of its own.
3567
+ def narrow_define_method_block_self(call_node, block_entry)
3568
+ narrowed = DefineMethodBlockSelf.narrow_self_type_for(
3569
+ scope: scope, call_node: call_node
3570
+ )
3571
+ narrowed ? block_entry.with_self_type(narrowed) : block_entry
3572
+ end
3573
+
2136
3574
  # Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
2137
3575
  # singleton, so a `def` inside binds an instance method on it through the ordinary
2138
3576
  # {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
@@ -2146,7 +3584,7 @@ module Rigor
2146
3584
  # frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
2147
3585
  # divergence that makes the chain a separate record rather than a view of the frame stack.
2148
3586
  def enter_meta_class_body(block, block_entry, class_context)
2149
- entry = block_entry.with_self_type(self_type_for_class_body(class_context))
3587
+ entry = block_entry.with_self_type(self_type_for_class_body(class_context)).with_singleton_class_body(false)
2150
3588
  sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
2151
3589
  end
2152
3590
 
@@ -2209,19 +3647,18 @@ module Rigor
2209
3647
  acc
2210
3648
  end
2211
3649
 
3650
+ # Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
3651
+ # before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
2212
3652
  def floor_callee_escaped_args_for_call(node, base_scope)
2213
- return base_scope unless self_dispatch_call?(node)
2214
- # Fast path — the floor only ever touches a local passed as an argument, so a call with no arguments cannot
2215
- # floor anything. Skip the def resolution + body scan entirely (the overwhelming common case).
2216
- return base_scope unless call_passes_local_argument?(node)
2217
-
2218
- def_node = resolve_self_callee_def(node)
2219
- return base_scope if def_node.nil?
3653
+ arguments = content_mutated_arguments(node)
3654
+ return base_scope if arguments.empty?
2220
3655
 
2221
- mutated = callee_content_mutated_parameters(def_node)
2222
- return base_scope if mutated.empty?
3656
+ arguments.reduce(base_scope) do |acc, argument|
3657
+ next acc unless acc.locals.key?(argument.name)
2223
3658
 
2224
- floor_arguments_at_positions(node, mutated, base_scope)
3659
+ floored = content_floor_for(acc.local(argument.name))
3660
+ floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
3661
+ end
2225
3662
  end
2226
3663
 
2227
3664
  # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
@@ -2367,20 +3804,6 @@ module Rigor
2367
3804
  end
2368
3805
  end
2369
3806
 
2370
- def floor_arguments_at_positions(node, positions, base_scope)
2371
- args = node.arguments
2372
- return base_scope unless args.respond_to?(:arguments)
2373
-
2374
- argument_nodes = args.arguments
2375
- positions.values.uniq.reduce(base_scope) do |acc, index|
2376
- arg = argument_nodes[index]
2377
- next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
2378
-
2379
- floored = content_floor_for(acc.local(arg.name))
2380
- floored.nil? ? acc : acc.with_local(arg.name, floored)
2381
- end
2382
- end
2383
-
2384
3807
  # Walk the receiver chain of `node` and fold the escaping-content widening of every block-bearing, escaping
2385
3808
  # receiver call into `base_scope`. Only receiver calls are walked — `node` itself is handled by the caller. A
2386
3809
  # `:non_escaping` receiver block is left to slice C's non-escaping write-back (which the receiver expression
@@ -2422,7 +3845,7 @@ module Rigor
2422
3845
 
2423
3846
  mutations.keys.reduce(post_scope) do |acc, name|
2424
3847
  floored = content_floor_for(acc.local(name))
2425
- floored.nil? ? acc : acc.with_local(name, floored)
3848
+ floored.nil? ? acc : acc.with_mutated_local(name, floored)
2426
3849
  end
2427
3850
  end
2428
3851
 
@@ -2445,10 +3868,17 @@ module Rigor
2445
3868
 
2446
3869
  # The Dynamic-floor carrier for a content-mutated escaping capture, or nil when the pre-state is not a recognised
2447
3870
  # mutable collection (leave it alone — e.g. an already-`Dynamic` binding or an unknown shape).
3871
+ #
3872
+ # A String counts in any refined form (`non-empty-string`, `decimal-int-string`), which `stringish?` does not
3873
+ # accept: the mutation can empty or rewrite it as it can a plain `String`.
2448
3874
  def content_floor_for(type)
2449
3875
  return nil if type.nil?
3876
+ # A union with a String member floors member by member, so neither carrier swallows the other and a member no
3877
+ # mutation can fill (`nil`) stays: taken whole, `Array | String` floored to `Array[untyped]` and `String?` to
3878
+ # nothing at all.
3879
+ return UnknownStoreWidening.content_floor(type) if string_union?(type)
2450
3880
 
2451
- if stringish?(type)
3881
+ if UnknownStoreWidening.carrier_class(type) == "String"
2452
3882
  Type::Combinator.nominal_of("String")
2453
3883
  elsif hashish?(type)
2454
3884
  Type::Combinator.nominal_of("Hash",
@@ -2459,12 +3889,26 @@ module Rigor
2459
3889
  end
2460
3890
  end
2461
3891
 
3892
+ # The type of `call_node`'s explicit receiver where its operands were typed ({#type_operand}), computed once per
3893
+ # call: the block's entry (its parameter types, a DSL `self`, the #1412 repeat gate), its escape class and
3894
+ # every write-back pass all ask, and {#type_operand} types the receiver afresh each time. The one slot is
3895
+ # keyed on the node, and an evaluator types every operand from the same scope.
3896
+ def explicit_receiver_type(call_node)
3897
+ memo = @explicit_receiver_type
3898
+ return memo[1] if memo && memo[0].equal?(call_node)
3899
+
3900
+ type = type_operand(call_node.receiver)
3901
+ @explicit_receiver_type = [call_node, type]
3902
+ type
3903
+ end
3904
+
2462
3905
  def classify_closure_escape(call_node)
2463
- receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
3906
+ receiver_type = call_node.receiver ? explicit_receiver_type(call_node) : nil
2464
3907
  ClosureEscapeAnalyzer.classify(
2465
3908
  receiver_type: receiver_type,
2466
3909
  method_name: call_node.name,
2467
- environment: scope.environment
3910
+ environment: scope.environment,
3911
+ scope: scope
2468
3912
  )
2469
3913
  rescue StandardError
2470
3914
  :unknown
@@ -2474,11 +3918,16 @@ module Rigor
2474
3918
  # drop matches the spec line "facts about locals it can write become unstable after the escape point": rather than
2475
3919
  # synthesise the union of the block's write types (which the current pass does not yet expose), we discard the
2476
3920
  # narrowed binding altogether. A future sub-phase MAY refine this to the union of the block's actual writes.
3921
+ #
3922
+ # An instance variable the body rebinds is dropped the same way: the block shares the caller's `self`, so a
3923
+ # callback that runs later writes the very ivar the continuation reads (`@clicked = false; button.on_click {
3924
+ # @clicked = true }` left `if @clicked` folding always-falsey). One still on its ADR-58 declaration seed is
3925
+ # left alone ({CapturedLocals.writes}): that seed already holds whatever the callback stores.
2477
3926
  def drop_captured_narrowing(block_node, base_scope)
2478
- names = CapturedLocals.writes(block_node, base_scope)
3927
+ names = CapturedLocals.writes(block_node, base_scope, ivars: true)
2479
3928
  return base_scope if names.empty?
2480
3929
 
2481
- names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
3930
+ names.reduce(base_scope) { |acc, name| bind_capture(acc, name, Type::Combinator.untyped) }
2482
3931
  end
2483
3932
 
2484
3933
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
@@ -2487,25 +3936,154 @@ module Rigor
2487
3936
  # — `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
2488
3937
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2489
3938
  #
2490
- # Fast path: a block writing no outer local leaves `post_scope` byte-identical (the overwhelming majority of
2491
- # blocks), so this costs one extra `CapturedLocals.writes` walk and nothing else.
3939
+ # The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
3940
+ # way — `@n = 0; [1, 2].each { @n += 1 }` left `@n == 0` folding always-truthy — so they join the name set,
3941
+ # seeded from their pre-call binding. See {#converge_captures_by_kind} for how a block that rebinds both kinds is
3942
+ # answered.
3943
+ #
3944
+ # Fast path: a block writing no outer local and no rebindable ivar leaves `post_scope` byte-identical (the
3945
+ # overwhelming majority of blocks), so this costs one `CapturedLocals.writes` walk and nothing else.
3946
+ #
3947
+ # Every pass reads a captured local the body mutates IN PLACE at its unknown-store widening
3948
+ # ({#capture_pass_bindings}), never at the contents the collection held before the call: only the rebound names
3949
+ # move between passes, so a rebind read from such a collection (`last = a.last; a << x`) would otherwise record
3950
+ # the first iteration's answer on every pass and the fixpoint would close over it (ADR-56 WD2.13, second
3951
+ # residue).
2492
3952
  def write_back_block_captures(call_node, post_scope)
2493
3953
  block = call_node.block
2494
3954
  return post_scope unless block.is_a?(Prism::BlockNode)
2495
3955
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
2496
3956
 
2497
- names = CapturedLocals.writes(block, scope)
3957
+ names = CapturedLocals.writes(block, scope, ivars: true)
2498
3958
  return post_scope if names.empty?
2499
3959
 
2500
- seed = names.to_h { |name| [name, scope.local(name)] }
2501
- result = BodyFixpoint.converge(
3960
+ break_pass = block_break_pass(block)
3961
+ result = converge_captures_by_kind(call_node, block, names, break_pass)
3962
+ result = join_block_break_bindings(call_node, block, result, break_pass)
3963
+ result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
3964
+ end
3965
+
3966
+ # The {BodyFixpoint} continuation of `names`, seeded from their pre-call bindings.
3967
+ def converge_block_captures(call_node, block, names, break_pass)
3968
+ BodyFixpoint.converge(
2502
3969
  names: names,
2503
- seed_bindings: seed,
3970
+ seed_bindings: names.to_h { |name| [name, CapturedLocals.bound_type(scope, name)] },
2504
3971
  widen: Type::Combinator.method(:widen_value_pinned),
2505
- evaluate_body: ->(bindings) { block_exit_bindings(call_node, block, bindings, names) }
3972
+ evaluate_body: ->(bindings) { block_pass_exit_bindings(call_node, block, bindings, names, break_pass) }
2506
3973
  )
3974
+ end
3975
+
3976
+ # The continuation of a block's rebound names. One {BodyFixpoint} over locals and ivars together is the wrong
3977
+ # answer for most of them: its final pass widens every name while any name still moves, so an ivar counter beside
3978
+ # `mode = :b` turned `mode`'s converged `:a | :b` into `Symbol` — and a local counter beside `@mode = :b` did the
3979
+ # same to `@mode` — and `take(mode)` against `(:a | :b) -> void` fired on code each kind's own fixpoint accepts.
3980
+ #
3981
+ # So each kind converges on its own first, with the other at its pre-call binding; for a block that rebinds one
3982
+ # kind that is the only fixpoint, and for the locals it is exactly the one a block rebinding no ivar has always
3983
+ # had. A name converged that way is wrong when it reads the other kind (`last = @n; @n += 1`, `@last = count`),
3984
+ # so one more pass under the settled bindings checks every name, and one whose exit leaves its settled binding
3985
+ # takes its answer from a joint fixpoint over both kinds, computed only then. The check repeats, because a name
3986
+ # read off a moved one (`first = last`) may move in turn; every round moves a name to its joint answer or stops.
3987
+ #
3988
+ # The body's last evaluation is therefore a pass under the settled bindings, so the scopes recorded inside the
3989
+ # block read those rather than a pass that pinned one kind to its pre-call value.
3990
+ #
3991
+ # `break_pass` ({#block_break_pass}) rides along so every pass — a fixpoint's or a check's — leaves its `break`
3992
+ # scopes for {#join_block_break_bindings}.
3993
+ def converge_captures_by_kind(call_node, block, names, break_pass)
3994
+ kinds = names.partition { |name| !CapturedLocals.ivar_name?(name) }
3995
+ return converge_block_captures(call_node, block, names, break_pass) if kinds.any?(&:empty?)
3996
+
3997
+ settled = kinds.map { |kind| converge_block_captures(call_node, block, kind, break_pass) }.reduce(:merge)
3998
+ joint = nil
3999
+ loop do
4000
+ exits = block_pass_exit_bindings(call_node, block, settled, names, break_pass)
4001
+ moved = escaped_captures(settled, exits, joint)
4002
+ return settled if moved.empty?
4003
+
4004
+ joint ||= converge_block_captures(call_node, block, names, break_pass)
4005
+ moved.each { |name| settled[name] = joint[name] }
4006
+ end
4007
+ end
4008
+
4009
+ # The names whose `exits` leave their `settled` binding, less those already on their `joint` answer.
4010
+ def escaped_captures(settled, exits, joint)
4011
+ settled.keys.select do |name|
4012
+ exit_type = exits[name]
4013
+ next false if exit_type.nil? || (joint && settled[name] == joint[name])
2507
4014
 
2508
- result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
4015
+ Type::Combinator.union(settled[name], exit_type) != settled[name]
4016
+ end
4017
+ end
4018
+
4019
+ # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
4020
+ # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
4021
+ # rebinds no ivar leaves the locals exactly as before.
4022
+ def bind_capture(scope, name, type)
4023
+ CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
4024
+ end
4025
+
4026
+ # A block-level `break` ends the CALL, so the binding it leaves with starts no further iteration and is no input
4027
+ # to the write-back fixpoint — feeding it back would type the next pass's body under a value the body never sees
4028
+ # (`acc = "s"; break` reaching the next pass's `acc`). It IS the continuation's binding on that path, though, and
4029
+ # without this join `found = nil; xs.each { |x| if x > 1; found = x; break; end }` left `found` on `nil` and
4030
+ # folded `if found` always-falsey.
4031
+ #
4032
+ # The arms must come from a pass whose entry is the CONVERGED binding, which contains every iteration's entry.
4033
+ # The write-back's last pass usually is one — a fixpoint that stabilised, and every by-kind check pass
4034
+ # ({#converge_captures_by_kind}), ran from the binding it returns — so its arms are reused
4035
+ # ({#block_pass_exit_bindings}). A capped fixpoint's widened binding was never evaluated, so only then does one
4036
+ # more pass run, without recording into the per-node scope index: that index keeps the write-back's own last
4037
+ # pass, which the check path's diagnostics read, exactly as the loop fixpoint's converged re-record is
4038
+ # display-only ({#record_converged_loop_body}). A name the fixpoint floored to `Dynamic[top]` keeps the floor; a
4039
+ # precise arm unioned into it would read as knowledge the analysis does not have.
4040
+ def join_block_break_bindings(call_node, block, converged, break_pass)
4041
+ return converged if break_pass.nil?
4042
+
4043
+ arms =
4044
+ if break_pass[:entry] == converged
4045
+ break_pass[:arms]
4046
+ else
4047
+ converged_break_arms(call_node, block, converged, break_pass[:targets])
4048
+ end
4049
+ return converged if arms.empty?
4050
+
4051
+ floor = Type::Combinator.untyped
4052
+ converged.to_h do |name, type|
4053
+ next [name, type] if type == floor
4054
+
4055
+ [name, Type::Combinator.union(type, *arms.filter_map { |arm| CapturedLocals.bound_type(arm, name) })]
4056
+ end
4057
+ end
4058
+
4059
+ # nil for a body with no block-level `break` — one allocation-free scan, and the write-back runs exactly as
4060
+ # before. Otherwise the record {#block_pass_exit_bindings} fills: the targeting `break`s, and the entry
4061
+ # bindings and `break` scopes of the most recent fixpoint pass.
4062
+ def block_break_pass(block)
4063
+ body = block.body
4064
+ return nil unless JumpTargets.any?(body, Prism::BreakNode)
4065
+
4066
+ { targets: JumpTargets.of(body, Prism::BreakNode), entry: nil, arms: [] }
4067
+ end
4068
+
4069
+ # One write-back fixpoint pass ({#block_exit_bindings}), collecting the scopes at the block-level `break`s it
4070
+ # reaches when there are any. `BodyFixpoint` hands every pass the same mutable assumption, so the entry is
4071
+ # copied before the fixpoint moves it.
4072
+ def block_pass_exit_bindings(call_node, block, bindings, names, break_pass)
4073
+ return block_exit_bindings(call_node, block, bindings, names) if break_pass.nil?
4074
+
4075
+ sink, exits = collect_break_scopes { block_exit_bindings(call_node, block, bindings, names) }
4076
+ break_pass[:entry] = bindings.dup
4077
+ break_pass[:arms] = targeted_scopes(sink, break_pass[:targets])
4078
+ exits
4079
+ end
4080
+
4081
+ # The `break` scopes of one unrecorded pass from `converged` — for a capped fixpoint, whose widened binding no
4082
+ # pass has run from.
4083
+ def converged_break_arms(call_node, block, converged, targets)
4084
+ entry = block_pass_entry(call_node, block, converged)
4085
+ sink, = collect_break_scopes { sub_eval(block, entry, **UNRECORDED) }
4086
+ targeted_scopes(sink, targets)
2509
4087
  end
2510
4088
 
2511
4089
  # ADR-56 slice C — receiver-content element-type join. After the rebind write-back and
@@ -2525,8 +4103,8 @@ module Rigor
2525
4103
  # post-call effect applied ahead of the widening; the pre-CALL `scope` would carry neither. The loop seam makes
2526
4104
  # the same choice with `pre_body`; see {#loop_content_writeback}.
2527
4105
  #
2528
- # The block body is typed once for argument evidence; the floor is `Array[Dynamic[top]]` /
2529
- # `Hash[untyped, untyped]` (the sound empty-seed behaviour). Always sound — only ever widens.
4106
+ # The stored evidence is typed in the block-entry scope and iterated to a fixpoint when a store reads a
4107
+ # collection the join moves — see {#join_content_to_fixpoint}. Always sound — only ever widens.
2530
4108
  def content_writeback_block_captures(call_node, post_scope, seed_scope:)
2531
4109
  block = call_node.block
2532
4110
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2535,14 +4113,424 @@ module Rigor
2535
4113
  body = block.body
2536
4114
  return post_scope if body.nil?
2537
4115
 
2538
- mutations = collect_content_mutations(body)
4116
+ shadows = {}.compare_by_identity
4117
+ mutations = captured_content_mutations(block, shadows)
2539
4118
  return post_scope if mutations.empty?
2540
4119
 
2541
- entry = build_block_entry_scope(call_node, block)
2542
- mutations.reduce(post_scope) do |acc, (name, calls)|
2543
- joined = join_content_for_local(name, calls, seed_scope, entry)
2544
- joined.nil? ? acc : acc.with_local(name, joined)
4120
+ seeds = mutations.to_h do |name, _calls|
4121
+ [name, lookup_mutated_seed(body, name, seed_scope.local(name)) { |depth, nesting| depth > nesting }]
4122
+ end
4123
+ shadow_rebound_reads(block, mutations, seeds, shadows)
4124
+ joined = join_content_to_fixpoint(mutations, seeds, build_block_entry_scope(call_node, block), shadows)
4125
+ rewrites = local_rewrites(block.body) { |receiver, ancestors| receiver.depth > scope_nesting(ancestors) }
4126
+ joined.reduce(post_scope) do |acc, (name, type)|
4127
+ acc.with_mutated_local(name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
4128
+ end
4129
+ end
4130
+
4131
+ NO_REWRITES = [].freeze
4132
+ private_constant :NO_REWRITES
4133
+
4134
+ # The {RewriteMutation} names `root` calls on each local its block admits — `a.map!(&:to_s)` beside an `a << x`.
4135
+ # The receiver test is the one the caller's content-mutation walk applies.
4136
+ def local_rewrites(root)
4137
+ rewrites = {}
4138
+ Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
4139
+ next unless node.is_a?(Prism::CallNode) && RewriteMutation.rewriter?(node.name)
4140
+
4141
+ receiver = node.receiver
4142
+ next unless receiver.is_a?(Prism::LocalVariableReadNode) && yield(receiver, ancestors)
4143
+
4144
+ (rewrites[receiver.name] ||= []) << node.name
4145
+ end
4146
+ rewrites
4147
+ end
4148
+
4149
+ # The slice-C join (and the loop seam's) rebuilds a collection from its SEED, so the rewrite the body's own
4150
+ # widening applied is gone from it: `a = [1]; [0].each { a.map!(&:to_s); a << "x" }` read `Array["x" | 1]`, and
4151
+ # `a[0] == "1"` folded always-falsey. Each rewrite the body makes on the local is re-applied here, on the seam's
4152
+ # terms: a seed the straight-line widening may not grow (a precise nominal, #561) is left as the join answered it.
4153
+ def rewritten_capture(type, seed, method_names)
4154
+ return type if method_names.empty? || !MutationWidening.shape_carrier?(seed)
4155
+
4156
+ method_names.uniq.reduce(type) { |acc, method_name| RewriteMutation.arm_through(acc, method_name) }
4157
+ end
4158
+
4159
+ # Adds to each store's `shadows` every local it reads that the block body writes and the block-entry scope binds:
4160
+ # an outer local the body rebinds, or a block parameter or `;`-local it reassigns. A local the body introduces
4161
+ # already reads `Dynamic[top]` there. A joined collection the body also rebinds is included: the join's seed
4162
+ # carries slice A's continuation, which misses a value written between two rebinds.
4163
+ #
4164
+ # The block-entry scope binds such a local where the call found it, so a store reading one recorded the first
4165
+ # iteration's value: `total = 0; out = []; [1, 2].each { |x| total += x; out << total }` stored `0` as far as the
4166
+ # join could tell, `out` read `Array[0]`, and `out.last == 3` folded always-falsey on a program whose `out` is
4167
+ # `[1, 3]`. Typed as `Dynamic[top]`, the store is `out`'s one unknown member and nothing folds.
4168
+ #
4169
+ # Every precise reading tried reported on correct code instead:
4170
+ #
4171
+ # - slice A's continuation misses a value written between two rebinds;
4172
+ # - joined with the block-entry typing, it still stores exit values no store reads;
4173
+ # - one more walk of the body to the store inherits every gap in the engine's in-body flow.
4174
+ #
4175
+ # See ADR-56 WD2.13.
4176
+ def shadow_rebound_reads(block, sites, seeds, shadows)
4177
+ entry_names = scope.locals.keys | CapturedLocals.introduced_locals(block).to_a
4178
+ written = scope_local_writes(block) & entry_names
4179
+ return if written.empty?
4180
+
4181
+ sites.each do |name, nodes|
4182
+ # A mixed `Array | Hash` seed reads as a Hash here: its key arguments are the Hash side's evidence, and a
4183
+ # `Dynamic` index only makes the Array side read the store as both forms.
4184
+ array = content_kind(seeds[name]) == :array
4185
+ nodes.each do |site|
4186
+ names = store_value_reads(site, array) & written
4187
+ shadows[site] = shadows.fetch(site, []) | names unless names.empty?
4188
+ end
4189
+ end
4190
+ end
4191
+
4192
+ # The locals a store reads to build what it stores. An Array index write's index arguments are left out, and so
4193
+ # is any name they read: the join classifies the store as an element or a splice from the index's type, and a
4194
+ # `Dynamic` index reads as both, so `grid[i] = [x, x]` would join `x` itself as a member of `grid` beside the
4195
+ # pair. Such an index keeps its block-entry binding, which is master's reading, and so does a stored value that
4196
+ # reads the same name: `ids[n] = n; n += 1` still stores `n`'s first-iteration value.
4197
+ def store_value_reads(site, array)
4198
+ return local_reads(site) unless array
4199
+
4200
+ if site.is_a?(Prism::CallNode) && site.name == :[]=
4201
+ *index, value = site.arguments&.arguments || []
4202
+ [value, site.receiver].compact.flat_map { |n| local_reads(n) } - index.flat_map { |n| local_reads(n) }
4203
+ elsif IndexWriteWidening.index_write?(site)
4204
+ value = site.respond_to?(:value) ? site.value : nil
4205
+ [value, site.receiver].compact.flat_map { |n| local_reads(n) } - local_reads(site.arguments)
4206
+ else
4207
+ local_reads(site)
4208
+ end
4209
+ end
4210
+
4211
+ # Every local the block writes in its own scope or an outer one, in its body or in a parameter's default. A
4212
+ # write inside an inner block or lambda to a name that block introduces is a different variable: its `depth`
4213
+ # climbs fewer scopes than it is nested in. A method, class or module body inside the block is a scope of its own.
4214
+ def scope_local_writes(block)
4215
+ names = []
4216
+ [block.parameters, block.body].compact.each do |root|
4217
+ Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
4218
+ next unless CapturedLocals::LOCAL_WRITE_NODES.any? { |klass| node.is_a?(klass) }
4219
+
4220
+ names << node.name if same_scope_local?(node, ancestors)
4221
+ end
4222
+ end
4223
+ names.uniq
4224
+ end
4225
+
4226
+ # The bodies that open a scope of their own, where a local's `depth` starts again from zero.
4227
+ SCOPE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
4228
+ private_constant :SCOPE_BODY_NODES
4229
+
4230
+ # True when the local `node` names lives in the scope the walk started in or an outer one.
4231
+ def same_scope_local?(node, ancestors)
4232
+ return false if ancestors.any? { |ancestor| SCOPE_BODY_NODES.any? { |klass| ancestor.is_a?(klass) } }
4233
+
4234
+ node.depth >= scope_nesting(ancestors)
4235
+ end
4236
+
4237
+ # The nodes that read a local's current value: a plain read, and the compound writes that read before they store.
4238
+ LOCAL_READ_NODES = [
4239
+ Prism::LocalVariableReadNode,
4240
+ Prism::LocalVariableOperatorWriteNode,
4241
+ Prism::LocalVariableOrWriteNode,
4242
+ Prism::LocalVariableAndWriteNode
4243
+ ].freeze
4244
+ private_constant :LOCAL_READ_NODES
4245
+
4246
+ # Every local `node` reads from the scope it sits in: a read inside an inner block of a name that block
4247
+ # introduces is a different variable.
4248
+ def local_reads(node)
4249
+ return [] if node.nil?
4250
+
4251
+ names = []
4252
+ Source::NodeWalker.each_with_ancestors(node) do |n, ancestors|
4253
+ next unless LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
4254
+
4255
+ names << n.name if same_scope_local?(n, ancestors)
4256
+ end
4257
+ names.uniq
4258
+ end
4259
+
4260
+ # The evidence a content join reads, per collection kind: one element union for an Array, a key union and a
4261
+ # value union for a Hash, all three for a seed carrying both ({ContentJoin.join_mixed_content}), and none for a
4262
+ # String, which widens to `String` whatever it stored.
4263
+ CONTENT_EVIDENCE_SLOTS = {
4264
+ array: %i[element].freeze, hash: %i[key value].freeze, mixed: %i[key value element].freeze, string: [].freeze
4265
+ }.freeze
4266
+ private_constant :CONTENT_EVIDENCE_SLOTS
4267
+
4268
+ # The joined continuation carrier of each content-mutated name, shared by the block seam and
4269
+ # {#each_with_object_return}. `sites` maps each name to its mutation nodes, `seeds` to its pre-state; the answer
4270
+ # omits a name whose pre-state is no collection.
4271
+ #
4272
+ # The evidence is typed in the block-entry scope, where each mutated collection still holds its PRE-CALL
4273
+ # contents. A store whose evidence reads one of them therefore records the FIRST iteration's answer: `h = { a:
4274
+ # 0 }; [:a, :a, :a].each { |k| h[k] = h[k] + 1 }` stored `1` as far as a single pass could tell, the join read
4275
+ # `Hash[:a | Symbol, 0 | 1]`, and `h[:a] == 3` folded always-falsey on a program that prints.
4276
+ #
4277
+ # So each collection a store reads is bound to what it holds at ANY iteration's entry. A name none of whose
4278
+ # stores reads a mutated Array or Hash is FIXED: its evidence is the same on every iteration, so it is typed
4279
+ # once and the name is bound to its own join — a String to `String`, whatever it stored, so `lens << buf.length`
4280
+ # after `buf << w` reads `Integer`, not the length of `buf`'s pre-call value. Every other name MOVES, and its
4281
+ # evidence is iterated to a fixpoint through {BodyFixpoint}: each of its evidence slots is one fixpoint name,
4282
+ # and each pass re-types its stores with every moving collection bound to its seed joined with the evidence so
4283
+ # far. The join above widens to `Hash[Symbol, 0 | Integer]` on the final pass, and evidence that keeps growing
4284
+ # structurally floors to `Dynamic[top]`, the slot's one-unknown-store answer. Only moving slots are ever
4285
+ # widened, so `acc << 1` beside such a store keeps `Array[1]`.
4286
+ #
4287
+ # That is what keeps this seam's claim to complete evidence ({MutationWidening#gradual_floor} rests on it): the
4288
+ # scan sees every store in the body, and no store's evidence is read off a first-iteration binding. The final
4289
+ # pass trusts its widening without re-checking it, exactly as slice A's fixpoint does (ADR-56 WD3). A gradual
4290
+ # arm on every self-reading store would be sound too, but its `Dynamic` would quiet every later read of the
4291
+ # collection, where the converged `Integer` still reports `h[:a].upcase`. With no moving name — the `acc = [];
4292
+ # xs.each { |x| acc.push(x) }` accumulator — this is the single pass it always was.
4293
+ #
4294
+ # `shadows` maps a site to the names its evidence is typed with bound to `Dynamic[top]`. A site nested in an inner
4295
+ # block or lambda lists the names that block binds itself (parameters, `;`-locals): the entry scope is the seam
4296
+ # block's, where such a name resolves to the OUTER local it shadows, and `|y| out << y.first` inside the block
4297
+ # must not read an outer `y = [0]`. Every site also lists the locals it reads that the body writes
4298
+ # ({#shadow_rebound_reads}).
4299
+ def join_content_to_fixpoint(sites, seeds, entry, shadows = NO_SHADOWS)
4300
+ kinds = seeds.filter_map { |name, seed| (kind = content_kind(seed)) && [name, kind] }.to_h
4301
+ return {} if kinds.empty?
4302
+
4303
+ moving = moving_content_names(sites, kinds)
4304
+ fixed = kinds.except(*moving)
4305
+ strings, settled = fixed.partition { |_name, kind| kind == :string }.map(&:to_h)
4306
+ fixed_entry = bind_content_joins(entry, strings, seeds, {})
4307
+ evidence = content_evidence(sites, fixed, fixed_entry, shadows)
4308
+ unless moving.empty?
4309
+ base = bind_content_joins(fixed_entry, settled, seeds, evidence)
4310
+ evidence = evidence.merge(converge_content_evidence(sites, seeds, kinds.slice(*moving), base, shadows))
4311
+ end
4312
+ kinds.to_h { |name, kind| [name, join_content_evidence(seeds[name], kind, name, evidence)] }
4313
+ end
4314
+
4315
+ # The pre-state's collection kind, or nil when the join has no carrier to rederive — the dispatch
4316
+ # {#join_content_for_param} makes, and the reason it answers nil for the same pre-states. A seed carrying both
4317
+ # an Array and a Hash member is `:mixed`, and each side joins with its own class's evidence.
4318
+ def content_kind(pre_state)
4319
+ return nil if pre_state.nil?
4320
+ return :string if stringish?(pre_state)
4321
+ return (arrayish?(pre_state) ? :mixed : :hash) if hashish?(pre_state)
4322
+
4323
+ :array if arrayish?(pre_state)
4324
+ end
4325
+
4326
+ # The names whose evidence can differ between iterations: a store that reads a mutated Array or Hash among its
4327
+ # arguments (a `[]=` call's stored value is one), or an Array-side compound index write (`a[i] += v`), whose
4328
+ # stored value is computed from the slot it overwrites. A String never moves — its join is `String` whatever it
4329
+ # stored — and the Hash side floors an index write's value, so there only the key arguments are typed.
4330
+ def moving_content_names(sites, kinds)
4331
+ movable = kinds.reject { |_name, kind| kind == :string }.keys
4332
+ movable.select do |name|
4333
+ sites[name].any? do |node|
4334
+ (kinds[name] == :array && IndexWriteWidening.index_write?(node)) || store_arguments_read?(node, movable)
4335
+ end
4336
+ end
4337
+ end
4338
+
4339
+ def bind_content_joins(scope, kinds, seeds, evidence)
4340
+ kinds.reduce(scope) do |acc, (name, kind)|
4341
+ acc.with_local(name, content_entry_binding(seeds[name], kind, name, evidence))
4342
+ end
4343
+ end
4344
+
4345
+ # What a collection holds at any iteration's entry, given the evidence so far: its join, plus the seed members
4346
+ # that join refutes. The join already covers every other seed value — a `Tuple`, `HashShape` or `Difference`
4347
+ # is absorbed into the rederived carrier and any other member survives beside it — but it drops a seed's
4348
+ # `nil` ({ContentJoin::NON_SURVIVING_CLASSES}). The first iteration's entry still holds that `nil`, and it can
4349
+ # outlive another collection's growth: without it `out << a.nil?; a ||= []; a << v` read `Array[false]`.
4350
+ # Unioning the whole seed back would re-add its literal shape as well, and dispatch over `[] | Array[2]` is
4351
+ # wider than over `Array[2]`, so `a[0, 1] ||= [2]` stopped converging.
4352
+ def content_entry_binding(seed, kind, name, evidence)
4353
+ join = join_content_evidence(seed, kind, name, evidence)
4354
+ members = seed.is_a?(Type::Union) ? seed.members : [seed]
4355
+ refuted = members.select do |member|
4356
+ ContentJoin::NON_SURVIVING_CLASSES.include?(ContentJoin.evidence_class(member))
4357
+ end
4358
+ refuted.empty? ? join : Type::Combinator.union(join, *refuted)
4359
+ end
4360
+
4361
+ # The nodes that open a local scope a read's `depth` counts.
4362
+ SCOPE_NESTING_NODES = [Prism::BlockNode, Prism::LambdaNode].freeze
4363
+ private_constant :SCOPE_NESTING_NODES
4364
+
4365
+ # The block's captured content mutations: `{ name => [node, ...] }` for every content mutator whose receiver is
4366
+ # a local from OUTSIDE the block. A read's `depth` counts the scopes it climbs, so it reaches past the seam's
4367
+ # block only when it climbs more scopes than the blocks and lambdas nested between it and the block's body.
4368
+ # `collect_content_mutations` tests `depth >= 1`, which is that rule only directly in the body: it took a block
4369
+ # PARAMETER mutated inside a nested block (`|y| [9].each { y << 9 }`), or a nested block's own parameter one
4370
+ # level deeper, for the outer local it shadows, and the join rebound the parameter to that local's contents.
4371
+ #
4372
+ # Each site nested in an inner block or lambda is recorded in `shadows` with the names those blocks bind
4373
+ # themselves (see {#join_content_to_fixpoint}).
4374
+ def captured_content_mutations(block, shadows)
4375
+ mutations = Hash.new { |h, k| h[k] = [] }
4376
+ Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
4377
+ name, site = content_mutation_target(node) { |receiver| receiver.depth > scope_nesting(ancestors) }
4378
+ next if name.nil?
4379
+
4380
+ mutations[name] << site
4381
+ record_shadows(shadows, site, ancestors)
4382
+ end
4383
+ mutations
4384
+ end
4385
+
4386
+ def scope_nesting(ancestors)
4387
+ ancestors.count { |ancestor| scope_nesting_node?(ancestor) }
4388
+ end
4389
+
4390
+ def scope_nesting_node?(node)
4391
+ SCOPE_NESTING_NODES.any? { |klass| node.is_a?(klass) }
4392
+ end
4393
+
4394
+ def record_shadows(shadows, site, ancestors)
4395
+ names = ancestors.select { |a| scope_nesting_node?(a) }
4396
+ .flat_map { |a| CapturedLocals.introduced_locals(a).to_a }
4397
+ shadows[site] = names unless names.empty?
4398
+ end
4399
+
4400
+ # `scope` with each name a site's enclosing inner blocks bind bound to `Dynamic[top]`: the seam's scope cannot
4401
+ # see those bindings, and the name would otherwise resolve to the outer local it shadows.
4402
+ def site_evidence_scope(scope, site, shadows)
4403
+ names = shadows[site]
4404
+ return scope if names.nil?
4405
+
4406
+ names.reduce(scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
4407
+ end
4408
+
4409
+ # `base` binds every fixed name to its join; the moving names are rebound on each pass.
4410
+ def converge_content_evidence(sites, seeds, kinds, base, shadows)
4411
+ slots = kinds.flat_map { |name, kind| CONTENT_EVIDENCE_SLOTS.fetch(kind).map { |slot| [name, slot] } }
4412
+ BodyFixpoint.converge(
4413
+ names: slots,
4414
+ seed_bindings: slots.to_h { |slot| [slot, Type::Combinator.bot] },
4415
+ widen: Type::Combinator.method(:widen_value_pinned),
4416
+ evaluate_body: lambda do |assumption|
4417
+ pass_entry = kinds.reduce(base) do |acc, (name, kind)|
4418
+ acc.with_local(name, content_carrier_under(seeds[name], kind, name, assumption))
4419
+ end
4420
+ content_evidence(sites, kinds, pass_entry, shadows)
4421
+ end
4422
+ )
4423
+ end
4424
+
4425
+ # The binding a fixpoint pass reads a moving collection at: its seed until any evidence exists, then — as for a
4426
+ # fixed name — {#content_entry_binding} over the evidence so far.
4427
+ def content_carrier_under(seed, kind, name, evidence)
4428
+ no_evidence = CONTENT_EVIDENCE_SLOTS.fetch(kind).all? { |slot| present_evidence(evidence[[name, slot]]).empty? }
4429
+ no_evidence ? seed : content_entry_binding(seed, kind, name, evidence)
4430
+ end
4431
+
4432
+ # `{ [name, slot] => union }` for every collection name, typed in `evidence_scope`; a slot no store contributes
4433
+ # to reads `bot`.
4434
+ def content_evidence(sites, kinds, evidence_scope, shadows)
4435
+ kinds.each_with_object({}) do |(name, kind), evidence|
4436
+ case kind
4437
+ when :hash
4438
+ record_pair_evidence(evidence, name, hash_pair_evidence(sites[name], evidence_scope, shadows))
4439
+ when :array
4440
+ evidence[[name, :element]] =
4441
+ Type::Combinator.union(*array_element_evidence(sites[name], evidence_scope, shadows).compact)
4442
+ when :mixed
4443
+ pairs, elements = mixed_content_evidence(sites[name], evidence_scope, shadows)
4444
+ record_pair_evidence(evidence, name, pairs)
4445
+ evidence[[name, :element]] = Type::Combinator.union(*elements.compact)
4446
+ end
4447
+ end
4448
+ end
4449
+
4450
+ # A collection seed with a String member (`[1] | "ab"`) joins that member as `String` and the rest as the
4451
+ # collection it is ({#join_string_members}); joined whole, the String member survived with its value pinned
4452
+ # although a String mutator in the body is what put the name here.
4453
+ def join_content_evidence(seed, kind, name, evidence)
4454
+ if kind != :string && string_union?(seed)
4455
+ return join_string_members(seed) { |others| join_content_evidence(others, kind, name, evidence) }
4456
+ end
4457
+
4458
+ case kind
4459
+ when :string
4460
+ Type::Combinator.nominal_of("String")
4461
+ when :hash
4462
+ ContentJoin.join_hash_content(seed, joined_pair_evidence(name, evidence))
4463
+ when :mixed
4464
+ ContentJoin.join_mixed_content(
4465
+ seed, joined_pair_evidence(name, evidence), present_evidence(evidence[[name, :element]])
4466
+ )
4467
+ else
4468
+ ContentJoin.join_array_content(seed, present_evidence(evidence[[name, :element]]))
4469
+ end
4470
+ end
4471
+
4472
+ def record_pair_evidence(evidence, name, pairs)
4473
+ evidence[[name, :key]] = Type::Combinator.union(*pairs.map(&:first).compact)
4474
+ evidence[[name, :value]] = Type::Combinator.union(*pairs.map(&:last).compact)
4475
+ end
4476
+
4477
+ # The `[pairs, elements]` the `calls` on a mixed `Array | Hash` seed store, typed in `entry_scope`. The seam
4478
+ # cannot tell which member a store reached, so an index store (`[]=` or an index write) is routed by its index.
4479
+ # One no Array accepts — a Symbol, String, `nil` or boolean key, where `[1][:k] = v` raises `TypeError` — is the
4480
+ # Hash side's alone. One that could reach either member floors BOTH sides to `Dynamic[top]`: read precisely, its
4481
+ # value lands on the side it never reached, and a hand-written `-> Array[Integer] | Hash[Symbol, String]`
4482
+ # rejects the `Array["t" | Integer]` a guarded `x[:b] = "t" if x.is_a?(Hash)` made of the Array member. Every
4483
+ # other adder belongs to one class, and each side reads it as its single-class join does.
4484
+ def mixed_content_evidence(calls, entry_scope, shadows = NO_SHADOWS)
4485
+ index_stores, adders = calls.partition { |c| index_write?(c) || (c.is_a?(Prism::CallNode) && c.name == :[]=) }
4486
+ hash_only, either = index_stores.partition do |site|
4487
+ array_index_excluded?(site, site_evidence_scope(entry_scope, site, shadows))
2545
4488
  end
4489
+ pairs = hash_pair_evidence(adders + hash_only, entry_scope, shadows)
4490
+ elements = array_element_evidence(adders, entry_scope, shadows)
4491
+ return [pairs, elements] if either.empty?
4492
+
4493
+ untyped = Type::Combinator.untyped
4494
+ [pairs + [[untyped, untyped]], elements + [untyped]]
4495
+ end
4496
+
4497
+ # The classes an Array index never converts from: none defines `to_int`, and none is a Range.
4498
+ NON_ARRAY_INDEX_CLASSES = %w[Symbol String NilClass TrueClass FalseClass].to_set.freeze
4499
+ private_constant :NON_ARRAY_INDEX_CLASSES
4500
+
4501
+ # True when one of the index store `site`'s index arguments provably holds no value an Array accepts as an index.
4502
+ # A splat, and a type with any member of another or unknown class, may hold one.
4503
+ def array_index_excluded?(site, scope)
4504
+ arguments = site.arguments
4505
+ list = arguments.is_a?(Prism::ArgumentsNode) ? arguments.arguments : []
4506
+ list = list.take(list.size - 1) if site.is_a?(Prism::CallNode)
4507
+ list.any? do |arg|
4508
+ next false if arg.is_a?(Prism::SplatNode)
4509
+
4510
+ ContentJoin.union_members(scope.type_of(arg, tracer: tracer)).all? do |member|
4511
+ NON_ARRAY_INDEX_CLASSES.include?(ContentJoin.evidence_class(member))
4512
+ end
4513
+ end
4514
+ rescue StandardError
4515
+ false
4516
+ end
4517
+
4518
+ # The Hash side's evidence as the one `[key, value]` pair its slots join to, or none.
4519
+ def joined_pair_evidence(name, evidence)
4520
+ key = present_evidence(evidence[[name, :key]]).first
4521
+ value = present_evidence(evidence[[name, :value]]).first
4522
+ key.nil? && value.nil? ? [] : [[key, value]]
4523
+ end
4524
+
4525
+ def present_evidence(type)
4526
+ type.nil? || type.is_a?(Type::Bot) ? [] : [type]
4527
+ end
4528
+
4529
+ def store_arguments_read?(node, names)
4530
+ arguments = node.arguments
4531
+ return false if arguments.nil?
4532
+
4533
+ Source::NodeWalker.each(arguments).any? { |n| n.is_a?(Prism::LocalVariableReadNode) && names.include?(n.name) }
2546
4534
  end
2547
4535
 
2548
4536
  # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
@@ -2567,15 +4555,51 @@ module Rigor
2567
4555
 
2568
4556
  # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
2569
4557
  # captured-local walk.
2570
- calls = body_content_mutations_on(body, memo_param)
4558
+ shadows = {}.compare_by_identity
4559
+ calls = body_content_mutations_on(body, memo_param, shadows)
2571
4560
  return call_type if calls.empty?
2572
4561
 
2573
- pre_state = scope.type_of(memo_arg, tracer: tracer)
2574
- entry = build_block_entry_scope(call_node, block)
2575
- joined = join_content_for_param(calls, pre_state, entry)
4562
+ pre_state = lookup_mutated_seed(body, memo_param, scope.type_of(memo_arg, tracer: tracer)) do |depth, nesting|
4563
+ depth == nesting
4564
+ end
4565
+ joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
2576
4566
  joined || call_type
2577
4567
  end
2578
4568
 
4569
+ # `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
4570
+ # never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
4571
+ # values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
4572
+ # `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
4573
+ # block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
4574
+ # `seed` describes. A `def` opens a scope of its own, so nothing under one is.
4575
+ def lookup_mutated_seed(body, name, seed)
4576
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
4577
+ next unless node.is_a?(Prism::CallNode) && HashLookupMutation::MUTATORS.include?(node.name)
4578
+ next if ancestors.any?(Prism::DefNode)
4579
+
4580
+ receiver = node.receiver
4581
+ next unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
4582
+ next unless yield(receiver.depth, scope_nesting(ancestors))
4583
+
4584
+ seed = MutationWidening.widen_for_mutator(seed, node.name) || seed
4585
+ end
4586
+ seed
4587
+ end
4588
+
4589
+ # The memo's joined carrier. The captured collections the block content-mutates join alongside it, and only the
4590
+ # memo's carrier is kept: a memo store reading one of them (`buf << w; m << buf.length`) must see it as it
4591
+ # stands at any iteration's entry, not at its pre-call contents. Their own continuation is the block seam's to
4592
+ # write.
4593
+ def join_memo_content(call_node, memo_param, calls, pre_state, shadows)
4594
+ block = call_node.block
4595
+ captured = captured_content_mutations(block, shadows)
4596
+ seeds = captured.keys.to_h { |name| [name, scope.local(name)] }
4597
+ seeds[memo_param] = pre_state
4598
+ sites = captured.merge(memo_param => calls)
4599
+ shadow_rebound_reads(block, sites, seeds, shadows)
4600
+ join_content_to_fixpoint(sites, seeds, build_block_entry_scope(call_node, block), shadows)[memo_param]
4601
+ end
4602
+
2579
4603
  # The name of the memo block parameter (the SECOND positional param of an `each_with_object` block), or nil when
2580
4604
  # the block does not bind a second positional param.
2581
4605
  def each_with_object_memo_param(block)
@@ -2592,18 +4616,21 @@ module Rigor
2592
4616
  second.respond_to?(:name) ? second.name : nil
2593
4617
  end
2594
4618
 
2595
- # Content-mutator calls on a block-local receiver `var_name` (depth 0) within `body`.
2596
- def body_content_mutations_on(body, var_name)
4619
+ # Content-mutator calls on the block-local `var_name` within `body` — directly, or from a nested block that
4620
+ # reaches it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a
4621
+ # different variable and does not count.
4622
+ def body_content_mutations_on(body, var_name, shadows)
2597
4623
  calls = []
2598
- Source::NodeWalker.each(body) do |descendant|
4624
+ Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
2599
4625
  next unless descendant.is_a?(Prism::CallNode)
2600
- next unless ContentJoin::CONTENT_ADDERS.include?(descendant.name)
4626
+ next unless CONTENT_MUTATORS.include?(descendant.name)
2601
4627
 
2602
4628
  receiver = descendant.receiver
2603
4629
  next unless receiver.is_a?(Prism::LocalVariableReadNode)
2604
- next unless receiver.name == var_name
4630
+ next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
2605
4631
 
2606
4632
  calls << descendant
4633
+ record_shadows(shadows, descendant, ancestors)
2607
4634
  end
2608
4635
  calls
2609
4636
  end
@@ -2616,11 +4643,14 @@ module Rigor
2616
4643
  # Dynamic out: a shapeless pre-state falls through to `join_array_param`, which declines it.
2617
4644
  def join_content_for_param(calls, pre_state, block_entry)
2618
4645
  return nil if pre_state.nil?
4646
+ return join_string_union(calls, pre_state, block_entry) if string_union?(pre_state)
2619
4647
 
2620
4648
  if stringish?(pre_state)
2621
4649
  # String carries no element parameter; mutating `<<`/`concat` makes the constant value unsound (`s = "a"; s <<
2622
4650
  # x` → runtime `"a…"`), so widen to the nominal base. Sound — only widens.
2623
4651
  Type::Combinator.nominal_of("String")
4652
+ elsif content_kind(pre_state) == :mixed
4653
+ ContentJoin.join_mixed_content(pre_state, *mixed_content_evidence(calls, block_entry))
2624
4654
  elsif hashish?(pre_state)
2625
4655
  join_hash_param(calls, pre_state, block_entry)
2626
4656
  else
@@ -2628,8 +4658,35 @@ module Rigor
2628
4658
  end
2629
4659
  end
2630
4660
 
4661
+ # A union with a String member (`Array | String`, `String?`) joins member by member: the String members widen to
4662
+ # `String`, which has no element evidence to join, and the rest join as a union of their own. Joined whole, the
4663
+ # union reached the Array or Hash join, which dropped the String member and read a String mutator's arguments as
4664
+ # elements — `x.force_encoding(e)` on an `Array | String` capture typed it `Array[1 | Encoding]`.
4665
+ def join_string_union(calls, union, block_entry)
4666
+ join_string_members(union) { |others| join_content_for_param(calls, others, block_entry) }
4667
+ end
4668
+
4669
+ # `String` for the String members of `union`, beside what the block answers for the rest (as a union of their
4670
+ # own), or the rest unchanged when the block answers nil.
4671
+ def join_string_members(union)
4672
+ rest = union.members.reject { |member| string_member?(member) }
4673
+ string = Type::Combinator.nominal_of("String")
4674
+ return string if rest.empty?
4675
+
4676
+ others = Type::Combinator.union(*rest)
4677
+ Type::Combinator.union(string, yield(others) || others)
4678
+ end
4679
+
4680
+ def string_union?(type)
4681
+ type.is_a?(Type::Union) && type.members.any? { |member| string_member?(member) }
4682
+ end
4683
+
4684
+ def string_member?(type)
4685
+ UnknownStoreWidening.carrier_class(type) == "String"
4686
+ end
4687
+
2631
4688
  def join_hash_param(calls, pre_state, block_entry)
2632
- pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
4689
+ pairs = hash_pair_evidence(calls, block_entry)
2633
4690
  return nil if pairs.empty? && !hashish?(pre_state)
2634
4691
 
2635
4692
  ContentJoin.join_hash_content(pre_state, pairs)
@@ -2638,14 +4695,49 @@ module Rigor
2638
4695
  def join_array_param(calls, pre_state, block_entry)
2639
4696
  return nil unless arrayish?(pre_state)
2640
4697
 
2641
- added = calls.flat_map do |c|
2642
- # Index-write on an array (`a[i] += v`) introduces no new element evidence we can cheaply attribute — the
2643
- # array-arity forget already widened the binding; contribute nothing.
2644
- next [] if index_write?(c)
4698
+ ContentJoin.join_array_content(pre_state, array_element_evidence(calls, block_entry))
4699
+ end
4700
+
4701
+ # No site sits under an inner block that shadows a name — the loop seam's answer, and the default.
4702
+ NO_SHADOWS = {}.freeze
4703
+ private_constant :NO_SHADOWS
4704
+
4705
+ # The `[key, value]` pairs `calls` store into a Hash, typed in `block_entry`.
4706
+ def hash_pair_evidence(calls, block_entry, shadows = NO_SHADOWS)
4707
+ calls.flat_map { |c| hash_pair_types(c, site_evidence_scope(block_entry, c, shadows)) }
4708
+ end
4709
+
4710
+ # The elements `calls` add to an Array, typed in `block_entry`.
4711
+ def array_element_evidence(calls, entry_scope, shadows = NO_SHADOWS)
4712
+ calls.flat_map do |c|
4713
+ block_entry = site_evidence_scope(entry_scope, c, shadows)
4714
+ # An index-write in the block (`a[i] += v`, `a[i] ||= v`, an index target) stores
4715
+ # through `[]=` the same way — emit its index arguments ahead of the node's own stored
4716
+ # type so the join classifies the same splice / element forms the straight-line path
4717
+ # does (issue #1140).
4718
+ next ContentJoin.array_added_elements(:[]=, index_write_block_arg_types(c, block_entry)) if index_write?(c)
2645
4719
 
2646
4720
  ContentJoin.array_added_elements(c.name, content_arg_types(c, block_entry))
2647
4721
  end
2648
- ContentJoin.join_array_content(pre_state, added)
4722
+ end
4723
+
4724
+ # `[index_type..., stored_value_type]` for an index-write node inside a block, typed in the
4725
+ # block-entry scope — the stored value is what the write stores through `[]=`, which for a
4726
+ # compound write is the dispatched compound result (`a[i] += v` stores `a[i] + v`, the same
4727
+ # compound result the node itself types as); an index target (a multi-assign slot, a `for`
4728
+ # index, a rescue reference) stays untyped.
4729
+ # `[]` when any type cannot be read, which reproduces the pre-join no-evidence answer.
4730
+ def index_write_block_arg_types(node, block_entry)
4731
+ args = node.arguments
4732
+ return [] if args.nil?
4733
+
4734
+ stored = index_write_stored_type(node, block_entry)
4735
+ return [] if stored.nil?
4736
+
4737
+ list = args.respond_to?(:arguments) ? args.arguments : args
4738
+ list.map { |a| a.is_a?(Prism::SplatNode) ? nil : block_entry.type_of(a, tracer: tracer) } + [stored]
4739
+ rescue StandardError
4740
+ []
2649
4741
  end
2650
4742
 
2651
4743
  # Walks the block body for content-mutator calls (`<<`, `push`, `[]=`, …) whose receiver is a captured outer local
@@ -2660,17 +4752,24 @@ module Rigor
2660
4752
  mutations
2661
4753
  end
2662
4754
 
2663
- # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a multi-assign target) that mutate a collection's
4755
+ # Index-write forms (`h[k] ||= v`, `h[k] += v`, and an index target's `h[k] = v` — a multi-assign slot, a `for`
4756
+ # index, a rescue reference) that mutate a collection's
2664
4757
  # CONTENT without a `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the OrWrite even though the
2665
4758
  # appended values land on the nested array — leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2666
- INDEX_WRITE_NODES = [
2667
- Prism::IndexOrWriteNode,
2668
- Prism::IndexAndWriteNode,
2669
- Prism::IndexOperatorWriteNode,
2670
- Prism::IndexTargetNode
2671
- ].freeze
4759
+ INDEX_WRITE_NODES = IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES
2672
4760
  private_constant :INDEX_WRITE_NODES
2673
4761
 
4762
+ # Every call name a content scan counts: the adders the joins read evidence from, and the String mutators no
4763
+ # Array or Hash table lists. A String carries no element parameter, so a join answers a String pre-state with the
4764
+ # bare `String` whatever the name, and a floor floors it; without them `def strip(s) = s.delete_prefix!("a")` and
4765
+ # an escaping `-> { s.upcase! }` left the caller's `+"ab"` pinned. A name an Array or Hash table also lists stays
4766
+ # with those tables' adders: a scan cannot see the receiver's class, and the Array join reads a non-adder's
4767
+ # arguments as appended elements (`slice!(0)`'s index).
4768
+ CONTENT_MUTATORS = (ContentJoin::CONTENT_ADDERS |
4769
+ (StringMutation::MUTATORS - MutationWidening::ARRAY_MUTATORS -
4770
+ MutationWidening::HASH_MUTATORS)).freeze
4771
+ private_constant :CONTENT_MUTATORS
4772
+
2674
4773
  # The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
2675
4774
  # method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
2676
4775
  # `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
@@ -2681,7 +4780,7 @@ module Rigor
2681
4780
  # (depth predicate), else the frozen `[nil, nil]`. Covers `[]=`-style CallNode mutators and the index-write node
2682
4781
  # forms.
2683
4782
  def content_mutation_target(node)
2684
- is_call_mutator = node.is_a?(Prism::CallNode) && ContentJoin::CONTENT_ADDERS.include?(node.name)
4783
+ is_call_mutator = node.is_a?(Prism::CallNode) && CONTENT_MUTATORS.include?(node.name)
2685
4784
  return NO_CONTENT_MUTATION unless is_call_mutator || index_write?(node)
2686
4785
 
2687
4786
  receiver = node.receiver
@@ -2691,13 +4790,6 @@ module Rigor
2691
4790
  [receiver.name, node]
2692
4791
  end
2693
4792
 
2694
- # Computes the joined continuation collection type for one captured local from its content-mutator calls. Returns
2695
- # `nil` (no overlay) when the pre-state is neither an Array-ish nor a Hash-ish binding — e.g. a String
2696
- # accumulator, whose `<<` carries no element parameter and whose binding already types as `String`.
2697
- def join_content_for_local(name, calls, post_scope, block_entry)
2698
- join_content_for_param(calls, post_scope.local(name), block_entry)
2699
- end
2700
-
2701
4793
  def index_write?(node)
2702
4794
  INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
2703
4795
  end
@@ -2742,10 +4834,16 @@ module Rigor
2742
4834
  return [[key, Type::Combinator.untyped]]
2743
4835
  end
2744
4836
 
4837
+ # Only a Hash adder stores a pair; a String mutator a content scan counted (`x.sub!("a", "b")` on a
4838
+ # `Hash | String` capture) stores none.
4839
+ return [] unless ContentJoin::HASH_CONTENT_ADDERS.include?(node.name)
4840
+
2745
4841
  args = content_arg_types(node, block_entry)
2746
4842
  return [] if args.size < 2
2747
4843
 
2748
- [[args.first, args.last]]
4844
+ # A splat index marker (`nil`) means unknown arity only to the Array classifier;
4845
+ # read as a key it is an unknown value — degrade to untyped (issue #1140).
4846
+ [[args.first || Type::Combinator.untyped, args.last]]
2749
4847
  end
2750
4848
 
2751
4849
  # Type of the index expression of an index-write node (`h[k] ||= v`).
@@ -2762,33 +4860,198 @@ module Rigor
2762
4860
  # Argument types for a content-mutator call, typed against the block-entry scope (block params bound). A
2763
4861
  # sub-evaluator over `block_entry` keeps the argument typing flow-correct for params / `;`-locals without leaking
2764
4862
  # into the outer scope.
2765
- def content_arg_types(call_node, block_entry)
4863
+ def content_arg_types(call_node, block_entry, operand_types = nil)
2766
4864
  arguments = call_node.arguments
2767
4865
  return [] if arguments.nil?
2768
4866
 
2769
- arguments.arguments.map { |arg| block_entry.type_of(arg, tracer: tracer) }
4867
+ list = arguments.arguments
4868
+ list.map.with_index do |arg, i|
4869
+ # For `[]=` a splat in an index position leaves the store's arity open — it is
4870
+ # marked `nil` for {ContentJoin.array_added_elements}, which counts it as
4871
+ # arity-unknown rather than as the untyped index it would type as (issue #1140).
4872
+ next nil if call_node.name == :[]= && i < list.size - 1 && arg.is_a?(Prism::SplatNode)
4873
+
4874
+ OperandWalk.type_of(block_entry, arg, tracer, operand_types)
4875
+ end
2770
4876
  rescue StandardError
2771
4877
  []
2772
4878
  end
2773
4879
 
2774
- # Evaluates `block`'s body once with each written outer local bound to the supplied `bindings` (block params /
2775
- # `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the `BodyFixpoint`
2776
- # body-evaluator.
4880
+ # Evaluates `block`'s body once with each written outer local or ivar bound to the supplied `bindings` (block
4881
+ # params / `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the
4882
+ # `BodyFixpoint` body-evaluator.
2777
4883
  def block_exit_bindings(call_node, block, bindings, names)
4884
+ _type, exit_scope = sub_eval(block, block_pass_entry(call_node, block, bindings))
4885
+ names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
4886
+ end
4887
+
4888
+ # The entry scope of one write-back pass: the block's entry with each written outer local or ivar bound to
4889
+ # `bindings`, and each captured local the body only mutates in place at its widening ({#capture_pass_bindings}).
4890
+ # That widening describes the same object, so it keeps the marks a source-level write drops (issue #1287,
4891
+ # `Scope#with_mutated_local`): a local copied from a declaration-seeded ivar stays exempt from nil-receiver
4892
+ # reports, as it does after straight-line `r << x`.
4893
+ def block_pass_entry(call_node, block, bindings)
2778
4894
  entry = build_block_entry_scope(call_node, block)
2779
- entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
2780
- _type, exit_scope = sub_eval(block, entry)
2781
- names.to_h { |name| [name, exit_scope.local(name)] }
4895
+ capture_pass_bindings(block, bindings).reduce(entry) do |acc, (name, type)|
4896
+ bindings.key?(name) ? bind_capture(acc, name, type) : acc.with_mutated_local(name, type)
4897
+ end
4898
+ end
4899
+
4900
+ NO_CAPTURE_BINDINGS = {}.freeze
4901
+ private_constant :NO_CAPTURE_BINDINGS
4902
+
4903
+ # Issue #1412 — the entry of the statement pass over a block ({#evaluate_block_if_present}). A block its call
4904
+ # may run more than once ({BlockRepetition.may_repeat?}, the gate the block-return pass lays the #587 (b)
4905
+ # binding under) enters with every captured local it mutates in place at its unknown-store widening
4906
+ # ({#capture_pass_bindings}), as each ADR-56 write-back pass enters. The write-back runs only for a
4907
+ # non-escaping body that also REBINDS a capture, so a body that only mutates one (`depth = []; lines.each {
4908
+ # |tl| puts depth.last.length if depth.last; depth << tl }`) was typed from `[]` on every pass: `depth.last`
4909
+ # read `nil`, and the guarded read reported `undefined method` for nil on correct code. A later pass reads
4910
+ # what an earlier one stored, so the widening's gradual arm is the honest entry. The price is that a read
4911
+ # only the first pass makes is gradual too — a false negative, the same one the write-back's passes take.
4912
+ #
4913
+ # A repeating call the escape analysis leaves `:unknown` (an iterator name on an untyped receiver) gets no
4914
+ # write-back at all, so a capture it rebinds from a `nil` placeholder read `nil` on every pass (`names = nil;
4915
+ # io.each_line { … names = {} … names[l] = true }`). Such a capture enters at {#unproven_rebind_bindings}.
4916
+ #
4917
+ # This costs no body pass. A body that can neither rebind nor mutate a captured binding
4918
+ # ({CapturedLocals.may_touch_capture?}, an allocation-free scan) — most of them — skips the gate, and the
4919
+ # gate reads the one receiver type and escape class the rest of {#eval_call} reads.
4920
+ def repeating_block_entry(call_node, block)
4921
+ body = block.body
4922
+ return build_block_entry_scope(call_node, block) if body.nil? || !CapturedLocals.may_touch_capture?(body, scope)
4923
+
4924
+ classification = repeating_block_class(call_node)
4925
+ return build_block_entry_scope(call_node, block) if classification.nil?
4926
+
4927
+ bindings = unproven_rebind_bindings(call_node, block, classification)
4928
+ return build_block_entry_scope(call_node, block) if bindings.empty? && block_content_mutations(block).empty?
4929
+
4930
+ block_pass_entry(call_node, block, bindings)
4931
+ end
4932
+
4933
+ # The call's {ClosureEscapeAnalyzer} class when {BlockRepetition.may_repeat?} holds for it, else nil. The
4934
+ # receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
4935
+ # (`Object` at the top level).
4936
+ def repeating_block_class(call_node)
4937
+ receiver_type =
4938
+ if call_node.receiver
4939
+ explicit_receiver_type(call_node)
4940
+ else
4941
+ operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
4942
+ end
4943
+ return nil if receiver_type.nil?
4944
+
4945
+ method_name = call_node.name
4946
+ classification = ClosureEscapeAnalyzer.classify(receiver_type: receiver_type, method_name: method_name,
4947
+ scope: scope)
4948
+ repeats = BlockRepetition.may_repeat?(
4949
+ method_name: method_name, receiver_type: receiver_type, scope: scope, classification: classification
4950
+ )
4951
+ repeats ? classification : nil
4952
+ rescue StandardError
4953
+ nil
4954
+ end
4955
+
4956
+ # The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
4957
+ # `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
4958
+ # its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
4959
+ # `:non_escaping`): the seed joined with `Dynamic[top]`. A sentinel is a placeholder the body replaces before
4960
+ # the reads it guards (`names = nil; io.each_line { |l| if state == :start; names = {}; else names[l] = true;
4961
+ # end }`), so a later pass reads whatever replaced it, and no pass types that here. Any other seed keeps its
4962
+ # call-site binding: the first pass reads it, and a later pass's binding is left unmodelled, as it was before.
4963
+ # A call the write-back reaches answers none: its fixpoint enters every pass it records at the converged
4964
+ # binding.
4965
+ def unproven_rebind_bindings(call_node, block, classification)
4966
+ return NO_CAPTURE_BINDINGS if call_node.receiver && classification == :non_escaping
4967
+
4968
+ names = CapturedLocals.writes(block, scope, ivars: true)
4969
+ return NO_CAPTURE_BINDINGS if names.empty?
4970
+
4971
+ untyped = Type::Combinator.untyped
4972
+ names.each_with_object({}) do |name, acc|
4973
+ seed = CapturedLocals.bound_type(scope, name)
4974
+ acc[name] = Type::Combinator.union(seed, untyped) if seed && sentinel_seed?(seed)
4975
+ end
4976
+ end
4977
+
4978
+ def sentinel_seed?(type)
4979
+ case type
4980
+ when Type::Constant then type.value.nil? || type.value == false
4981
+ when Type::Union then type.members.all? { |member| sentinel_seed?(member) }
4982
+ else false
4983
+ end
4984
+ end
4985
+
4986
+ # `bindings` plus the pass binding of every captured local the body mutates in place
4987
+ # ({CapturedLocals.content_mutations}): its binding widened for a store of UNKNOWN values at every mutation site
4988
+ # ({#unknown_store_binding}), so it holds whatever any earlier iteration stored. A name the pass does not move
4989
+ # is widened from its call-site binding; a name it moves (the body both rebinds and mutates it) is widened over
4990
+ # the running assumption, as the per-element fold widens the same name (#587 (b)) — that assumption carries the
4991
+ # exits of the body's straight-line seam, which can close the collection without a gradual arm
4992
+ # (`stack ||= [0]; top = stack.pop; stack.push(x)` kept `top` at `0?`). The stored values are not typed: one
4993
+ # computed from the collection's own entry contents is the same first-iteration answer. `bindings` itself
4994
+ # comes back for the common body that mutates nothing captured.
4995
+ #
4996
+ # The price is the gradual arm on a rebind that reads such a collection — `last = a.last; a << x` over `a =
4997
+ # [0]` reads `0 | Dynamic[top] | nil`, not `0 | 1 | 2 | nil`. Precise evidence would mean iterating this
4998
+ # fixpoint jointly with slice C's content join; ADR-56 WD2.13 records why that was not taken.
4999
+ def capture_pass_bindings(block, bindings)
5000
+ stores = block_content_mutations(block)
5001
+ return bindings if stores.empty?
5002
+
5003
+ widened = stores.each_with_object({}) do |(name, sites), acc|
5004
+ next if bindings.key?(name)
5005
+
5006
+ seed = scope.local(name)
5007
+ acc[name] = unknown_store_binding(seed, sites) unless seed.nil?
5008
+ end
5009
+ widened.merge(bindings.to_h do |name, type|
5010
+ sites = stores[name]
5011
+ [name, sites.nil? || type.nil? ? type : unknown_store_binding(type, sites)]
5012
+ end)
5013
+ end
5014
+
5015
+ # {CapturedLocals.content_mutations} of `block` against this evaluator's scope, once per block: every write-back
5016
+ # pass asks, and neither input changes between them.
5017
+ def block_content_mutations(block)
5018
+ (@block_content_mutations ||= {}.compare_by_identity)[block] ||= CapturedLocals.content_mutations(block, scope)
5019
+ end
5020
+
5021
+ # `type` widened through `sites` for a store of unknown values, with one more step: when the result is still a
5022
+ # collection whose contents are value-pinned, those pins are the first-iteration answer and the contents take
5023
+ # the gradual arm. A widening that DECLINES leaves such a binding — `s = [0, 9]; s.pop` leaves `Array[0 | 9]`,
5024
+ # a nominal the `push` in the body then declines — and so does one that only changes a refinement: under `if
5025
+ # s.any?` the `pop` drops `non-empty-array[0 | 9]` to that same pinned `Array[0 | 9]` before the `push` declines
5026
+ # it. Either way `top = s.last; s.push(x)` would keep `top` at `0 | 9`. A result that already carries the arm
5027
+ # is unchanged by it.
5028
+ def unknown_store_binding(type, sites)
5029
+ widened = UnknownStoreWidening.widen(type, sites)
5030
+ return widened unless UnknownStoreWidening.value_pinned_collection?(widened)
5031
+
5032
+ UnknownStoreWidening.gradual_content(widened)
2782
5033
  end
2783
5034
 
2784
5035
  # `Prism::BlockNode` is reached through {#eval_call}; the handler runs the body under `scope`, which the caller
2785
5036
  # has already augmented with the block's parameter bindings. Effects do not leak past the block (the outer
2786
5037
  # eval_call returns the caller's scope unchanged), but the body's local writes are threaded through subsequent
2787
5038
  # statements *inside* the block so `each { |x| sum = x; sum.succ }` types `sum.succ` under the `sum: x` binding.
5039
+ # The scope returned is the one the invocation ENDS with — the fall-through joined with every `next` that
5040
+ # leaves it ({#evaluate_invocation}).
2788
5041
  def eval_block(node)
2789
- return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
5042
+ type, _fall_through, exit_scope = evaluate_invocation(node)
5043
+ [type, exit_scope]
5044
+ end
2790
5045
 
2791
- sub_eval(node.body, scope)
5046
+ # `base` joined with every collected jump scope whose node is in `targets` — a jump belonging to a nested
5047
+ # construct lands in the same sink and is dropped by identity.
5048
+ def join_jump_scopes(base, sink, targets)
5049
+ targeted_scopes(sink, targets).reduce(base) { |acc, jump_scope| acc.join(jump_scope) }
5050
+ end
5051
+
5052
+ # The scopes a sink collected at the jumps in `targets`.
5053
+ def targeted_scopes(sink, targets)
5054
+ sink.filter_map { |node, jump_scope| jump_scope if targets.key?(node) }
2792
5055
  end
2793
5056
 
2794
5057
  # Issue #878 — `->() { }` and `lambda { }` build the same object, so they MUST type the same. The `lambda`
@@ -2803,9 +5066,13 @@ module Rigor
2803
5066
  # literal is a value that outlives the expression, exactly like the Proc `lambda` returns, so the outer locals it
2804
5067
  # can rebind lose their narrowing rather than being written back through ADR-56's non-escaping fixpoint. Both
2805
5068
  # halves are widenings — the two spellings now agree in both directions instead of `->` being the precise one.
5069
+ # A lambda is a return barrier: `return` inside it returns from the lambda, so its body runs with the method's
5070
+ # return sink suspended, as a nested `def` body does ({#eval_def}). Issue #1223 made the barrier matter more
5071
+ # often: a lambda passed as an argument (`register(-> { return :skip if … })`) is now evaluated with its
5072
+ # enclosing call's operands, where it used to be typed as a value only.
2806
5073
  def eval_lambda(node)
2807
5074
  lambda_type = scope.type_of(node, tracer: tracer)
2808
- sub_eval(node.body, build_block_entry_scope(nil, node)) unless node.body.nil?
5075
+ without_return_sink { sub_eval(node.body, build_block_entry_scope(nil, node)) } unless node.body.nil?
2809
5076
 
2810
5077
  [lambda_type, escaping_closure_captures(node, scope)]
2811
5078
  end
@@ -2827,17 +5094,46 @@ module Rigor
2827
5094
  # when the runtime would actually `NoMethodError` on `nil`.
2828
5095
  def build_block_entry_scope(call_node, block_node)
2829
5096
  expected = expected_block_param_types_for(call_node)
2830
- bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
2831
5097
  # Issue #316 — every block body enters with `self` unmodelled (`Scope#entering_opaque_block`); the
2832
5098
  # yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
2833
- scope_with_params = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
2834
- acc.with_local(name, type)
2835
- end
5099
+ # Issue #1358 — a body that may run a match reads the match globals an earlier iteration may have rebound
5100
+ # ({MatchRebinding.block_entry}).
5101
+ entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
5102
+ scope_with_params = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
5103
+ # ADR-16 Tier A — a plugin `block_as_methods:` entry that matches `(receiver, name)` narrows the
5104
+ # body's `self` to the object the DSL `instance_eval`s the block on (`params` on
5105
+ # `Grape::Validations::ParamsScope`, `namespace` on the `Grape::API::Instance` class object, verb
5106
+ # bodies on `Grape::Endpoint`). The expression-side narrowing
5107
+ # ({ExpressionTyper#block_body_self_narrowing}) already applies the same contract to block-return
5108
+ # typing; without it here the recorded per-node scopes — what `dump_type`/`assert_type` and the
5109
+ # survey read — keep the enclosing `self_type` and every DSL call inside stays `Dynamic[top]`.
5110
+ narrowed = call_node && narrow_macro_block_self(call_node)
5111
+ scope_with_params = scope_with_params.with_self_type(narrowed) if narrowed
2836
5112
  block_local_names(block_node).reduce(scope_with_params) do |acc, name|
2837
5113
  acc.with_local(name, Type::Combinator.constant_of(nil))
2838
5114
  end
2839
5115
  end
2840
5116
 
5117
+ # The receiver an ADR-16 `block_as_methods:` match is keyed on: the explicit receiver's type, or the
5118
+ # current `self_type` for an implicit-self DSL call (the `params do` / `namespace do` shapes, whose
5119
+ # receiver is the enclosing `Singleton[X]`). A miss leaves the entry scope as built — the false-
5120
+ # positive-safe direction.
5121
+ def narrow_macro_block_self(call_node)
5122
+ receiver_type =
5123
+ if call_node.receiver
5124
+ explicit_receiver_type(call_node)
5125
+ else
5126
+ scope.self_type
5127
+ end
5128
+ return nil if receiver_type.nil?
5129
+
5130
+ MacroBlockSelfType.narrow_self_type_for(
5131
+ scope: scope, call_node: call_node, receiver_type: receiver_type
5132
+ )
5133
+ rescue StandardError
5134
+ nil
5135
+ end
5136
+
2841
5137
  def block_local_names(block_node)
2842
5138
  params_root = block_node.parameters
2843
5139
  return [] unless params_root.is_a?(Prism::BlockParametersNode)
@@ -2850,7 +5146,12 @@ module Rigor
2850
5146
  def expected_block_param_types_for(call_node)
2851
5147
  return [] if call_node.nil?
2852
5148
 
2853
- receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
5149
+ receiver_type =
5150
+ if call_node.receiver
5151
+ explicit_receiver_type(call_node)
5152
+ else
5153
+ scope.self_type || scope.environment.nominal_for_name("Object")
5154
+ end
2854
5155
  return [] if receiver_type.nil?
2855
5156
 
2856
5157
  arg_types = call_arg_types_for(call_node)
@@ -2858,7 +5159,8 @@ module Rigor
2858
5159
  receiver_type: receiver_type,
2859
5160
  method_name: call_node.name,
2860
5161
  arg_types: arg_types,
2861
- environment: scope.environment
5162
+ environment: scope.environment,
5163
+ scope: scope
2862
5164
  )
2863
5165
  rescue StandardError
2864
5166
  []
@@ -2868,7 +5170,7 @@ module Rigor
2868
5170
  arguments = call_node.arguments
2869
5171
  return [] if arguments.nil?
2870
5172
 
2871
- arguments.arguments.map { |arg| scope.type_of(arg, tracer: tracer) }
5173
+ arguments.arguments.map { |arg| type_operand(arg) }
2872
5174
  end
2873
5175
 
2874
5176
  # ----- def/class helpers -----
@@ -2883,6 +5185,11 @@ module Rigor
2883
5185
  fresh = build_fresh_body_scope
2884
5186
  body_self = self_type_for_class_body(new_context)
2885
5187
  fresh = fresh.with_self_type(body_self) if body_self
5188
+ # Issue #963 — `self` in a `class << ...` body is the SINGLETON class, which shares the `Singleton[X]`
5189
+ # carrier a `class X` body gets. The mark is the only thing that tells the two apart downstream, and a
5190
+ # `def` reached from this body clears it by starting from a fresh scope.
5191
+ fresh = fresh.with_singleton_class_body(node.is_a?(Prism::SingletonClassNode))
5192
+ fresh = fresh.with_match_frame(node.body)
2886
5193
  fresh = stamp_nesting(fresh, new_nesting)
2887
5194
  sub_eval(node.body, fresh, class_context: new_context, lexical_nesting: new_nesting)
2888
5195
  end
@@ -2891,7 +5198,9 @@ module Rigor
2891
5198
  singleton = singleton_def?(def_node)
2892
5199
  binder = MethodParameterBinder.new(
2893
5200
  environment: scope.environment,
2894
- class_path: current_class_path,
5201
+ # Issue #1120 — a refinement exists to redefine, so X's declared parameters for the name are not this
5202
+ # def's contract; it binds its parameters as an undeclared method does.
5203
+ class_path: @class_context.last&.refinement ? nil : current_class_path,
2895
5204
  singleton: singleton,
2896
5205
  source_path: scope.source_path
2897
5206
  )
@@ -2917,6 +5226,9 @@ module Rigor
2917
5226
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2918
5227
  fresh = seed_class_cvars(fresh)
2919
5228
  fresh = seed_program_globals(fresh)
5229
+ # Issue #1358 — the body runs in a frame of its own, whose match globals its blocks and closures share, and
5230
+ # so do its parameters' default expressions.
5231
+ fresh = fresh.with_match_frame(def_node.body, def_node.parameters)
2920
5232
  # ADR-48 Struct slice 3 — install the method body's fold-safe-local set so a member read off a mutation-free
2921
5233
  # local folds during the in-body walk (the call-return inference path is seeded separately).
2922
5234
  fresh = fresh.with_struct_fold_safe(
@@ -3016,14 +5328,21 @@ module Rigor
3016
5328
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
3017
5329
  end
3018
5330
 
3019
- # Globals are process-wide. The body scope already inherited the program-globals accumulator through
3020
- # `with_program_globals`; seeding here just materialises each entry into the body's `globals` map so reads observe
3021
- # a precise type without consulting the accumulator on every lookup.
5331
+ # Globals are process-wide. The body scope already inherited the program-global tables through its discovery
5332
+ # index; seeding here just materialises each entry into the body's `globals` map so reads observe a precise type
5333
+ # without consulting the index on every lookup. The frame-local `$_` and `$~` are not in it (issue #1359): a
5334
+ # method body starts with a slot of its own. A global Ruby's own signatures declare is seeded with its declared
5335
+ # type joined with the file's writes, under the ADR-58 `:global` mark (issue #1362,
5336
+ # `ScopeIndexer#join_declared_globals`, `Scope#seed_declaration_sourced_global`).
3022
5337
  def seed_program_globals(body_scope)
3023
- seeded = scope.program_globals
3024
- return body_scope if seeded.empty?
5338
+ written = scope.program_globals
5339
+ return body_scope if written.empty?
3025
5340
 
3026
- seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
5341
+ seeds = scope.discovery.program_global_seeds
5342
+ written.reduce(body_scope) do |acc, (name, type)|
5343
+ seed = seeds[name]
5344
+ seed ? acc.seed_declaration_sourced_global(name, seed) : acc.with_global(name, type)
5345
+ end
3027
5346
  end
3028
5347
 
3029
5348
  # Slice A-declarations. Class- and method-bodies start from a fresh local-empty scope, but they MUST keep the
@@ -3183,21 +5502,33 @@ module Rigor
3183
5502
  def eval_next(node)
3184
5503
  sink = Thread.current[NEXT_SINK_KEY]
3185
5504
  sink << [node, jump_value_type(node)] if sink
5505
+ @next_scope_sink << [node, jump_scope(node)] if @next_scope_sink
3186
5506
  [Type::Combinator.bot, scope]
3187
5507
  end
3188
5508
 
3189
- # A `break` transfers control to the loop exit (its flow value is `Bot`, like `return`). It records the current
3190
- # scope into the active loop's break sink so the loop join can recover a `break`-path binding the fall-through
3191
- # would drop (`flag = true; break` -> `flag` is `false | true` after the loop). nil sink = a `break` not inside an
3192
- # inferred loop body (a block targeting a method, or top-level) — left to the existing escaping-block / no-op
3193
- # handling.
5509
+ # The scope control leaves with at a `next` / `break`: the entry scope threaded through the jump's arguments, so a
5510
+ # write inside one (`next(n = :odd)`, `break(flag = true)`) is part of the path that leaves. The arguments are
5511
+ # evaluated without recording into the per-node scope index: whether a sink is collecting depends on unrelated
5512
+ # context (a captured write elsewhere in the block), and the index must not change with it.
5513
+ def jump_scope(node)
5514
+ args = node.arguments&.arguments || []
5515
+ args.reduce(scope) { |acc, arg| sub_eval(arg, acc, **UNRECORDED).last }
5516
+ end
5517
+
5518
+ # A `break` transfers control to the loop exit (its flow value is `Bot`, like `return`). It records the scope it
5519
+ # leaves with ({#jump_scope}) into the active break sink so the loop join can recover a `break`-path binding the
5520
+ # fall-through would drop (`flag = true; break` -> `flag` is `false | true` after the loop); ADR-56's block
5521
+ # write-back reads the same sink for a `break` that ends a yielding call ({#join_block_break_bindings}), and an
5522
+ # enclosing `begin … ensure` carries the recorded scope through its clause ({#carry_jumps_through_ensure}). nil
5523
+ # sink = a `break` reached outside either collection (top level, or an escaping block) — left to the existing
5524
+ # escaping-block / no-op handling.
3194
5525
  #
3195
5526
  # Issue #853: the value it carries out belongs to the yielding CALL, so it is recorded into the separate
3196
5527
  # break-value sink for `ExpressionTyper#call_dispatch_type_for` to union in. Both sinks are optional and
3197
5528
  # independent — a loop body collects scopes while an enclosing call collects values from the same walk.
3198
5529
  def eval_break(node)
3199
5530
  sink = Thread.current[BREAK_SINK_KEY]
3200
- sink << [node, scope] if sink
5531
+ sink << [node, jump_scope(node)] if sink
3201
5532
  value_sink = Thread.current[BREAK_VALUE_SINK_KEY]
3202
5533
  value_sink << [node, jump_value_type(node)] if value_sink
3203
5534
  [Type::Combinator.bot, scope]
@@ -3215,15 +5546,34 @@ module Rigor
3215
5546
  Type::Combinator.tuple_of(*args.map { |arg| sub_eval(arg, scope).first })
3216
5547
  end
3217
5548
 
3218
- def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting)
5549
+ # `on_enter: nil` evaluates without recording into the per-node scope index — for a pass whose scopes are not
5550
+ # the ones the index should keep. `next_scope_sink:` is replaced only by {#evaluate_invocation} and
5551
+ # {#loop_iteration}.
5552
+ def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting,
5553
+ on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_recorder: @operand_recorder)
5554
+ evaluator_at(with_scope, class_context: class_context, lexical_nesting: lexical_nesting, on_enter: on_enter,
5555
+ next_scope_sink: next_scope_sink, operand_recorder: operand_recorder).evaluate(node)
5556
+ end
5557
+
5558
+ # An evaluator over `with_scope` that inherits everything else from this one. `operand_scope:` and
5559
+ # `operand_types:` are set only by {#invoke_from}, for the evaluator that runs a call from the scope its
5560
+ # operands left.
5561
+ def evaluator_at(with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting, # rubocop:disable Metrics/ParameterLists
5562
+ on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_scope: nil,
5563
+ in_operand: @in_operand, operand_recorder: @operand_recorder, operand_types: nil)
3219
5564
  StatementEvaluator.new(
3220
5565
  scope: with_scope,
3221
5566
  tracer: tracer,
3222
- on_enter: @on_enter,
5567
+ on_enter: on_enter,
3223
5568
  class_context: class_context,
3224
5569
  lexical_nesting: lexical_nesting,
3225
- converged_loop_recording: @converged_loop_recording
3226
- ).evaluate(node)
5570
+ converged_loop_recording: @converged_loop_recording,
5571
+ next_scope_sink: next_scope_sink,
5572
+ operand_scope: operand_scope,
5573
+ in_operand: in_operand,
5574
+ operand_recorder: operand_recorder,
5575
+ operand_types: operand_types
5576
+ )
3227
5577
  end
3228
5578
 
3229
5579
  # Slice 7 phase 14 — branch exit detection. Returns true when the branch's body unconditionally exits the
@@ -3279,10 +5629,10 @@ module Rigor
3279
5629
  branch_type.is_a?(Type::Bot)
3280
5630
  end
3281
5631
 
3282
- def eval_branch_or_nil(branch_node, branch_scope)
5632
+ def eval_branch_or_nil(branch_node, branch_scope, on_enter: @on_enter)
3283
5633
  return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
3284
5634
 
3285
- sub_eval(branch_node, branch_scope)
5635
+ sub_eval(branch_node, branch_scope, on_enter: on_enter)
3286
5636
  end
3287
5637
 
3288
5638
  # Joins two branch scopes at a control-flow merge point. Names bound in only one branch are nil-injected into the
@@ -3310,17 +5660,31 @@ module Rigor
3310
5660
  # ---------------------------------------------------------------
3311
5661
 
3312
5662
  # Returns `scope` extended with the rescue reference variable bound to the exception instance type. Leaves scope
3313
- # unchanged when the node carries no reference (bare `rescue` without `=> var`).
5663
+ # unchanged when the node carries no reference (bare `rescue` without `=> var`). An index-target reference
5664
+ # (`rescue => h[:e]`) stores the exception through `[]=` instead, so its receiver widens with the exception
5665
+ # instance type as the stored value, exactly as `rescue => e; h[:e] = e` widens it.
5666
+ #
5667
+ # Issue #1360 — with or without a reference, the clause runs with `$!` bound to the exception it rescued, `$@` to
5668
+ # its backtrace and `$?` unbound ({ErrorInfo.rescue_entry}); {#eval_begin_node} restores `$!` and `$@` once the
5669
+ # `begin` exits.
3314
5670
  def bind_rescue_reference(rescue_node, scope)
5671
+ exception_type = rescue_exception_type(rescue_node, scope)
3315
5672
  ref = rescue_node.reference
3316
- return scope unless ref.is_a?(Prism::LocalVariableTargetNode)
3317
-
3318
- scope.with_local(ref.name, rescue_exception_type(rescue_node, scope))
5673
+ reference = ref.name if ref.is_a?(Prism::LocalVariableTargetNode)
5674
+ scope = ErrorInfo.rescue_entry(scope, exception_type, rescue_node.statements, reference)
5675
+ case ref
5676
+ when Prism::LocalVariableTargetNode
5677
+ scope.with_local(ref.name, exception_type)
5678
+ when Prism::IndexTargetNode
5679
+ widen_index_target(ref, exception_type, scope, type_scope: scope)
5680
+ else
5681
+ scope
5682
+ end
3319
5683
  end
3320
5684
 
3321
5685
  # Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
3322
- # type is `StandardError`. When one or more exception classes are named the types are unioned. Falls back to
3323
- # `StandardError` for any class that cannot be resolved to a `Singleton` type.
5686
+ # type is `StandardError`. When one or more exception classes are named the types are unioned. A class that
5687
+ # cannot be resolved to a `Singleton` type contributes `Dynamic[top]`.
3324
5688
  def rescue_exception_type(rescue_node, scope)
3325
5689
  exceptions = rescue_node.exceptions
3326
5690
  if exceptions.empty?
@@ -3338,87 +5702,392 @@ module Rigor
3338
5702
  # ---------------------------------------------------------------
3339
5703
 
3340
5704
  # Builds the entry scope for an `in` branch by injecting every variable captured by the pattern as a local
3341
- # binding.
3342
- def apply_in_pattern_bindings(subject, pattern, scope)
3343
- bindings = collect_in_pattern_bindings(subject, pattern, scope)
5705
+ # binding. `subject_type` is the type of the `case` subject — nil when the `case` carries no predicate, which
5706
+ # leaves every binding at the `Dynamic[top]` floor — and `subject_node` the subject expression, which the
5707
+ # `deconstruct` / `deconstruct_keys` dispatch reads for its freshness gate.
5708
+ def apply_in_pattern_bindings(subject_type, subject_node, pattern, scope)
5709
+ bindings = collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
3344
5710
  bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
3345
5711
  end
3346
5712
 
3347
- # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`. Unrecognised pattern
3348
- # nodes contribute no bindings (fail-soft).
3349
- def collect_in_pattern_bindings(subject, pattern, scope)
5713
+ # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`, typed against the
5714
+ # subject. Unrecognised pattern nodes contribute no bindings (fail-soft).
5715
+ #
5716
+ # A union subject distributes first (see {#collect_union_pattern_bindings}); every other subject walks the
5717
+ # pattern once, and each node kind decides how much of the subject it can name:
5718
+ #
5719
+ # - a bare target (`in [i, s]`, `in x`) binds the slot the enclosing pattern hands it,
5720
+ # - a capture (`Integer => i`, `[a, b] => whole`) binds its own constraint, and recurses into the captured
5721
+ # pattern,
5722
+ # - an array / find / hash pattern decomposes the subject (see the three collectors below).
5723
+ def collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: nil)
5724
+ if subject_type.is_a?(Type::Union)
5725
+ return collect_union_pattern_bindings(subject_type.members, pattern, scope, subject_node: subject_node)
5726
+ end
5727
+
3350
5728
  case pattern
3351
5729
  when Prism::CapturePatternNode
3352
- [[pattern.target.name, pattern_capture_type(pattern.value, scope)]]
5730
+ collect_capture_pattern_bindings(subject_type, pattern, scope)
3353
5731
  when Prism::LocalVariableTargetNode
3354
- subject_type = subject.is_a?(Prism::LocalVariableReadNode) ? scope.local(subject.name) : nil
3355
5732
  [[pattern.name, subject_type || Type::Combinator.untyped]]
3356
5733
  when Prism::ImplicitNode
3357
- collect_in_pattern_bindings(subject, pattern.value, scope)
5734
+ collect_in_pattern_bindings(subject_type, pattern.value, scope)
3358
5735
  when Prism::ArrayPatternNode
3359
- collect_array_pattern_bindings(pattern, scope)
5736
+ collect_array_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
3360
5737
  when Prism::FindPatternNode
3361
- collect_find_pattern_bindings(pattern, scope)
5738
+ collect_find_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
3362
5739
  when Prism::HashPatternNode
3363
- collect_hash_pattern_bindings(pattern, scope)
5740
+ collect_hash_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
3364
5741
  when Prism::AlternationPatternNode
3365
- collect_alternation_pattern_bindings(subject, pattern, scope)
5742
+ collect_alternation_pattern_bindings(subject_type, pattern, scope)
3366
5743
  else
3367
5744
  []
3368
5745
  end
3369
5746
  end
3370
5747
 
3371
- def collect_array_pattern_bindings(pattern, scope)
3372
- bindings = [*pattern.requireds, *pattern.posts].flat_map do |elem|
3373
- collect_in_pattern_bindings(nil, elem, scope)
5748
+ # `pattern => target` binds `target` to what the pattern matched AND every name the pattern itself binds:
5749
+ # `in [a, b] => whole` binds `a`, `b` and `whole`. The target's own type is the pattern's constraint when it
5750
+ # names a class (`Integer => i`), and the subject otherwise — a capture over any other pattern IS the subject
5751
+ # the pattern matched.
5752
+ def collect_capture_pattern_bindings(subject_type, pattern, scope)
5753
+ target = pattern.target
5754
+ inner = collect_in_pattern_bindings(subject_type, pattern.value, scope)
5755
+ return inner unless target.is_a?(Prism::LocalVariableTargetNode)
5756
+
5757
+ [[target.name, capture_pattern_type(subject_type, pattern.value, scope)]] + inner
5758
+ end
5759
+
5760
+ # `in [i, s]` / `in [a, *rest, z]` / `in Foo[a, b]`.
5761
+ def collect_array_pattern_bindings(subject_type, pattern, scope, subject_node:)
5762
+ subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
5763
+ fronts, rest_type, backs = pattern_slot_types(subject_type, pattern, scope, subject_node)
5764
+ bindings = pattern.requireds.each_with_index.flat_map do |elem, i|
5765
+ collect_in_pattern_bindings(fronts[i], elem, scope)
5766
+ end
5767
+ bindings += pattern.posts.each_with_index.flat_map do |elem, i|
5768
+ collect_in_pattern_bindings(backs[i], elem, scope)
5769
+ end
5770
+ append_array_splat_binding(bindings, pattern.rest, rest_type)
5771
+ bindings
5772
+ end
5773
+
5774
+ # The per-slot types a positional pattern reads: the subject's own decomposition when it is a carrier
5775
+ # {MultiTargetBinder.decompose_slots} accepts (`Tuple`, `Array[T]`), else the `deconstruct` projection of a
5776
+ # subject that defines one (`Struct#deconstruct`, a `Data` instance, a class whose `deconstruct` names the
5777
+ # parts), else the `Dynamic[top]` floor per slot.
5778
+ def pattern_slot_types(subject_type, pattern, scope, subject_node)
5779
+ view = positional_pattern_view(subject_type, scope, subject_node)
5780
+ return floor_pattern_slots(pattern.requireds.size, pattern.posts.size, !pattern.rest.nil?) if view.nil?
5781
+
5782
+ MultiTargetBinder.decompose_slots(
5783
+ view, front_count: pattern.requireds.size, back_count: pattern.posts.size,
5784
+ rest_present: !pattern.rest.nil?, scope: scope
5785
+ )
5786
+ end
5787
+
5788
+ # The carrier a positional pattern decomposes: the subject itself when {MultiTargetBinder} already accepts it,
5789
+ # else what the subject's `deconstruct` answers with (a `Tuple` / `Array[T]` carrier), else nil.
5790
+ #
5791
+ # `subject_node` rides along as the dispatch's call node so the `Struct` fold's freshness gate can see the
5792
+ # receiver: `case Point.new(1, 2); in [x, y]` is a freshly materialised instance and folds, while a stored
5793
+ # binding that may have been mutated since does not (ADR-48).
5794
+ def positional_pattern_view(subject_type, scope, subject_node)
5795
+ return subject_type if subject_type.is_a?(Type::Tuple)
5796
+ return subject_type if MultiTargetBinder.array_element_type(subject_type)
5797
+
5798
+ deconstruct_projection(subject_type, scope, subject_node)
5799
+ end
5800
+
5801
+ # What `subject.deconstruct` answers with, when that is a carrier this binder decomposes; nil otherwise —
5802
+ # an absent method, or `Struct#deconstruct`'s RBS `Array[untyped]` for a class whose members are not known.
5803
+ def deconstruct_projection(subject_type, scope, subject_node)
5804
+ result = struct_instance_projection(subject_type, :deconstruct, scope, subject_node) ||
5805
+ pattern_decomposition_dispatch(subject_type, :deconstruct, [], scope) ||
5806
+ source_decomposition_projection(subject_type, :deconstruct, [], scope)
5807
+ return nil if result.nil?
5808
+ return result if result.is_a?(Type::Tuple) || MultiTargetBinder.array_element_type(result)
5809
+
5810
+ nil
5811
+ end
5812
+
5813
+ # The inferred return type of a project-defined `deconstruct` / `deconstruct_keys` (issue #1122). The
5814
+ # dispatcher answers nil for a class no RBS describes: the body-inference tier that would type
5815
+ # `subject.deconstruct` lives on `ExpressionTyper` and needs the call NODE a pattern does not have, so
5816
+ # this asks the scope's own entry point for the same answer a resolved call site gets (ADR-84 memo
5817
+ # included). nil when the project defines no such method, or when the body's answer is the gradual floor.
5818
+ def source_decomposition_projection(subject_type, method_name, arg_types, scope)
5819
+ return nil unless subject_type.is_a?(Type::Nominal)
5820
+
5821
+ def_node = scope.discovered_def_nodes[subject_type.class_name]&.[](method_name)
5822
+ return nil if def_node.nil?
5823
+
5824
+ result = scope.user_method_return(def_node, subject_type, arg_types)
5825
+ return nil if result.nil? || result.is_a?(Type::Dynamic) || result.is_a?(Type::Top)
5826
+
5827
+ result
5828
+ end
5829
+
5830
+ # A `StructInstance`'s own projection — `Tuple` of its member values for `deconstruct`, `HashShape` of
5831
+ # its members for `deconstruct_keys` — or nil when the subject is another carrier or the projection would
5832
+ # be unsound.
5833
+ #
5834
+ # The `Struct` fold's freshness gate cannot answer for a pattern through the dispatcher: it asks whether
5835
+ # the CALL's receiver was freshly materialised (`Point.new(1, 2).x`), while a pattern's subject node IS
5836
+ # that materialisation (`case Point.new(1, 2); in [x, y]`). The gate still applies — a `Struct` is
5837
+ # mutable, so a stored binding's member map may be stale (ADR-48) — so this asks it the pattern's own
5838
+ # question, with the subject expression as the materialisation. A `Data` instance needs none of this:
5839
+ # it is frozen, and the dispatcher already projects it (see `DataFolding`).
5840
+ def struct_instance_projection(subject_type, method_name, scope, subject_node)
5841
+ return nil unless subject_type.is_a?(Type::StructInstance)
5842
+ return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, subject_type,
5843
+ scope)
5844
+
5845
+ case method_name
5846
+ when :deconstruct then Type::Combinator.tuple_of(*subject_type.members.values)
5847
+ when :deconstruct_keys then Type::Combinator.hash_shape_of(subject_type.members.dup)
5848
+ end
5849
+ end
5850
+
5851
+ # `deconstruct` / `deconstruct_keys` on a subject carrier. A `Dynamic` / `Top` answer is the gradual floor
5852
+ # rather than a method's result, so it reads as "cannot ask" to every caller here.
5853
+ #
5854
+ # The subject node is deliberately NOT passed as the dispatch's `call_node`: the tiers read a call node's
5855
+ # receiver and arguments, and a pattern's subject is any expression at all — `StructFolding`'s freshness
5856
+ # gate dereferences `call_node.receiver`, which a local read or a literal does not answer. The one
5857
+ # freshness question a pattern needs (`case Point.new(1, 2)`) is asked by
5858
+ # {#struct_instance_projection} instead.
5859
+ def pattern_decomposition_dispatch(subject_type, method_name, args, scope)
5860
+ return nil if subject_type.nil?
5861
+
5862
+ result = MethodDispatcher.dispatch(
5863
+ receiver_type: subject_type, method_name: method_name, arg_types: args,
5864
+ environment: scope.environment, scope: scope
5865
+ )
5866
+ return nil if result.is_a?(Type::Dynamic) || result.is_a?(Type::Top)
5867
+
5868
+ result
5869
+ end
5870
+
5871
+ # `in [*pre, m, *post]`. Ruby matches a find pattern's required elements at the EARLIEST position the
5872
+ # surrounding splats allow (the pre-splat is non-greedy: `[1, 2, 3] in [*pre, x, *post]` binds `pre = []`, `x =
5873
+ # 1`), but a required that does not match there slides right, so each required binds the union of every
5874
+ # position it could occupy and the surrounding splats bind an `Array` of the subject's element type.
5875
+ def collect_find_pattern_bindings(subject_type, pattern, scope, subject_node:)
5876
+ subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
5877
+ view = positional_pattern_view(subject_type, scope, subject_node)
5878
+ slots = find_pattern_slots(view, pattern.requireds.size)
5879
+ bindings = pattern.requireds.each_with_index.flat_map do |elem, i|
5880
+ collect_in_pattern_bindings(slots ? slots[i] : Type::Combinator.untyped, elem, scope)
3374
5881
  end
3375
- append_array_splat_binding(bindings, pattern.rest)
5882
+ surround = find_pattern_surround_type(view)
5883
+ [pattern.left, pattern.right].each { |splat| append_array_splat_binding(bindings, splat, surround) }
3376
5884
  bindings
3377
5885
  end
3378
5886
 
3379
- def collect_hash_pattern_bindings(pattern, scope)
5887
+ # The type each of a find pattern's `count` requireds can see, or nil when the subject cannot supply that many
5888
+ # elements (the pattern cannot match).
5889
+ def find_pattern_slots(view, count)
5890
+ if view.is_a?(Type::Tuple)
5891
+ elements = view.elements
5892
+ return nil if elements.size < count
5893
+
5894
+ return Array.new(count) { |i| Type::Combinator.union(*elements[i..(elements.size - count + i)]) }
5895
+ end
5896
+
5897
+ element = view && MultiTargetBinder.array_element_type(view)
5898
+ element && Array.new(count) { element }
5899
+ end
5900
+
5901
+ # `*pre` / `*post` capture the elements the requireds did not: `Array[T]` for an `Array[T]` subject, an `Array`
5902
+ # of the element union for a `Tuple`, `Array[untyped]` when the subject could not be decomposed.
5903
+ def find_pattern_surround_type(view)
5904
+ element = case view
5905
+ when Type::Tuple then union_of_types(view.elements)
5906
+ else view && MultiTargetBinder.array_element_type(view)
5907
+ end
5908
+ Type::Combinator.nominal_of("Array", type_args: [element || Type::Combinator.untyped])
5909
+ end
5910
+
5911
+ # `in {name: String => n}` / `in {name:, **rest}`. Each element reads the subject's value at its key through
5912
+ # the subject's `deconstruct_keys` projection; `**rest` binds the remaining entries.
5913
+ def collect_hash_pattern_bindings(subject_type, pattern, scope, subject_node:)
5914
+ subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
5915
+ view = hash_pattern_view(subject_type, scope, subject_node)
3380
5916
  bindings = pattern.elements.flat_map do |assoc|
3381
5917
  next [] unless assoc.is_a?(Prism::AssocNode) && assoc.value
3382
5918
 
3383
- collect_in_pattern_bindings(nil, assoc.value, scope)
5919
+ collect_in_pattern_bindings(hash_pattern_value_type(view, assoc.key), assoc.value, scope)
3384
5920
  end
3385
5921
  rest = pattern.rest
3386
- if rest.is_a?(Prism::AssocSplatNode)
3387
- val = rest.value
3388
- bindings << [val.name, hash_pattern_rest_type] if val.is_a?(Prism::LocalVariableTargetNode)
5922
+ if rest.is_a?(Prism::AssocSplatNode) && rest.value.is_a?(Prism::LocalVariableTargetNode)
5923
+ bindings << [rest.value.name, hash_pattern_rest_type(view)]
3389
5924
  end
3390
5925
  bindings
3391
5926
  end
3392
5927
 
3393
- def collect_find_pattern_bindings(pattern, scope)
3394
- bindings = pattern.requireds.flat_map do |elem|
3395
- collect_in_pattern_bindings(nil, elem, scope)
3396
- end
3397
- [pattern.left, pattern.right].each { |splat| append_array_splat_binding(bindings, splat) }
3398
- bindings
5928
+ # The `Hash`-shaped view a hash pattern reads: the subject's `deconstruct_keys` answer, which is a `HashShape`
5929
+ # for a shape carrier or a `deconstruct_keys` body and `Hash[K, V]` for the RBS answer of a plain `Hash` — or
5930
+ # nil when the subject cannot be asked.
5931
+ def hash_pattern_view(subject_type, scope, subject_node)
5932
+ args = [Type::Combinator.constant_of(nil)]
5933
+ struct_instance_projection(subject_type, :deconstruct_keys, scope, subject_node) ||
5934
+ pattern_decomposition_dispatch(subject_type, :deconstruct_keys, args, scope) ||
5935
+ source_decomposition_projection(subject_type, :deconstruct_keys, args, scope)
3399
5936
  end
3400
5937
 
3401
- # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of the unmatched elements; bind `rest` to
3402
- # `Array[untyped]` rather than the previous bare `untyped`. Per-element typing waits on subject-aware element-type
3403
- # extraction (the binder doesn't see the case subject).
3404
- def append_array_splat_binding(bindings, splat)
3405
- return unless splat.is_a?(Prism::SplatNode)
5938
+ # The value type the pattern's key reads out of the view: a `HashShape` answers per key, a `Hash[K, V]`
5939
+ # answers `V` for every key, and anything else — a key the AST does not pin (`in {"#{k}": v}`), a shape that
5940
+ # does not carry the key — answers the floor.
5941
+ def hash_pattern_value_type(view, key_node)
5942
+ key = hash_pattern_key(key_node)
5943
+ return Type::Combinator.untyped if key.nil?
5944
+ return view.pairs[key] || Type::Combinator.untyped if view.is_a?(Type::HashShape)
3406
5945
 
3407
- target = splat.expression
3408
- return unless target.is_a?(Prism::LocalVariableTargetNode)
5946
+ hash_value_type(view) || Type::Combinator.untyped
5947
+ end
3409
5948
 
3410
- bindings << [target.name, Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
5949
+ # The key a hash pattern element names, or nil when the AST does not pin one (an interpolated or computed key).
5950
+ def hash_pattern_key(key_node)
5951
+ case key_node
5952
+ when Prism::SymbolNode then key_node.unescaped.to_sym
5953
+ when Prism::StringNode then key_node.unescaped
5954
+ end
3411
5955
  end
3412
5956
 
3413
5957
  # `{ key:, **rest }` binds `rest` to a Hash whose keys are Symbols (the only legal key shape for a hash pattern)
3414
- # and whose values are untyped (the binder can't see the subject's value type).
3415
- def hash_pattern_rest_type
5958
+ # and whose values are the view's own value type — the entries the pattern named are a subset of it.
5959
+ def hash_pattern_rest_type(view)
5960
+ value = view.is_a?(Type::HashShape) ? union_of_types(view.pairs.values) : hash_value_type(view)
3416
5961
  Type::Combinator.nominal_of(
3417
5962
  "Hash",
3418
- type_args: [Type::Combinator.nominal_of("Symbol"), Type::Combinator.untyped]
5963
+ type_args: [Type::Combinator.nominal_of("Symbol"), value || Type::Combinator.untyped]
3419
5964
  )
3420
5965
  end
3421
5966
 
5967
+ # The `V` of a `Hash[K, V]`, or nil for a raw `Hash`, a non-`Hash` nominal, or a `Dynamic` / `Top` value.
5968
+ def hash_value_type(type)
5969
+ return nil unless type.is_a?(Type::Nominal) && type.class_name == "Hash" && type.type_args.size == 2
5970
+
5971
+ value = type.type_args.last
5972
+ return nil if value.is_a?(Type::Dynamic) || value.is_a?(Type::Top)
5973
+
5974
+ value
5975
+ end
5976
+
5977
+ # The union of a list of types, ignoring the gradual floor (`Dynamic[top]` / `Top` carries nothing to union)
5978
+ # and answering nil when nothing is left.
5979
+ def union_of_types(types)
5980
+ known = types.reject { |type| type.is_a?(Type::Dynamic) || type.is_a?(Type::Top) }
5981
+ known.empty? ? nil : Type::Combinator.union(*known)
5982
+ end
5983
+
5984
+ # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of the unmatched elements. `rest_type` is the
5985
+ # enclosing decomposition's own rest (a `Tuple` of the middle elements for a tuple subject, `Array[T]` for an
5986
+ # `Array[T]`); without one — a subject that did not decompose — the rest is `Array[untyped]`.
5987
+ def append_array_splat_binding(bindings, splat, rest_type)
5988
+ return unless splat.is_a?(Prism::SplatNode)
5989
+
5990
+ target = splat.expression
5991
+ return unless target.is_a?(Prism::LocalVariableTargetNode)
5992
+
5993
+ bindings << [target.name, rest_type || Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
5994
+ end
5995
+
5996
+ # The `Dynamic[top]` floor per slot, for a subject no rule decomposes.
5997
+ def floor_pattern_slots(front_count, back_count, rest_present)
5998
+ [
5999
+ Array.new(front_count) { Type::Combinator.untyped },
6000
+ rest_present ? Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) : nil,
6001
+ Array.new(back_count) { Type::Combinator.untyped }
6002
+ ]
6003
+ end
6004
+
6005
+ # The class a pattern's own constant asserts (`in Point[x, y]`, `in Foo{...}`), applied to the subject. An
6006
+ # opaque subject — `Dynamic` / `Top`, which no `is_a?`-style narrowing can refine — becomes `Nominal[C]`: the
6007
+ # pattern's `C === subject` test has just established the class, which is what lets a constrained pattern bind
6008
+ # off a subject whose own type named nothing. A subject whose class is already known keeps it.
6009
+ def pattern_class_constraint(subject_type, constant_node, scope)
6010
+ return subject_type if constant_node.nil?
6011
+
6012
+ nominal = singleton_to_nominal(sub_eval(constant_node, scope).first)
6013
+ return subject_type unless opaque_pattern_subject?(subject_type)
6014
+ return subject_type if nominal.is_a?(Type::Dynamic) || nominal.is_a?(Type::Top)
6015
+
6016
+ nominal
6017
+ end
6018
+
6019
+ def opaque_pattern_subject?(subject_type)
6020
+ subject_type.nil? || subject_type.is_a?(Type::Dynamic) || subject_type.is_a?(Type::Top)
6021
+ end
6022
+
6023
+ # Distributes a union subject over the pattern, the rule {MultiTargetBinder} applies to `a, b = union`: every
6024
+ # member walks the same pattern and each name binds the join of its per-member types, while a member that binds
6025
+ # `Dynamic[top]` floors the name for the whole union — a precise member must not stand for one nothing is known
6026
+ # about. A member that PROVABLY cannot match the pattern contributes nothing at all instead of flooring:
6027
+ # `case maybe; in [a, b]` over `Tuple[1, "a"] | nil` binds `1` / `"a"`, because a `nil` subject raises
6028
+ # `NoMatchingPatternError` rather than reaching the body.
6029
+ def collect_union_pattern_bindings(members, pattern, scope, subject_node: nil)
6030
+ reachable = members.reject { |member| pattern_match_impossible?(member, pattern, scope) }
6031
+ walks = (reachable.empty? ? [Type::Combinator.untyped] : reachable).map do |member|
6032
+ collect_in_pattern_bindings(member, pattern, scope, subject_node: subject_node)
6033
+ end
6034
+ merge_pattern_bindings(walks)
6035
+ end
6036
+
6037
+ # Whether `type` provably cannot match `pattern`, so its arm contributes no binding. Only the two
6038
+ # decompositions a pattern asks for are checked — `deconstruct` (array / find patterns) and `deconstruct_keys`
6039
+ # (hash patterns) — and both only for a class the RBS environment knows, whose method set is closed, the same
6040
+ # rule `array_conversion_free?` applies to the multi-assign `to_ary` question (issue #1094). A carrier the
6041
+ # check cannot prove negative about (Dynamic, a source class, an unresolved constant) answers false, which
6042
+ # keeps the union at the conservative floor.
6043
+ def pattern_match_impossible?(type, pattern, scope)
6044
+ case pattern
6045
+ when Prism::ArrayPatternNode, Prism::FindPatternNode then !decomposable_as_array?(type, scope)
6046
+ when Prism::HashPatternNode then !decomposable_as_hash?(type, scope)
6047
+ when Prism::CapturePatternNode then pattern_match_impossible?(type, pattern.value, scope)
6048
+ else false
6049
+ end
6050
+ end
6051
+
6052
+ def decomposable_as_array?(type, scope)
6053
+ return true unless pattern_decomposition_dispatch(type, :deconstruct, [], scope).nil?
6054
+
6055
+ class_name = MultiTargetBinder.conversion_class_name(type)
6056
+ class_name.nil? || !MethodDispatcher::RbsDispatch.array_conversion_free?(class_name, scope)
6057
+ end
6058
+
6059
+ def decomposable_as_hash?(type, scope)
6060
+ args = [Type::Combinator.constant_of(nil)]
6061
+ return true unless pattern_decomposition_dispatch(type, :deconstruct_keys, args, scope).nil?
6062
+
6063
+ class_name = MultiTargetBinder.conversion_class_name(type)
6064
+ return true if class_name.nil? || scope.environment.nil?
6065
+
6066
+ !Reflection.rbs_class_known?(class_name, environment: scope.environment)
6067
+ end
6068
+
6069
+ # Joins per-member binding lists by name: `Dynamic[top]` from any walk — or a name a walk does not bind —
6070
+ # floors the name, otherwise the members union. The first walk's order is the declaration order.
6071
+ def merge_pattern_bindings(walks)
6072
+ floor = Type::Combinator.untyped
6073
+ walks.first.map do |name, _type|
6074
+ types = walks.map { |walk| walk.assoc(name)&.last }
6075
+ [name, types.any? { |type| type.nil? || type == floor } ? floor : Type::Combinator.union(*types)]
6076
+ end
6077
+ end
6078
+
6079
+ # `expr in pattern` (a `MatchPredicateNode`, evaluating to a boolean) and `expr => pattern` (a
6080
+ # `MatchRequiredNode`, evaluating to `nil` and raising `NoMatchingPatternError` on a mismatch) — the one-line
6081
+ # pattern matches. Both bind every name the pattern captures into the post-scope, decomposed exactly as an `in`
6082
+ # branch of a `case` is, and WITHOUT the nil-injection a surrounding join would add: a name is read on the
6083
+ # truthy side only after the pattern matched it, which is the shape `if config in {timeout: Integer => t}`
6084
+ # depends on.
6085
+ def eval_match_pattern(node)
6086
+ subject_type, post_value = sub_eval(node.value, scope)
6087
+ bound = apply_in_pattern_bindings(subject_type, node.value, node.pattern, post_value)
6088
+ [scope.type_of(node, tracer: tracer), bound]
6089
+ end
6090
+
3422
6091
  # --------------------------------------------------------------- named-capture regex binding (`MatchWriteNode`)
3423
6092
  # ---------------------------------------------------------------
3424
6093
 
@@ -3449,26 +6118,43 @@ module Rigor
3449
6118
  type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
3450
6119
  end
3451
6120
 
3452
- # Returns the type to bind for a `CapturePatternNode`'s target. Plain class references collapse to the matching
3453
- # `Nominal[T]`; `AlternationPatternNode` (`Integer | String => x`) unions every alternate's resolved type.
3454
- # Anything else falls back to `untyped` (the conservative legacy behaviour).
3455
- def pattern_capture_type(value_node, scope)
3456
- if value_node.is_a?(Prism::AlternationPatternNode)
3457
- left = pattern_capture_type(value_node.left, scope)
3458
- right = pattern_capture_type(value_node.right, scope)
3459
- Type::Combinator.union(left, right)
3460
- else
6121
+ # Returns the type to bind for a `CapturePatternNode`'s target. A class reference (`Integer => x`, and every
6122
+ # alternate of `Integer | String => x`) answers the constraint's `Nominal[T]` — the pattern's own `T ===
6123
+ # subject` test is what licenses the binding even when the subject's type is opaque. A value pattern (`1 => x`)
6124
+ # answers the literal's own type. A capture over any other pattern (`[a, b] => whole`, `^(x) => y`) answers the
6125
+ # subject, which is what that pattern matched.
6126
+ def capture_pattern_type(subject_type, value_node, scope)
6127
+ case value_node
6128
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
3461
6129
  singleton_to_nominal(sub_eval(value_node, scope).first)
6130
+ when Prism::AlternationPatternNode
6131
+ Type::Combinator.union(
6132
+ capture_pattern_type(subject_type, value_node.left, scope),
6133
+ capture_pattern_type(subject_type, value_node.right, scope)
6134
+ )
6135
+ when Prism::ArrayPatternNode, Prism::FindPatternNode, Prism::HashPatternNode,
6136
+ Prism::PinnedVariableNode, Prism::PinnedExpressionNode, Prism::ImplicitNode
6137
+ subject_type || Type::Combinator.untyped
6138
+ else
6139
+ literal_pattern_type(value_node, scope)
3462
6140
  end
3463
6141
  end
3464
6142
 
3465
- # `in PatternA | PatternB` — Ruby requires both alternates to bind the same names, but the binder runs against the
3466
- # AST and cannot enforce that. We collect bindings from each side and merge by name, unioning types when both
6143
+ # The type a non-class pattern node evaluates to: `Constant[1]` for `1 => x`, the nominal for `/re/ => x`,
6144
+ # the range for `1..5 => x`. A class reference is the one carrier that must convert (`Singleton[C]` is the
6145
+ # class object; the binding holds an instance), which the caller's constant arm does.
6146
+ def literal_pattern_type(value_node, scope)
6147
+ type = sub_eval(value_node, scope).first
6148
+ type.is_a?(Type::Singleton) ? singleton_to_nominal(type) : type
6149
+ end
6150
+
6151
+ # `in PatternA | PatternB` — Ruby requires both alternates to bind the same names, but the binder runs against
6152
+ # the AST and cannot enforce that. We collect bindings from each side and merge by name, unioning types when both
3467
6153
  # alternates contribute. Names that only one alternate contributes still surface (the parser would have rejected
3468
6154
  # the case at compile time, so by the time we see it the user's intent is the merged set).
3469
- def collect_alternation_pattern_bindings(subject, pattern, scope)
3470
- left = collect_in_pattern_bindings(subject, pattern.left, scope)
3471
- right = collect_in_pattern_bindings(subject, pattern.right, scope)
6155
+ def collect_alternation_pattern_bindings(subject_type, pattern, scope)
6156
+ left = collect_in_pattern_bindings(subject_type, pattern.left, scope)
6157
+ right = collect_in_pattern_bindings(subject_type, pattern.right, scope)
3472
6158
  merged = {}
3473
6159
  (left + right).each do |name, type|
3474
6160
  merged[name] = merged.key?(name) ? Type::Combinator.union(merged[name], type) : type