rigortype 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (550) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +45 -13
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
  8. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
  9. data/docs/handbook/01-getting-started.md +6 -3
  10. data/docs/handbook/11-sig-gen.md +85 -3
  11. data/docs/handbook/appendix-phpstan.md +4 -4
  12. data/docs/install.md +20 -0
  13. data/docs/manual/01-installation.md +67 -26
  14. data/docs/manual/02-cli-reference.md +455 -113
  15. data/docs/manual/03-configuration.md +76 -5
  16. data/docs/manual/04-diagnostics.md +5 -4
  17. data/docs/manual/06-baseline.md +6 -4
  18. data/docs/manual/08-skills.md +11 -0
  19. data/docs/manual/09-editor-integration.md +21 -2
  20. data/docs/manual/10-mcp-server.md +38 -2
  21. data/docs/manual/12-caching.md +4 -3
  22. data/docs/manual/14-rails-quickstart.md +1 -1
  23. data/docs/manual/19-effect-labels.md +3 -4
  24. data/docs/manual/plugins/rigor-actionpack.md +32 -15
  25. data/docs/manual/plugins/rigor-activesupport-core-ext.md +23 -1
  26. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  27. data/lib/rigor/analysis/baseline.rb +24 -27
  28. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  29. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  30. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  31. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  32. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +12 -4
  33. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  34. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +85 -13
  35. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +16 -4
  36. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -1
  37. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  38. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  39. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +41 -12
  40. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  41. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +3 -3
  42. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  43. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +9 -2
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  46. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +2 -2
  49. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +2 -2
  50. data/lib/rigor/analysis/check_rules.rb +720 -141
  51. data/lib/rigor/analysis/crash_signature.rb +45 -3
  52. data/lib/rigor/analysis/dependency_recorder.rb +237 -80
  53. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  54. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  55. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  57. data/lib/rigor/analysis/edit_replay.rb +93 -6
  58. data/lib/rigor/analysis/fact_store.rb +3 -3
  59. data/lib/rigor/analysis/incremental.rb +11 -1
  60. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +4 -3
  61. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  62. data/lib/rigor/analysis/incremental_session.rb +306 -53
  63. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  64. data/lib/rigor/analysis/rule_catalog.rb +39 -3
  65. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  66. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  67. data/lib/rigor/analysis/run_stats.rb +2 -2
  68. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  69. data/lib/rigor/analysis/runner/declaration_position.rb +8 -8
  70. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +264 -112
  71. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +4 -9
  72. data/lib/rigor/analysis/runner/pool_coordinator.rb +173 -328
  73. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  74. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  75. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  76. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  77. data/lib/rigor/analysis/runner.rb +298 -109
  78. data/lib/rigor/analysis/self_call_resolution_recorder.rb +62 -23
  79. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  80. data/lib/rigor/analysis/template_units.rb +9 -1
  81. data/lib/rigor/analysis/worker_session.rb +65 -71
  82. data/lib/rigor/bleeding_edge.rb +22 -10
  83. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  84. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  85. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  86. data/lib/rigor/cache/descriptor.rb +6 -5
  87. data/lib/rigor/cache/file_digest.rb +39 -31
  88. data/lib/rigor/cache/incremental_snapshot.rb +95 -17
  89. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  90. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  91. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  92. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  93. data/lib/rigor/cache/rbs_descriptor.rb +90 -4
  94. data/lib/rigor/cache/rbs_environment.rb +8 -1
  95. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  96. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  97. data/lib/rigor/cache/store.rb +11 -10
  98. data/lib/rigor/ci_detector.rb +1 -1
  99. data/lib/rigor/cli/annotate_command.rb +24 -19
  100. data/lib/rigor/cli/baseline_command.rb +160 -57
  101. data/lib/rigor/cli/catalogue.rb +256 -0
  102. data/lib/rigor/cli/check_command.rb +112 -104
  103. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  104. data/lib/rigor/cli/command.rb +15 -1
  105. data/lib/rigor/cli/coverage_command.rb +21 -8
  106. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  107. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  108. data/lib/rigor/cli/coverage_scan.rb +3 -5
  109. data/lib/rigor/cli/diff_command.rb +25 -20
  110. data/lib/rigor/cli/docs_command.rb +1 -2
  111. data/lib/rigor/cli/doctor_command.rb +12 -12
  112. data/lib/rigor/cli/effects_command.rb +50 -40
  113. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  114. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  115. data/lib/rigor/cli/effects_renderer.rb +2 -1
  116. data/lib/rigor/cli/effects_snapshot_command.rb +26 -18
  117. data/lib/rigor/cli/explain_command.rb +18 -8
  118. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  119. data/lib/rigor/cli/json_document.rb +41 -0
  120. data/lib/rigor/cli/lens_command.rb +29 -10
  121. data/lib/rigor/cli/lsp_command.rb +250 -23
  122. data/lib/rigor/cli/mcp_command.rb +12 -7
  123. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  124. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  125. data/lib/rigor/cli/options.rb +40 -1
  126. data/lib/rigor/cli/plugin_command.rb +93 -46
  127. data/lib/rigor/cli/plugins_command.rb +17 -18
  128. data/lib/rigor/cli/plugins_renderer.rb +10 -6
  129. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  130. data/lib/rigor/cli/protection_renderer.rb +2 -1
  131. data/lib/rigor/cli/show_bleedingedge_command.rb +21 -15
  132. data/lib/rigor/cli/sig_gen_command.rb +92 -27
  133. data/lib/rigor/cli/skill_command.rb +32 -3
  134. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  135. data/lib/rigor/cli/skill_describe.rb +2 -1
  136. data/lib/rigor/cli/trace_command.rb +14 -9
  137. data/lib/rigor/cli/triage_command.rb +10 -11
  138. data/lib/rigor/cli/triage_renderer.rb +2 -1
  139. data/lib/rigor/cli/type_of_command.rb +16 -11
  140. data/lib/rigor/cli/type_of_project_scope.rb +1 -4
  141. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  142. data/lib/rigor/cli/type_scan_command.rb +14 -9
  143. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  144. data/lib/rigor/cli/unused_command.rb +27 -17
  145. data/lib/rigor/cli/upgrade_command.rb +5 -0
  146. data/lib/rigor/cli.rb +214 -56
  147. data/lib/rigor/config_audit.rb +33 -19
  148. data/lib/rigor/configuration/severity_profile.rb +14 -5
  149. data/lib/rigor/configuration.rb +104 -35
  150. data/lib/rigor/effects/collector.rb +53 -21
  151. data/lib/rigor/effects/definition_context.rb +0 -2
  152. data/lib/rigor/effects/envelope.rb +1 -1
  153. data/lib/rigor/effects/framework_units.rb +1 -1
  154. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  155. data/lib/rigor/effects/propagator.rb +1 -1
  156. data/lib/rigor/effects/registry.rb +2 -2
  157. data/lib/rigor/effects/snapshot.rb +1 -1
  158. data/lib/rigor/effects/taint_cause.rb +0 -2
  159. data/lib/rigor/effects/unit_scan.rb +9 -13
  160. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -33
  161. data/lib/rigor/environment/class_registry.rb +3 -3
  162. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  163. data/lib/rigor/environment/core_root.rb +77 -0
  164. data/lib/rigor/environment/default_libraries.rb +18 -1
  165. data/lib/rigor/environment/deferred_signature_plan.rb +381 -0
  166. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  167. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  168. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  169. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  170. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  171. data/lib/rigor/environment/member_consistency.rb +56 -7
  172. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  173. data/lib/rigor/environment/project_path_gems.rb +124 -0
  174. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  175. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  176. data/lib/rigor/environment/rbs_hierarchy.rb +12 -15
  177. data/lib/rigor/environment/rbs_loader.rb +772 -110
  178. data/lib/rigor/environment/reflection.rb +7 -7
  179. data/lib/rigor/environment/required_features.rb +0 -3
  180. data/lib/rigor/environment/ruby_library_roots.rb +1 -1
  181. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  182. data/lib/rigor/environment.rb +169 -42
  183. data/lib/rigor/flow_contribution/merger.rb +4 -4
  184. data/lib/rigor/inference/acceptance.rb +136 -40
  185. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  186. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  187. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  188. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  189. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  190. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  191. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  192. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  193. data/lib/rigor/inference/block_run_count.rb +185 -0
  194. data/lib/rigor/inference/budget_trace.rb +8 -2
  195. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  196. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  197. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  198. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  199. data/lib/rigor/inference/callee_mutation_chain.rb +88 -77
  200. data/lib/rigor/inference/callee_write_census.rb +815 -0
  201. data/lib/rigor/inference/captured_locals.rb +2 -0
  202. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  203. data/lib/rigor/inference/clause_widening.rb +1 -1
  204. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  205. data/lib/rigor/inference/construction_escape.rb +258 -0
  206. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  207. data/lib/rigor/inference/declaration_walk.rb +4 -0
  208. data/lib/rigor/inference/deep_erasure.rb +39 -7
  209. data/lib/rigor/inference/def_handle.rb +1 -1
  210. data/lib/rigor/inference/def_node_resolver.rb +33 -15
  211. data/lib/rigor/inference/def_return_typer.rb +47 -5
  212. data/lib/rigor/inference/definer_resolution.rb +19 -9
  213. data/lib/rigor/inference/definers_index.rb +162 -0
  214. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  215. data/lib/rigor/inference/element_read_widening.rb +49 -3
  216. data/lib/rigor/inference/error_info.rb +16 -0
  217. data/lib/rigor/inference/expression_typer.rb +1559 -320
  218. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  219. data/lib/rigor/inference/flow_tracer.rb +31 -14
  220. data/lib/rigor/inference/fork_map.rb +3 -0
  221. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  222. data/lib/rigor/inference/global_write_census.rb +81 -5
  223. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  224. data/lib/rigor/inference/hkt_body.rb +1 -2
  225. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  226. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  227. data/lib/rigor/inference/hkt_registry.rb +3 -5
  228. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  229. data/lib/rigor/inference/indexed_narrowing.rb +61 -15
  230. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  231. data/lib/rigor/inference/ivar_writer_census.rb +154 -8
  232. data/lib/rigor/inference/key_presence_guard.rb +51 -24
  233. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  234. data/lib/rigor/inference/last_line.rb +5 -2
  235. data/lib/rigor/inference/loop_value_record.rb +66 -14
  236. data/lib/rigor/inference/match_rebinding/frame.rb +57 -0
  237. data/lib/rigor/inference/match_rebinding/yielding_match.rb +101 -0
  238. data/lib/rigor/inference/match_rebinding.rb +28 -5
  239. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  240. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  241. data/lib/rigor/inference/method_dispatcher/block_folding.rb +44 -30
  242. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  243. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +3 -3
  244. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  245. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  246. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  247. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  248. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  249. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  250. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  251. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +1 -1
  252. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  253. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +51 -27
  254. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  255. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +606 -78
  256. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  257. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  258. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +3 -3
  259. data/lib/rigor/inference/method_dispatcher/set_folding.rb +9 -7
  260. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +467 -84
  261. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  262. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  263. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  264. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  265. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  266. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  267. data/lib/rigor/inference/method_dispatcher.rb +123 -25
  268. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  269. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  270. data/lib/rigor/inference/module_function_state.rb +24 -3
  271. data/lib/rigor/inference/multi_target_binder.rb +2 -2
  272. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  273. data/lib/rigor/inference/mutation_widening.rb +199 -29
  274. data/lib/rigor/inference/narrowing.rb +238 -50
  275. data/lib/rigor/inference/operand_effects.rb +43 -2
  276. data/lib/rigor/inference/optimistic_origin.rb +134 -9
  277. data/lib/rigor/inference/origin_lookup.rb +1 -1
  278. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  279. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  280. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  281. data/lib/rigor/inference/precision_scanner.rb +1 -1
  282. data/lib/rigor/inference/project_method_ownership.rb +30 -9
  283. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  284. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  285. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  286. data/lib/rigor/inference/project_yielder.rb +11 -17
  287. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  288. data/lib/rigor/inference/receiver_alias.rb +20 -0
  289. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  290. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  291. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +57 -17
  292. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -16
  293. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  294. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  295. data/lib/rigor/inference/remover_holder_scan.rb +287 -0
  296. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  297. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  298. data/lib/rigor/inference/scope_indexer/certainty.rb +0 -1
  299. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  300. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  301. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +70 -26
  302. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  303. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  304. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +295 -0
  305. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  306. data/lib/rigor/inference/scope_indexer.rb +2213 -226
  307. data/lib/rigor/inference/self_call_ivar_writes.rb +103 -12
  308. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  309. data/lib/rigor/inference/set_mutation.rb +189 -0
  310. data/lib/rigor/inference/singleton_hook_decline.rb +42 -8
  311. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  312. data/lib/rigor/inference/statement_evaluator.rb +3268 -358
  313. data/lib/rigor/inference/string_mutation.rb +14 -0
  314. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  315. data/lib/rigor/inference/synthetic_method_scanner.rb +40 -17
  316. data/lib/rigor/inference/toplevel_def_census.rb +67 -0
  317. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  318. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  319. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  320. data/lib/rigor/inference/void_tail_summary.rb +29 -9
  321. data/lib/rigor/language_server/buffer_table.rb +3 -3
  322. data/lib/rigor/language_server/completion_provider.rb +26 -20
  323. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  324. data/lib/rigor/language_server/diagnostic_publisher.rb +8 -3
  325. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  326. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  327. data/lib/rigor/language_server/hover_provider.rb +1 -7
  328. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  329. data/lib/rigor/language_server/project_context.rb +39 -1
  330. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  331. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  332. data/lib/rigor/language_server/server.rb +7 -3
  333. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  334. data/lib/rigor/lens/claims/comparison.rb +78 -0
  335. data/lib/rigor/lens/claims/request.rb +131 -0
  336. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  337. data/lib/rigor/lens/claims.rb +208 -0
  338. data/lib/rigor/lens/contract_reader.rb +5 -0
  339. data/lib/rigor/lens/document.rb +1 -1
  340. data/lib/rigor/lens/file_reader.rb +21 -8
  341. data/lib/rigor/lens/plugins.rb +1 -1
  342. data/lib/rigor/lens/run.rb +8 -0
  343. data/lib/rigor/lens.rb +1 -1
  344. data/lib/rigor/library_probe.rb +68 -0
  345. data/lib/rigor/mcp/server.rb +73 -2
  346. data/lib/rigor/mcp.rb +1 -1
  347. data/lib/rigor/memo_audit.rb +282 -0
  348. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  349. data/lib/rigor/plugin/base.rb +15 -2
  350. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  351. data/lib/rigor/plugin/io_boundary.rb +26 -4
  352. data/lib/rigor/plugin/loader.rb +25 -8
  353. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  354. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  355. data/lib/rigor/plugin/manifest.rb +16 -16
  356. data/lib/rigor/plugin/registry.rb +16 -5
  357. data/lib/rigor/plugin/services.rb +2 -3
  358. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  359. data/lib/rigor/plugin/trust_policy.rb +48 -2
  360. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  361. data/lib/rigor/plugin.rb +6 -8
  362. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  363. data/lib/rigor/project_environment.rb +16 -2
  364. data/lib/rigor/protection/analysis_guard.rb +1 -1
  365. data/lib/rigor/protection/discovery_seed.rb +44 -5
  366. data/lib/rigor/protection/mutation_cache.rb +4 -1
  367. data/lib/rigor/protection/mutator.rb +1 -1
  368. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  369. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  370. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  371. data/lib/rigor/rbs_extended.rb +2 -3
  372. data/lib/rigor/reflection/constant_ancestors.rb +81 -22
  373. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  374. data/lib/rigor/reflection/declarations.rb +58 -0
  375. data/lib/rigor/reflection/project_method.rb +177 -0
  376. data/lib/rigor/reflection.rb +2 -0
  377. data/lib/rigor/rooted_glob.rb +32 -1
  378. data/lib/rigor/run_scoped_slots.rb +38 -0
  379. data/lib/rigor/runtime/jit.rb +1 -1
  380. data/lib/rigor/scope/discovery_index.rb +168 -8
  381. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  382. data/lib/rigor/scope/resolution_chain.rb +901 -54
  383. data/lib/rigor/scope.rb +644 -37
  384. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  385. data/lib/rigor/sig_gen/classification.rb +18 -1
  386. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  387. data/lib/rigor/sig_gen/generator.rb +588 -66
  388. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  389. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  390. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  391. data/lib/rigor/sig_gen/method_candidate.rb +37 -4
  392. data/lib/rigor/sig_gen/observation_collector.rb +14 -7
  393. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  394. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  395. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  396. data/lib/rigor/sig_gen/renderer.rb +15 -9
  397. data/lib/rigor/sig_gen/skip_reason_catalog.rb +62 -2
  398. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  399. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  400. data/lib/rigor/sig_gen/write_result.rb +23 -12
  401. data/lib/rigor/sig_gen/writer.rb +238 -37
  402. data/lib/rigor/sig_gen.rb +1 -0
  403. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  404. data/lib/rigor/source/node_children.rb +13 -0
  405. data/lib/rigor/type/acceptance_router.rb +2 -2
  406. data/lib/rigor/type/accepts_result.rb +1 -1
  407. data/lib/rigor/type/app.rb +4 -6
  408. data/lib/rigor/type/combinator.rb +173 -21
  409. data/lib/rigor/type/constant.rb +4 -5
  410. data/lib/rigor/type/hash_shape.rb +21 -40
  411. data/lib/rigor/type/interface.rb +60 -0
  412. data/lib/rigor/type/union.rb +1 -1
  413. data/lib/rigor/type.rb +1 -0
  414. data/lib/rigor/type_node/identifier.rb +1 -2
  415. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  416. data/lib/rigor/type_node/string_literal.rb +1 -2
  417. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  418. data/lib/rigor/type_node/union.rb +3 -6
  419. data/lib/rigor/type_of_memo/serving.rb +99 -0
  420. data/lib/rigor/type_of_memo/stats.rb +43 -0
  421. data/lib/rigor/type_of_memo.rb +337 -0
  422. data/lib/rigor/typing_state.rb +1094 -0
  423. data/lib/rigor/value_semantics.rb +1 -10
  424. data/lib/rigor/version.rb +1 -1
  425. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  426. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  427. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  428. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +38 -36
  429. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  430. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -4
  431. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  432. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  433. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  434. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  435. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +3 -3
  436. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +115 -36
  437. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  438. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  439. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  440. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  441. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  442. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -3
  443. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  444. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  445. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  446. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  447. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  448. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +132 -33
  449. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -6
  450. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  451. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +54 -17
  452. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  453. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  454. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  455. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +11 -14
  456. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  457. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  458. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  459. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +30 -9
  460. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +55 -13
  461. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  462. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  463. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  464. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  465. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  466. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  467. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  468. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  469. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  470. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  471. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  472. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  473. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  474. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  475. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +8 -7
  476. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  477. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  478. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  479. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  480. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  481. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +8 -2
  482. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  483. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  484. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  485. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  486. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  487. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +200 -4
  488. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  489. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  490. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  491. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +4 -3
  492. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  493. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +5 -5
  494. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  495. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  496. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  497. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +3 -2
  498. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  499. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  500. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +210 -3
  501. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  502. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  503. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  504. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +97 -24
  505. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  506. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  507. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +4 -8
  508. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  509. data/sig/rigor/analysis/baseline.rbs +0 -3
  510. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  511. data/sig/rigor/analysis/fact_store.rbs +2 -2
  512. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  513. data/sig/rigor/cache/descriptor.rbs +14 -0
  514. data/sig/rigor/cli/diff_command.rbs +1 -1
  515. data/sig/rigor/cli/explain_command.rbs +1 -1
  516. data/sig/rigor/effects/registry.rbs +2 -2
  517. data/sig/rigor/environment.rbs +4 -1
  518. data/sig/rigor/inference.rbs +4 -1
  519. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  520. data/sig/rigor/plugin/base.rbs +53 -18
  521. data/sig/rigor/plugin/effect_attribution.rbs +3 -3
  522. data/sig/rigor/plugin/effect_edge.rbs +1 -1
  523. data/sig/rigor/plugin/effect_entry_points.rbs +3 -3
  524. data/sig/rigor/plugin/interfaces.rbs +51 -0
  525. data/sig/rigor/plugin/io_boundary.rbs +3 -3
  526. data/sig/rigor/plugin/manifest.rbs +9 -9
  527. data/sig/rigor/plugin/node_context.rbs +21 -0
  528. data/sig/rigor/plugin/protocol_contract.rbs +3 -7
  529. data/sig/rigor/reflection.rbs +37 -0
  530. data/sig/rigor/scope.rbs +43 -4
  531. data/sig/rigor/trinary.rbs +1 -0
  532. data/sig/rigor/type.rbs +9 -3
  533. data/sig/rigor.rbs +3 -2
  534. data/skills/rigor-ask/SKILL.md +1 -0
  535. data/skills/rigor-plugin-author/SKILL.md +4 -4
  536. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  537. data/skills/rigor-plugin-review/SKILL.md +3 -3
  538. data/skills/rigor-project-init/references/01-detect.md +1 -0
  539. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +17 -2
  540. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  541. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  542. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  543. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  544. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  545. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  546. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  547. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  548. data/skills/rigor-type-oracle/SKILL.md +26 -1
  549. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  550. metadata +82 -3
@@ -14,7 +14,10 @@ require_relative "block_call_timing"
14
14
  require_relative "block_repetition"
15
15
  require_relative "block_parameter_binder"
16
16
  require_relative "body_fixpoint"
17
+ require_relative "../memo_audit"
17
18
  require_relative "callee_mutation_chain"
19
+ require_relative "toplevel_def_census"
20
+ require_relative "callee_write_census"
18
21
  require_relative "captured_locals"
19
22
  require_relative "dynamic_origin"
20
23
  require_relative "error_info"
@@ -25,11 +28,15 @@ require_relative "../analysis/check_rules/inferred_param_guard"
25
28
  require_relative "../analysis/check_rules/published_constant_guard"
26
29
  require_relative "struct_fold_safety"
27
30
  require_relative "closure_escape_analyzer"
31
+ require_relative "self_preserving_block"
28
32
  require_relative "content_join"
33
+ require_relative "construction_escape"
29
34
  require_relative "define_method_block_self"
30
35
  require_relative "macro_block_self_type"
36
+ require_relative "global_writer_reach"
31
37
  require_relative "guard_rebinding"
32
38
  require_relative "self_call_ivar_writes"
39
+ require_relative "direct_ivar_writes"
33
40
  require_relative "intervening_ivar_reset"
34
41
  require_relative "match_rebinding"
35
42
  require_relative "clause_widening"
@@ -42,6 +49,7 @@ require_relative "method_dispatcher"
42
49
  require_relative "method_parameter_binder"
43
50
  require_relative "multi_target_binder"
44
51
  require_relative "mutation_widening"
52
+ require_relative "out_buffer_mutation"
45
53
  require_relative "narrowing"
46
54
  require_relative "operand_effects"
47
55
  require_relative "operand_walk"
@@ -54,6 +62,8 @@ require_relative "rewrite_mutation"
54
62
  require_relative "unknown_store_widening"
55
63
  require_relative "untyped_rejoin"
56
64
  require_relative "version_guard"
65
+ require_relative "../run_scoped_slots"
66
+ require_relative "../typing_state"
57
67
 
58
68
  module Rigor
59
69
  module Inference
@@ -186,13 +196,22 @@ module Rigor
186
196
  # return type. The flow value of a `return` is still `Bot` (it transfers control rather than producing a value);
187
197
  # the sink only records what the method *returns* through that edge. nil means "not collecting" — a top-level /
188
198
  # DSL-block walk, or inside a nested `def` barrier (whose returns belong to the inner method).
189
- RETURN_SINK_KEY = :rigor_return_sink
190
- private_constant :RETURN_SINK_KEY
199
+ #
200
+ # ADR-129 WD1 — the installation is a Frame and each record is a Resource touch.
201
+ RETURN_SINK = TypingState.frame(
202
+ :return_sink,
203
+ contents: :resource, reason: "installed per method body; each `return` records into it",
204
+ witness: "spec/fixtures/typing_state_witnesses/return_sink.rb"
205
+ )
206
+ private_constant :RETURN_SINK
191
207
 
192
208
  # Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
193
209
  # than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
194
- PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
195
- private_constant :PROVISIONAL_RETURNS_KEY
210
+ PROVISIONAL_RETURNS = TypingState.frame(
211
+ :provisional_returns, reason: "a flag installed around a side pass",
212
+ witness: "spec/fixtures/typing_state_witnesses/provisional_returns.rb"
213
+ )
214
+ private_constant :PROVISIONAL_RETURNS
196
215
 
197
216
  # The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
198
217
  # with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
@@ -207,8 +226,9 @@ module Rigor
207
226
  @provisional = []
208
227
  end
209
228
 
210
- def record(node, type)
211
- if Thread.current[PROVISIONAL_RETURNS_KEY]
229
+ # `provisional` is the {PROVISIONAL_RETURNS} frame the evaluator reached the `return` under.
230
+ def record(node, type, provisional)
231
+ if provisional
212
232
  @provisional << [node, type]
213
233
  else
214
234
  @types << type
@@ -226,14 +246,18 @@ module Rigor
226
246
  # Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
227
247
  # leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
228
248
  # block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
229
- # `Constant[true]` and folded the call to always-truthy — the same defect `RETURN_SINK_KEY` fixes one level up for
249
+ # `Constant[true]` and folded the call to always-truthy — the same defect `RETURN_SINK` fixes one level up for
230
250
  # a method's early `return`. The flow value of a `next` stays `Bot`.
231
251
  #
232
252
  # The sink also collects `next`s from nested blocks / loops / defs evaluated under the same installation (they do
233
253
  # not install their own), so the consumer filters by node identity against a statically computed
234
254
  # directly-targeting set, exactly as the break sink does. nil means "not collecting".
235
- NEXT_SINK_KEY = :rigor_next_sink
236
- private_constant :NEXT_SINK_KEY
255
+ NEXT_SINK = TypingState.frame(
256
+ :next_sink,
257
+ contents: :resource, reason: "installed per block body; each `next` records into it",
258
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
259
+ )
260
+ private_constant :NEXT_SINK
237
261
 
238
262
  # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the scope at each `break` reached while
239
263
  # evaluating a loop body, so `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true; break`) into
@@ -241,21 +265,33 @@ module Rigor
241
265
  # installs its own sink, restored on exit, so an inner loop's break does not leak to the outer one. A `break`
242
266
  # inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the loop's
243
267
  # {JumpTargets} set ({#loop_jumps}). See docs/notes/20260615-loop-break-binding-propagation-design.md.
244
- BREAK_SINK_KEY = :rigor_break_sink
245
- private_constant :BREAK_SINK_KEY
268
+ #
269
+ # ADR-129 WD3 — a construct reads the sink's size before it runs and again after, to find the jumps it recorded
270
+ # itself ({#restoring_error_info}, {#ensure_jump_marks}), so a read of the contents is delta-internal.
271
+ BREAK_SINK = TypingState.frame(
272
+ :break_sink,
273
+ contents: :resource, read_exemption: :delta_internal,
274
+ reason: "installed per loop body; each `break` records into it, and an enclosing `ensure` rewrites them",
275
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
276
+ )
277
+ private_constant :BREAK_SINK
246
278
 
247
279
  # Thread-local sink (an Array of `[BreakNode, Type]`) collecting the value each `break` carries out of the
248
280
  # construct it leaves. Issue #853: `break value` terminates the yielding CALL and is that call's value, so —
249
- # unlike `next`, which {NEXT_SINK_KEY} joins into the block — these arms are consumed one level up, by
281
+ # unlike `next`, which {NEXT_SINK} joins into the block — these arms are consumed one level up, by
250
282
  # `ExpressionTyper#call_dispatch_type_for`. Without them `ops.all? { |o| break false unless o; true }` folded to
251
283
  # `Constant[true]` and warned about a program that really can answer false.
252
284
  #
253
- # Separate from {BREAK_SINK_KEY}, which records SCOPES for the loop-continuation join: the two consumers want
285
+ # Separate from {BREAK_SINK}, which records SCOPES for the loop-continuation join: the two consumers want
254
286
  # different things from the same node, and a loop body evaluated under a call's collection must keep feeding its
255
287
  # own sink. Both are filtered by node identity against a statically computed directly-targeting set, so a `break`
256
288
  # that belongs to an inner loop or block never reaches the wrong consumer. nil means "not collecting".
257
- BREAK_VALUE_SINK_KEY = :rigor_break_value_sink
258
- private_constant :BREAK_VALUE_SINK_KEY
289
+ BREAK_VALUE_SINK = TypingState.frame(
290
+ :break_value_sink,
291
+ contents: :resource, reason: "installed per yielding call; each `break` records into it",
292
+ witness: "spec/fixtures/typing_state_witnesses/jump_sinks.rb"
293
+ )
294
+ private_constant :BREAK_VALUE_SINK
259
295
 
260
296
  # Lexical class frame: the `name:` field is the qualified class name as it would render in Ruby (e.g.,
261
297
  # `"Foo::Bar"`); the `singleton:` field is `true` for `class << self` frames so nested defs resolve to
@@ -348,18 +384,9 @@ module Rigor
348
384
  # so a `def` evaluated inside another method's body (which itself installed a sink) does not corrupt the outer
349
385
  # one. Used by `ExpressionTyper#infer_user_method_return` to join the explicit returns into the inferred
350
386
  # method-return type.
351
- def self.with_return_sink
352
- previous = Thread.current[RETURN_SINK_KEY]
353
- previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
387
+ def self.with_return_sink(&)
354
388
  sink = ReturnSink.new
355
- Thread.current[RETURN_SINK_KEY] = sink
356
- Thread.current[PROVISIONAL_RETURNS_KEY] = nil
357
- begin
358
- result = yield
359
- ensure
360
- Thread.current[RETURN_SINK_KEY] = previous
361
- Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
362
- end
389
+ result = TypingState.current.with_pair(RETURN_SINK, sink, PROVISIONAL_RETURNS, nil, &)
363
390
  [result, sink.types]
364
391
  end
365
392
 
@@ -367,48 +394,54 @@ module Rigor
367
394
  # on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
368
395
  # `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
369
396
  # although the statement walk of the same block recorded `r` with its real binding.
370
- def self.provisional_returns
371
- previous = Thread.current[PROVISIONAL_RETURNS_KEY]
372
- Thread.current[PROVISIONAL_RETURNS_KEY] = true
373
- begin
374
- yield
375
- ensure
376
- Thread.current[PROVISIONAL_RETURNS_KEY] = previous
377
- end
397
+ def self.provisional_returns(&)
398
+ TypingState.current.with(PROVISIONAL_RETURNS, true, &)
378
399
  end
379
400
 
380
401
  # Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
381
402
  # caller. Stacks like the return sink, and for the same reason: a block body evaluated inside another block body's
382
403
  # evaluation must not spill its arms into the outer collection. Used by `ExpressionTyper#type_block_body` to join
383
404
  # the `next` arms into the block's value type.
384
- def self.with_next_sink
385
- previous = Thread.current[NEXT_SINK_KEY]
405
+ def self.with_next_sink(&)
386
406
  sink = []
387
- Thread.current[NEXT_SINK_KEY] = sink
388
- begin
389
- result = yield
390
- ensure
391
- Thread.current[NEXT_SINK_KEY] = previous
392
- end
407
+ result = TypingState.current.with(NEXT_SINK, sink, &)
393
408
  [result, sink]
394
409
  end
395
410
 
411
+ # Runs `block` with none of {CALLER_SINKS} installed: a user-method body re-typed for its return starts outside
412
+ # its caller's constructs (ADR-129 WD1: the return memo forces these Frames at its entry). Installs only when one
413
+ # is set, read with `forcing_read`, so the common entry costs no frame.
414
+ def self.outside_caller_sinks(&)
415
+ state = TypingState.current
416
+ return yield if CALLER_SINKS.all? { |slot| state.forcing_read(slot).nil? }
417
+
418
+ state.without(CALLER_SINKS, &)
419
+ end
420
+
396
421
  # Runs `block` with a fresh `break`-value sink installed, then yields the collected `[BreakNode, Type]` pairs to
397
422
  # the caller. Stacks like the other sinks: a block body evaluated inside another block body's evaluation must not
398
423
  # spill its arms into the outer collection. Used by `ExpressionTyper#call_dispatch_type_for` to union the `break`
399
424
  # arms into the yielding call's type (issue #853).
400
- def self.with_break_value_sink
401
- previous = Thread.current[BREAK_VALUE_SINK_KEY]
425
+ def self.with_break_value_sink(&)
402
426
  sink = []
403
- Thread.current[BREAK_VALUE_SINK_KEY] = sink
404
- begin
405
- result = yield
406
- ensure
407
- Thread.current[BREAK_VALUE_SINK_KEY] = previous
408
- end
427
+ result = TypingState.current.with(BREAK_VALUE_SINK, sink, &)
409
428
  [result, sink]
410
429
  end
411
430
 
431
+ # Issue #1246 — enters only `call`'s block, from the receiver scope, as the statement form enters it
432
+ # ({#evaluate_block_if_present}): the per-node scope index's evaluation of a block a value-position call holds
433
+ # ({ScopeIndexer#evaluate_unentered_block}). The call's receiver and arguments are left to the index walk.
434
+ #
435
+ # Such a block enters with the unknown-`self` mark only ({Scope#with_block_self_unknown}), as the walk entered
436
+ # it, not with the statement form's opaque-`self` mark ({Scope#entering_opaque_block}): issue #1717 keeps the
437
+ # latter, which gates binding a call to a cross-file top-level `def`, away from value-position blocks.
438
+ def evaluate_block_of(call)
439
+ @value_position_block = call.block
440
+ evaluate_block_if_present(call)
441
+ ensure
442
+ @value_position_block = nil
443
+ end
444
+
412
445
  # Evaluate `node` under the receiver scope. Returns `[type, scope']` where `type` is the value the node produces
413
446
  # and `scope'` is the scope observable after the node has run. The receiver scope is never mutated.
414
447
  def evaluate(node)
@@ -517,9 +550,13 @@ module Rigor
517
550
  # def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
518
551
  # callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
519
552
  # the effects equal and the callers' floors stale.
553
+ #
554
+ # Issue #2296 — the parameters the def mutates inside a block, or through a Set-only adder, count too: they
555
+ # floor the caller's argument through {#floor_callee_escaped_args_for_call}'s element-keeping channels.
520
556
  def content_mutated_parameter_positions(def_node)
521
- slots = content_parameter_slots(def_node)
522
- census = callee_content_mutated_parameters(def_node).values
557
+ slots = forwarding_slots(def_node)
558
+ census = callee_content_mutated_parameters(def_node).values +
559
+ own_block_content_parameters(def_node).values + own_set_content_parameters(def_node).values
523
560
  each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
524
561
  positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
525
562
  positions.sort + keywords.sort
@@ -531,25 +568,703 @@ module Rigor
531
568
  # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
532
569
  # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
533
570
  def content_mutated_arguments(call_node)
534
- return NO_ARGUMENT_READS unless self_dispatch_call?(call_node)
571
+ callees = argument_mutating_callees(call_node)
572
+ return NO_ARGUMENT_READS if callees.empty?
573
+
574
+ reads = callees.flat_map do |def_node, offset, owner|
575
+ arguments_for_slots(call_node, census_of(owner, :callee_content_mutated_parameters, def_node), offset)
576
+ end
577
+ callees.size == 1 ? reads : reads.uniq
578
+ end
579
+
580
+ # The `[def, offset]` pairs a self-dispatch `call_node` that passes a local resolves to
581
+ # ({#resolve_self_callees}) — the defs whose census the callee floor reads; empty for any other call.
582
+ #
583
+ # Issue #2336 — a call no def answers that runs code the floor cannot see against its arguments (`pr.call(acc)`,
584
+ # `obj.method(:poke).call(acc)`, `send(name, acc)`; {#opaque_sink_call?}) answers {OPAQUE_CALLEES}: every
585
+ # argument it passes reads as written.
586
+ def argument_mutating_callees(call_node)
587
+ unless self_dispatch_call?(call_node)
588
+ found = receiver_callees(call_node)
589
+ return found unless found.empty? && opaque_sink_call?(call_node)
590
+
591
+ return call_passes_local_argument?(call_node) ? OPAQUE_CALLEES : NO_CALLEES
592
+ end
535
593
  # Fast path — only a local passed as an argument can be reported, so a call with none skips the def
536
594
  # resolution and the body scan entirely (the overwhelming common case).
537
- return NO_ARGUMENT_READS unless call_passes_local_argument?(call_node)
595
+ return NO_CALLEES unless call_passes_local_argument?(call_node)
596
+
597
+ found = resolve_self_callees(call_node)
598
+ found.empty? && opaque_sink_call?(call_node) ? OPAQUE_CALLEES : found
599
+ end
600
+
601
+ # True for a call whose arguments go to code no def shows: `call` / `instance_exec` / `class_exec` /
602
+ # `module_exec` on an explicit receiver (a Proc, a Method, a lambda), or a `send` whose target is no literal
603
+ # ({CalleeWriteCensus}, which reads the same calls inside a callee).
604
+ def opaque_sink_call?(call_node)
605
+ name = call_node.name
606
+ return !CalleeWriteCensus.literal_send?(call_node) if SEND_HAND_OFFS.include?(name)
607
+ return false unless OPAQUE_SINKS.include?(name)
608
+
609
+ # A receiverless `call` is a self-call to a def of that name; `instance_exec` and the rest run their block on
610
+ # any receiver.
611
+ name != :call || !self_dispatch_call?(call_node)
612
+ end
613
+
614
+ OPAQUE_SINKS = %i[call instance_exec class_exec module_exec].to_set.freeze
615
+ private_constant :OPAQUE_SINKS
616
+
617
+ # Issue #2510, #2515 — the `[def, 0, owner]` triple an explicit-receiver `recv.name(local)` call resolves to when
618
+ # `recv` types as a project class object (`Const`, a bare constant or any constant path, or a local holding
619
+ # one), whose singleton chain has `name` (`def self.name`, `class << self`, `module_function`, an `extend`ed
620
+ # module, the project superclass's), or as an instance of a project class (`Opts.new.add_format(...)`,
621
+ # `h = Opts.new; h.add_format(...)`), whose instance chain has `name`: the same census a self-call reads then
622
+ # floors the passed local, so `Opts.add_format(opts, options)` handing `options` to a block that writes it
623
+ # leaves the literal behind. `owner` is a {CalleeOwner}: the receiver type the callee's own hand-offs
624
+ # (`really(parser, options)`) resolve self-calls in, not the caller's class. `resolved_census` keys the shared
625
+ # memo by the receiver's class and the def node, whose recorded nesting its constants resolve in. A def the
626
+ # receiver inherits (a superclass's, an included module's) resolves to {FLOOR_ALL} ({#walked_owner}). A receiver
627
+ # of any other type resolves to nothing, as does `Const.new` of a class that defines its own `self.new`; `send`
628
+ # hand-offs are not followed through an explicit receiver. The hand-offs inside a callee, whose arguments are
629
+ # parameters, take {#hand_off_receiver_callees}.
630
+ def receiver_callees(call_node)
631
+ return NO_CALLEES if call_node.receiver.nil?
632
+ return NO_CALLEES unless call_passes_local_argument?(call_node)
633
+
634
+ receiver_callees_in(scope, call_node)
635
+ end
636
+
637
+ # {#receiver_callees} for one scope `call_node`'s receiver (or `receiver`, the constant form a callee's local
638
+ # receiver was assigned) is typed in.
639
+ def receiver_callees_in(typing_scope, call_node, receiver = call_node.receiver)
640
+ return NO_CALLEES if overridden_constructor?(receiver, typing_scope)
641
+
642
+ receiver_type = typing_scope.type_of(receiver)
643
+ own = callees_for_receiver_type(receiver_type, call_node.name)
644
+ return own unless own.empty? && send_hand_off?(call_node)
645
+
646
+ shifted_callees(callees_for_receiver_type(receiver_type, send_target(call_node)))
647
+ end
648
+
649
+ # Issue #2336 — the method a `send` hand-off names (`recv.send(:poke, h)`, `send("poke", h)`).
650
+ def send_target(call_node) = call_node.arguments.arguments.first.unescaped.to_sym
651
+
652
+ # `callees` with each offset one further: the callees of a `send` hand-off's target, whose first argument is the
653
+ # name. A capped answer stays as it is.
654
+ def shifted_callees(callees)
655
+ return callees if callees.empty? || callees.equal?(CAPPED_HAND_OFF)
656
+
657
+ shifted = callees.map { |def_node, offset, owner| [def_node, offset + 1, owner] }
658
+ callees.is_a?(UnionCallees) ? UnionCallees.new(shifted) : shifted
659
+ end
660
+
661
+ # `[def, 0, owner]` for `name` called on a receiver of `receiver_type`, or none.
662
+ def callees_for_receiver_type(receiver_type, name)
663
+ if receiver_type.is_a?(Type::Union)
664
+ return receiver_type.members.flat_map { |member| callees_for_receiver_type(member, name) }
665
+ .uniq { |callee, _, owner| [callee.object_id, owner] }
666
+ end
667
+
668
+ singleton = receiver_type.is_a?(Type::Singleton)
669
+ return NO_CALLEES unless singleton || receiver_type.is_a?(Type::Nominal)
670
+
671
+ class_name = receiver_type.class_name
672
+ found, defining =
673
+ if singleton then scope.singleton_def_through_ancestors(class_name, name)
674
+ else scope.user_def_through_ancestors(class_name, name)
675
+ end
676
+ side = singleton ? :singleton : :instance
677
+ return unresolved_receiver_callees(receiver_type, side, name) if found.nil?
678
+
679
+ [[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
680
+ end
681
+
682
+ # `owner`, the owner a def found on `defining` by a lookup on `class_name` is walked under, or {FLOOR_ALL} for a
683
+ # def the class inherits (`defining` is not `class_name`). An inherited def is not walked (#2521): walking it
684
+ # exposed the own-def census's blind spots, which #2595 closes ({#callee_write_census}), and the hand-offs #2336
685
+ # lists, which stay open and which the floor masks.
686
+ def walked_owner(defining, class_name, owner)
687
+ defining == class_name ? owner : FLOOR_ALL
688
+ end
689
+
690
+ # Issue #2336 — what a call of `name` on a receiver of `receiver_type` reaches when its `side` chain has no def of
691
+ # the name: a body an ownerless definition row may give it ({#receiver_ownerless_body?}), floored outright; on
692
+ # the singleton side, a same-named instance def
693
+ # written as a `module_function def x` argument ({Scope#module_function_row_def?}), which the singleton def table
694
+ # does not hold, walked as a class method; else the chain's `method_missing`.
695
+ def unresolved_receiver_callees(receiver_type, side, name)
696
+ class_name = receiver_type.class_name
697
+ return OPAQUE_CALLEES if receiver_ownerless_body?(name, class_name)
698
+
699
+ if side == :singleton
700
+ found, defining = scope.user_def_through_ancestors(class_name, name)
701
+ if found && scope.module_function_row_def?(found, defining)
702
+ return [[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
703
+ end
704
+ end
705
+ method_missing_callees(class_name, side, name, 0)
706
+ end
707
+
708
+ # Issue #2336 — true when an ownerless definition row may give `name` a body on `class_name`, the receiver of an
709
+ # explicit-receiver call ({Scope#ownerless_definition?}). A `define_method` / `define_singleton_method` /
710
+ # `alias_method` call or a def in a block claims no owner, so it may be the receiver's own and always counts. A
711
+ # top-level def lives on `Object`, so a method RBS declares on the receiver's class answers ahead of it
712
+ # (`Bag < Hash` and a top-level `def store`): the top-level rows count only when RBS declares none.
713
+ def receiver_ownerless_body?(name, class_name)
714
+ return true if scope.ownerless_definition?(name, top_level: false)
715
+ return false if rbs_declares?(class_name, name)
716
+
717
+ scope.ownerless_definition?(name)
718
+ end
719
+
720
+ # Issue #2336 — true when a body no def table holds may answer the receiverless call of `name` in `class_name`'s
721
+ # `side` chain, which no def, `method_missing` or top-level def answers: a definer the chain records
722
+ # (`define_method(:format)` in the class body; {Scope#discovered_method_through_ancestors?}), or, for a name RBS
723
+ # does not declare on `Object` (`Class` on the singleton side), any ownerless definition row. A name `Object`
724
+ # declares (`warn`, `format`) is answered by that method once the chain has no definer of it, so a `def warn` in a
725
+ # `refine Kernel` block or a `define_method` on another class does not count.
726
+ def self_ownerless_body?(name, class_name, side)
727
+ return true if scope.discovered_method_through_ancestors?(class_name, name, side)
728
+ return false if Reflection.instance_method_definition(side == :singleton ? "Class" : "Object", name,
729
+ scope: scope)
730
+
731
+ scope.ownerless_definition?(name)
732
+ end
733
+
734
+ # Issue #2521 (#2570 review) — the `[def, offset, FLOOR_ALL]` a call of `name` that resolves to no def on
735
+ # `class_name`'s `side` chain reaches when the chain defines `method_missing` and `name` is not a method every
736
+ # object (`Object`, or `Class` on the singleton side) answers: Ruby calls `method_missing(name, *args)`, so its
737
+ # parameters sit `offset` places ahead of the call's arguments (-1; 0 for a `send(:name, ...)` hand-off).
738
+ def method_missing_callees(class_name, side, name, offset)
739
+ found =
740
+ if side == :singleton then scope.singleton_def_through_ancestors(class_name, :method_missing).first
741
+ else scope.user_def_through_ancestors(class_name, :method_missing).first
742
+ end
743
+ return NO_CALLEES if found.nil?
744
+
745
+ base = side == :singleton ? "Class" : "Object"
746
+ return NO_CALLEES if Reflection.instance_method_definition(base, name, scope: scope)
747
+
748
+ [[found, offset - 1, FLOOR_ALL]]
749
+ end
750
+
751
+ # What a cross-class hand-off carries instead of an evaluator: the receiver type, which is `self` in the callee.
752
+ # The def's lexical nesting is not carried: it is a property of the def node ({#recorded_def_nesting}), which
753
+ # keys the census memo and the chain's nodes beside it. Holds no scope, so the run-scoped census memo that keeps
754
+ # an {CalleeMutationChain::Edge} alive keeps no caller state alive.
755
+ CalleeOwner = Data.define(:self_type)
756
+
757
+ # The owner of an inherited def ({#walked_owner}): its census and hand-offs are not walked. Every content
758
+ # parameter reads as mutated, so the argument is floored, and a {BudgetTrace::CALLEE_CHAIN_CAP} hit is recorded.
759
+ FLOOR_ALL = :floor_all
760
+
761
+ # Issue #2515 — the owner of a top-level def that may not be the method the call runs (several defs of the name,
762
+ # an alias, a class chain that may answer it first; {ToplevelDefCensus}, {#top_level_callees}): the def is not
763
+ # read, and EVERY argument the call passes reads as written, whatever the def's own parameters are, since another
764
+ # def of the name may take more of them. A hand-off to it floors the handed parameter outright.
765
+ FLOOR_EVERY = :floor_every
766
+
767
+ # Issue #2336 — the answer for a call no def resolves whose arguments the floor reads as written: an opaque sink
768
+ # ({#opaque_sink_call?}), or a name a definer other than a `def` may give a body ({#receiver_ownerless_body?},
769
+ # {#self_ownerless_body?}). No def is
770
+ # read; every argument floors as under {FLOOR_EVERY}.
771
+ OPAQUE_CALLEES = [[nil, 0, FLOOR_EVERY].freeze].freeze
772
+
773
+ # The owner of a top-level def reached from inside a class: its hand-offs resolve at the top level, where `self`
774
+ # is no instance of the caller's class.
775
+ TOPLEVEL_OWNER = CalleeOwner.new(nil)
776
+ OVER_APPROXIMATING = [FLOOR_ALL, FLOOR_EVERY].freeze
777
+ private_constant :CalleeOwner, :FLOOR_ALL, :FLOOR_EVERY, :OPAQUE_CALLEES, :TOPLEVEL_OWNER, :OVER_APPROXIMATING
778
+
779
+ # True for `Const.new` where the class defines its own `self.new`, whose result is not an instance of `Const`.
780
+ def overridden_constructor?(receiver, typing_scope)
781
+ return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
782
+
783
+ klass = typing_scope.type_of(receiver.receiver) if constant_node?(receiver.receiver)
784
+ klass.is_a?(Type::Singleton) &&
785
+ !typing_scope.singleton_def_through_ancestors(klass.class_name, :new).first.nil?
786
+ end
787
+
788
+ # Issue #2522 — the callees a hand-off inside `def_node`'s body (`Help.c(p, o)`, `Helper.new.c(p, o)`) resolves
789
+ # to: the receiver typed once under the `Module.nesting` the declaration walk recorded for `def_node`, so a bare
790
+ # constant follows Ruby's lookup (the lexical chain, the innermost cref's ancestors, the top level) for the
791
+ # spelling the def is actually written under (`class B::Opts` reads `::Helper`, `module B; class Opts` reads
792
+ # `B::Helper`). The nesting is a function of the def node, never of the caller, so the answer cannot depend on
793
+ # which caller reached the memoised edges first. A def with no recorded nesting takes
794
+ # {#unrecorded_hand_off_callees}.
795
+ def hand_off_receiver_callees(call_node, def_node, receiver = call_node.receiver)
796
+ nesting = exact_def_nesting(def_node)
797
+ return unrecorded_hand_off_callees(call_node, receiver) if nesting.nil?
798
+
799
+ receiver_callees_in(scope.with_lexical_nesting(nesting), call_node, receiver)
800
+ end
801
+
802
+ # The def's recorded `Module.nesting` ({Scope::DiscoveryIndex#recorded_def_nesting}), nil when the declaration
803
+ # walk recorded none.
804
+ def recorded_def_nesting(def_node) = scope.discovery.recorded_def_nesting(def_node)
805
+
806
+ # The nesting the hand-offs of `def_node`, run with `self_type`, resolve their constants in exactly, or nil. A
807
+ # def in a `class << self` body answers nil: there Ruby's cref is the singleton class, whose ancestors are not
808
+ # the class's (#2570 review), and the recorded nesting does not name it. Such a def is receiverless, runs on the
809
+ # class object, and is not in its cref's instance def table, which a `module_function` / `extend self` def or an
810
+ # `extend`ed module's def (whose cref is the module itself) is.
811
+ def exact_def_nesting(def_node, self_type = scope.self_type)
812
+ nesting = recorded_def_nesting(def_node)
813
+ return nesting unless self_type.is_a?(Type::Singleton) && def_node.receiver.nil?
814
+ return nil if nesting.nil? || nesting.empty?
815
+
816
+ scope.user_def_for(nesting.first, def_node.name).equal?(def_node) ? nesting : nil
817
+ end
818
+
819
+ # A hand-off in a def whose nesting was not recorded (a def under a header that renders no name, a bundle-served
820
+ # def outside a rehydrating run): the spelling of the defining class is unknown (`module A; class B::C` has the
821
+ # nesting `["A::B::C", "A"]`, `class A::B::C` has `["A::B::C"]`), so the union of two resolutions that try
822
+ # every spelling it could have had, each of which only adds callees:
823
+ #
824
+ # - By name: a bare constant `K` as each prefix of the class name followed by `K`, and as the top-level `K`; a
825
+ # rooted one (`::K`) as `K` alone.
826
+ # - Typed: the receiver typed under each truncation of the nested-spelling nesting (`["A::B::C", "A::B", "A"]`
827
+ # gives `["A::B::C"]`, `["A::B::C", "A::B"]` and the whole chain), which sees an alias, an inherited or
828
+ # included constant and a constant holding an instance.
829
+ def unrecorded_hand_off_callees(call_node, receiver = call_node.receiver)
830
+ constructed = receiver.is_a?(Prism::CallNode) && receiver.name == :new
831
+ written = constant_name(constructed ? receiver.receiver : receiver)
832
+ return NO_CALLEES if written.nil?
833
+ return compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed) if hand_off_memo_bypassed?
834
+
835
+ memo = unrecorded_hand_off_memo(written)
836
+ key = [constructed, call_node.name]
837
+ unless memo.key?(key)
838
+ return memo[key] = MemoAudit.key_audit("unrecorded hand-off") do
839
+ compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
840
+ end
841
+ end
842
+
843
+ MemoAudit.check("unrecorded hand-off", memo[key], written, key) do
844
+ compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
845
+ end
846
+ end
847
+
848
+ def compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
849
+ found = hand_off_class_names(written).flat_map do |class_name|
850
+ hand_off_callee_in(class_name, constructed, call_node.name)
851
+ end
852
+ hand_off_typing_scopes(written).each do |typing_scope|
853
+ found.concat(receiver_callees_in(typing_scope, call_node, receiver))
854
+ end
855
+ found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
856
+ end
857
+
858
+ # The run's per-discovery memo of {#unrecorded_hand_off_callees}: the answer depends on the receiver's spelling,
859
+ # the class `self` is and its side (a constant's ancestor lookup differs between the class object and an
860
+ # instance), and the called name, not on the def it sits in.
861
+ def unrecorded_hand_off_memo(written)
862
+ table = typing_state.weak_key_map(UNRECORDED_HAND_OFFS)
863
+ self_type = scope.self_type
864
+ by_side = ((table[scope.discovery] ||= {})[self_type.is_a?(Type::Singleton)] ||= {})
865
+ (by_side[enclosing_class_name_for(self_type)] ||= {})[written] ||= {}
866
+ end
867
+
868
+ UNRECORDED_HAND_OFFS = TypingState.memo(
869
+ :callee_unrecorded_hand_offs,
870
+ run_scoped: true, audit: "unrecorded hand-off",
871
+ reason: "keyed by discovery, side, class and spelling, all its answer reads"
872
+ )
873
+ private_constant :UNRECORDED_HAND_OFFS
874
+
875
+ # The scopes {#unrecorded_hand_off_callees} types a receiver written as `written` in. A rooted constant and a
876
+ # top-level def are typed once.
877
+ def hand_off_typing_scopes(written)
878
+ name = enclosing_class_name_for(scope.self_type)
879
+ return [scope.with_lexical_nesting([])] if name.nil?
880
+ return [scope] if written.start_with?("::")
881
+
882
+ parts = name.to_s.split("::")
883
+ chain = parts.each_index.map { |i| parts[0..(parts.size - 1 - i)].join("::") }
884
+ chain.each_index.map { |k| scope.with_lexical_nesting(chain[0..k]) }
885
+ end
886
+
887
+ # `[def, 0, owner]` for `name` called on `class_name` (or on its `new`), or none.
888
+ def hand_off_callee_in(class_name, constructed, name)
889
+ if constructed
890
+ return NO_CALLEES unless scope.singleton_def_through_ancestors(class_name, :new).first.nil?
891
+
892
+ found = scope.user_def_for(class_name, name)
893
+ return found.nil? ? NO_CALLEES : [[found, 0, CalleeOwner.new(Type::Combinator.nominal_of(class_name))]]
894
+ end
895
+
896
+ found, defining = scope.singleton_def_through_ancestors(class_name, name)
897
+ return NO_CALLEES if found.nil?
898
+
899
+ owner = CalleeOwner.new(Type::Combinator.singleton_of(class_name))
900
+ [[found, 0, walked_owner(defining, class_name, owner)]]
901
+ end
902
+
903
+ # The class names a constant written as `written` (`K`, `A::K`, `::K`) can name from the def's class name.
904
+ def hand_off_class_names(written)
905
+ return [written.delete_prefix("::")] if written.start_with?("::")
906
+
907
+ name = enclosing_class_name_for(scope.self_type)
908
+ return [written] if name.nil?
909
+
910
+ parts = name.to_s.split("::")
911
+ parts.each_index.map { |i| "#{parts[0..i].join('::')}::#{written}" } << written
912
+ end
913
+
914
+ # True when a census memo must not be read or written: a dependency recorder is active (it must see each
915
+ # constant read), or this scope carries guard narrowings of constants (`if INST.is_a?(Sub)`), which survive
916
+ # `with_self_type` and change what the constant types as, so the answer is no longer the def's alone. An audit
917
+ # recomputation ({MemoAudit}) bypasses them too, so it reads no stored entry.
918
+ def hand_off_memo_bypassed?
919
+ Analysis::DependencyRecorder.active? || !scope.constant_narrowings.empty? || MemoAudit.suspended?
920
+ end
921
+
922
+ # The name a constant node is written with, or nil when part of it is not a constant (`foo::Bar`).
923
+ def constant_name(node)
924
+ node.full_name if constant_node?(node)
925
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError, Prism::ConstantPathNode::MissingNodesInConstantPathError
926
+ nil
927
+ end
928
+
929
+ # The evaluator a hand-off's {CalleeOwner} stands for, built from this one's scope and kept on it.
930
+ def owner_evaluator(owner)
931
+ (@owner_evaluators ||= {})[owner] ||=
932
+ self.class.new(scope: scope.with_self_type(owner.self_type))
933
+ end
934
+
935
+ # The `[def, offset, owner]` triples a call inside `def_node`'s body hands its parameter to: a self-call
936
+ # ({#resolve_self_callees}, owner nil) or a call on a constant / `Const.new` receiver ({#hand_off_receiver?},
937
+ # {#hand_off_receiver_callees}).
938
+ #
939
+ # Issue #2336 — a call on `itself` is a self-call, and one on `self.class` resolves on the class object `self`
940
+ # types as. A call on any other receiver the callee's body cannot type (a call's result: `new.store(h)`,
941
+ # `instance.helper(h)`; a class variable or a global) resolves by name, as an ivar receiver does. A literal
942
+ # `send` hand-off on any receiver resolves as its target.
943
+ def hand_off_callees(call_node, def_node, depth = 0)
944
+ receiver = call_node.receiver
945
+ return self_hand_off_callees(call_node) if self_dispatch_call?(call_node) || itself_receiver?(receiver)
946
+
947
+ if receiver.is_a?(Prism::LocalVariableReadNode) || receiver.is_a?(Prism::ItLocalVariableReadNode)
948
+ return local_receiver_callees(call_node, def_node, depth)
949
+ end
950
+ return receiver_callees_in(scope, call_node) if class_of_self?(receiver)
951
+ return hand_off_receiver_callees(call_node, def_node) if constant_receiver_form?(receiver)
952
+
953
+ named_hand_off_callees(call_node)
954
+ end
955
+
956
+ # {#named_instance_callees} for `call_node`'s name, or a literal `send` hand-off's target.
957
+ def named_hand_off_callees(call_node)
958
+ return named_instance_callees(call_node.name) unless send_hand_off?(call_node)
959
+
960
+ shifted_callees(named_instance_callees(send_target(call_node)))
961
+ end
962
+
963
+ # `itself` / `self.itself`, which is `self`.
964
+ def itself_receiver?(receiver)
965
+ receiver.is_a?(Prism::CallNode) && receiver.name == :itself && receiver.arguments.nil? &&
966
+ receiver.block.nil? && self_dispatch_call?(receiver)
967
+ end
968
+
969
+ # `self.class`, the class object of `self`.
970
+ def class_of_self?(receiver)
971
+ receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode) &&
972
+ receiver.arguments.nil? && receiver.block.nil?
973
+ end
974
+
975
+ # {#resolve_self_callees} for a self-call that is a hand-off inside a def. In a def the evaluator is the top
976
+ # level's (`self` nil), the def a top-level one, and a top-level def is a private method on `Object`: it may be
977
+ # called from any object, so a same-named instance def of the caller's class answers the hand-off before the
978
+ # top-level one. Issue #2515 — the project's same-named instance defs are unioned in as a receiver the callee
979
+ # cannot type is ({#named_instance_callees}): they floor, never narrow.
980
+ def self_hand_off_callees(call_node)
981
+ found = resolve_self_callees(call_node)
982
+ return found unless enclosing_class_name_for(scope.self_type).nil?
983
+
984
+ send_hand_off = send_hand_off?(call_node)
985
+ target = send_hand_off ? call_node.arguments.arguments.first.unescaped.to_sym : call_node.name
986
+ named = named_instance_callees(target)
987
+ return named if named.equal?(CAPPED_HAND_OFF)
988
+ return found if named.empty?
989
+
990
+ named = named.map { |def_node, _offset, owner| [def_node, 1, owner] } if send_hand_off
991
+ UnionCallees.new(found + named)
992
+ end
993
+
994
+ # True for a call on an explicit receiver: one the callee's body can type without its locals (a constant or
995
+ # `Const.new`), a local of the callee (#2515: {#local_receiver_callees}), or (issue #2595 for an ivar, #2336 for
996
+ # any other) one whose type the def's AST does not show, so it resolves by name ({#named_hand_off_callees}).
997
+ def hand_off_receiver?(node) = node.is_a?(Prism::CallNode) && !node.receiver.nil?
998
+
999
+ def constant_receiver_form?(receiver)
1000
+ case receiver
1001
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then true
1002
+ when Prism::CallNode then receiver.name == :new && constant_node?(receiver.receiver)
1003
+ else false
1004
+ end
1005
+ end
1006
+
1007
+ # Issue #2515 — the callees of `local.name(p, o)` inside `def_node`, where `local` is a parameter or a local of
1008
+ # the def. A local every assignment of which in the def is a constant receiver form (`Const`, `Const.new`) is
1009
+ # typed inside the callee, as the constant receiver is ({#hand_off_receiver_callees}). Any other receiver (a
1010
+ # parameter, a local assigned anything else, a block-local) has a type that depends on the caller, which is
1011
+ # not a function of the graph node, so the call resolves by name instead ({#named_instance_callees}).
1012
+ def local_receiver_callees(call_node, def_node, depth)
1013
+ receiver = call_node.receiver
1014
+ typed = receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
1015
+ forms = typed_local_forms(def_node, receiver.name) if typed
1016
+ forms = nil if forms && local_object_opened?(def_node, call_node)
1017
+ return named_hand_off_callees(call_node) if forms.nil?
1018
+
1019
+ found = forms.flat_map { |form| hand_off_receiver_callees(call_node, def_node, form) }
1020
+ found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
1021
+ end
1022
+
1023
+ # The constant receiver forms every assignment of the local `name` in `def_node` holds, or nil when `name` is a
1024
+ # parameter, is never assigned, or may hold anything else. The whole body is read, including the scopes the
1025
+ # hand-off walk leaves out (a lambda, an `instance_exec` / `class_exec` / `define_method` block, a block that
1026
+ # shadows the name), so a write there counts. The local is untyped when any write is not a constant form (an
1027
+ # op-write, a multiple assignment, `for` or `rescue =>` target, a pattern capture, any other value), when it is
1028
+ # the receiver of `send` / `public_send` / `__send__` / `singleton_class` or is assigned to another local
1029
+ # (`g = h`), and, for every local, when the body calls `binding`, `eval`, `instance_eval` or
1030
+ # `local_variable_set` on any receiver, which can write a local no node names. A local whose object the syntax
1031
+ # gives methods ({#local_object_opened?}) is untyped at that call. A per-def AST fact, memoised as {#def_census}.
1032
+ def typed_local_forms(def_node, name)
1033
+ def_census(:local_forms, def_node) { local_forms_of(def_node) }[name]
1034
+ end
1035
+
1036
+ # True when the file's syntax gives the receiver local's object methods the declaration tables do not show
1037
+ # (`def h.x`, `h.extend(M)`, `h.define_singleton_method`, `h.singleton_class...`, `class << h`, an
1038
+ # `h.instance_eval` that defines): {Analysis::CheckRules::LexicalMethodSites}, asked once per def for every
1039
+ # call on a local.
1040
+ def local_object_opened?(def_node, call_node)
1041
+ def_census(:opened_local_calls, def_node) { opened_local_call_offsets(def_node) }
1042
+ .include?(call_node.location.start_offset)
1043
+ end
1044
+
1045
+ def opened_local_call_offsets(def_node)
1046
+ require_relative "../analysis/check_rules/lexical_method_sites"
1047
+ sites = Analysis::CheckRules::LexicalMethodSites.new(def_node)
1048
+ offsets = Set.new
1049
+ Source::NodeWalker.each(def_node.body) do |node|
1050
+ next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode)
1051
+
1052
+ offsets << node.location.start_offset if sites.singleton_local_def?(node)
1053
+ end
1054
+ offsets.freeze
1055
+ end
1056
+
1057
+ DYNAMIC_LOCAL_CALLS = Set[:binding, :eval, :instance_eval, :local_variable_set].freeze
1058
+ DISPATCHING_CALLS = Set[:send, :public_send, :__send__, :singleton_class].freeze
1059
+ private_constant :DYNAMIC_LOCAL_CALLS, :DISPATCHING_CALLS
1060
+
1061
+ def local_forms_of(def_node)
1062
+ forms = {}
1063
+ tainted = Set.new
1064
+ return NO_FORMS if collect_local_forms(def_node.body, forms, tainted)
1065
+
1066
+ params = def_node.parameters
1067
+ forms.each_value(&:uniq!)
1068
+ forms.delete_if do |name, values|
1069
+ tainted.include?(name) || !values.all? { |value| constant_receiver_form?(value) } ||
1070
+ parameter_named?(params, name)
1071
+ end
1072
+ forms.empty? ? NO_FORMS : forms
1073
+ end
1074
+
1075
+ # Fills `forms` (`{ name => written values }`) and `tainted` from `node` and everything under it, a nested `def`
1076
+ # body excepted (its locals are its own). True when the body may write locals the nodes do not show.
1077
+ def collect_local_forms(node, forms, tainted)
1078
+ return false if node.nil?
1079
+
1080
+ return false if node.is_a?(Prism::DefNode)
1081
+ return true if node.is_a?(Prism::CallNode) && dynamic_local_call?(node, tainted)
1082
+
1083
+ note_local_write(node, forms, tainted)
1084
+ dynamic = false
1085
+ node.rigor_each_child { |child| dynamic = true if collect_local_forms(child, forms, tainted) }
1086
+ dynamic
1087
+ end
1088
+
1089
+ def note_local_write(node, forms, tainted)
1090
+ case node
1091
+ when Prism::LocalVariableWriteNode
1092
+ (forms[node.name] ||= []) << node.value
1093
+ tainted << node.value.name if node.value.is_a?(Prism::LocalVariableReadNode)
1094
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
1095
+ Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
1096
+ (forms[node.name] ||= []) << node
1097
+ end
1098
+ end
1099
+
1100
+ # True for a `binding` / `eval` / ... call on any receiver (`Kernel.binding` included), which can write a local no
1101
+ # node names. A local receiving a dispatching call (`send`, `singleton_class`, ...) is tainted as a side effect.
1102
+ def dynamic_local_call?(node, tainted)
1103
+ return true if DYNAMIC_LOCAL_CALLS.include?(node.name)
1104
+
1105
+ tainted << node.receiver.name if DISPATCHING_CALLS.include?(node.name) &&
1106
+ node.receiver.is_a?(Prism::LocalVariableReadNode)
1107
+ false
1108
+ end
1109
+
1110
+ NO_FORMS = {}.freeze
1111
+ private_constant :NO_FORMS
1112
+
1113
+ def parameter_named?(params, name)
1114
+ return false if params.nil?
1115
+
1116
+ all = [params.requireds, params.optionals, params.posts, params.keywords,
1117
+ [params.rest, params.keyword_rest, params.block]]
1118
+ all.flatten.compact.any? { |param| param.respond_to?(:name) && param.name == name }
1119
+ end
1120
+
1121
+ # Issue #2515 — the `[def, 0, owner]` triples of every user instance def named `name`, for a hand-off on a
1122
+ # receiver whose type the callee cannot know. It over-approximates (it favours flooring, ADR-5), as
1123
+ # {#unrecorded_hand_off_callees} unions spellings. A def on a class nothing subclasses is walked under that
1124
+ # class. A def on a module, or on a class a project class names as its superclass, is {FLOOR_ALL}: the real
1125
+ # receiver may be an includer or a subclass whose overrides the walk would not see (ADR-127 WD4, as an inherited
1126
+ # def, {#walked_owner}). A name no user def has (a core or gem method) contributes nothing; a name with more
1127
+ # than {NAMED_HAND_OFF_CAP} defs answers {CAPPED_HAND_OFF}, which floors the handed parameter outright.
1128
+ def named_instance_callees(name)
1129
+ owners = instance_def_owners(name)
1130
+ return NO_CALLEES if owners.empty?
1131
+ return CAPPED_HAND_OFF if owners.size > NAMED_HAND_OFF_CAP
1132
+
1133
+ opaque = subclassing_opaque?
1134
+ UnionCallees.new(owners.filter_map do |class_name, open|
1135
+ found = scope.user_def_for(class_name, name)
1136
+ next if found.nil?
1137
+
1138
+ [found, 0, open || opaque ? FLOOR_ALL : CalleeOwner.new(Type::Combinator.nominal_of(class_name))]
1139
+ end)
1140
+ end
1141
+
1142
+ # A resolution by name, whose candidates are guesses at the receiver: every edge it yields is an
1143
+ # over-approximation ({CalleeMutationChain::Edge#approx}), as is any hand-off with more than one candidate.
1144
+ class UnionCallees < Array; end
1145
+ private_constant :UnionCallees
1146
+
1147
+ # The most defs one name may have for {#named_instance_callees} to walk them; more floors the parameter. A cap
1148
+ # on the name key's fan-out that floors, not a cut of the walk, so ADR-127's rejection of out-degree caps does
1149
+ # not apply: the answer stays a function of the graph node.
1150
+ NAMED_HAND_OFF_CAP = 8
538
1151
 
539
- def_node = resolve_self_callee_def(call_node)
540
- return NO_ARGUMENT_READS if def_node.nil?
1152
+ # The answer for a name above the cap; {#compute_forwarding_edges} turns it into a floor of each handed
1153
+ # parameter.
1154
+ CAPPED_HAND_OFF = [[nil, 0, nil].freeze].freeze
1155
+ private_constant :NAMED_HAND_OFF_CAP, :CAPPED_HAND_OFF
541
1156
 
542
- mutated = callee_content_mutated_parameters(def_node)
1157
+ # The `[class, open]` owners of `name` ({Scope#user_instance_def_owners}). A function of the discovery and the
1158
+ # name alone, so its run-scoped memo is keyed by both; the answer reads no constant and no narrowing, so
1159
+ # {#hand_off_memo_bypassed?} does not bypass it. A hit does not reach the scope, so a dependency recorder is told
1160
+ # of the reads here. Built per name, not as a whole index: a run holds a discovery per file, and a whole-index
1161
+ # build per file cost more than the walks it served.
1162
+ def instance_def_owners(name)
1163
+ return scope.user_instance_def_owners(name) if MemoAudit.suspended?
1164
+
1165
+ table = typing_state.weak_key_map(INSTANCE_DEF_INDEX)
1166
+ by_name = (table[scope.discovery] ||= {})
1167
+ unless by_name.key?(name)
1168
+ return by_name[name] = MemoAudit.key_audit("instance def owners") { scope.user_instance_def_owners(name) }
1169
+ end
1170
+
1171
+ record_instance_def_owner_reads(name, by_name[name]) if Analysis::DependencyRecorder.active?
1172
+ MemoAudit.check("instance def owners", by_name[name], name) { scope.user_instance_def_owners(name) }
1173
+ end
1174
+
1175
+ # {Scope#subclassing_opacity}'s flag, memoised beside the owners per discovery; a recorder is told its keys on
1176
+ # every call, hit or not.
1177
+ def subclassing_opaque?
1178
+ return scope.subclassing_opacity.first if MemoAudit.suspended?
1179
+
1180
+ table = typing_state.weak_key_map(INSTANCE_DEF_INDEX)
1181
+ by_name = (table[scope.discovery] ||= {})
1182
+ entry = by_name[OPACITY_ENTRY] ||= MemoAudit.key_audit("subclassing opacity") { scope.subclassing_opacity }
1183
+ Analysis::DependencyRecorder.read_keys(entry.last) if Analysis::DependencyRecorder.active?
1184
+ MemoAudit.check("subclassing opacity", entry, OPACITY_ENTRY) { scope.subclassing_opacity }.first
1185
+ end
1186
+
1187
+ OPACITY_ENTRY = :"<subclassing-opacity>"
1188
+ private_constant :OPACITY_ENTRY
1189
+
1190
+ def record_instance_def_owner_reads(name, owners)
1191
+ Analysis::DependencyRecorder.read_name(:defines, name)
1192
+ owners.map(&:first).each { |owner| Analysis::DependencyRecorder.read_last_segment(:subclass_of, owner) }
1193
+ end
1194
+
1195
+ INSTANCE_DEF_INDEX = TypingState.memo(
1196
+ :callee_instance_def_index,
1197
+ run_scoped: true, audit: ["instance def owners", "subclassing opacity"],
1198
+ reason: "keyed by discovery and name, all its answer reads"
1199
+ )
1200
+ private_constant :INSTANCE_DEF_INDEX
1201
+
1202
+ def constant_node?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
1203
+
1204
+ # `census` (a `callee_*_mutated_parameters` name) of `def_node`, read through `owner`, the {CalleeOwner} the
1205
+ # callee's hand-offs resolve in, or this evaluator when the callee is a self-call's.
1206
+ def census_of(owner, census, def_node)
1207
+ return floor_all_census(census, def_node) if owner == FLOOR_ALL
1208
+ return floor_every_census(census) if owner == FLOOR_EVERY
1209
+
1210
+ (owner.nil? ? self : owner_evaluator(owner)).__send__(census, def_node)
1211
+ end
1212
+
1213
+ # The {FLOOR_EVERY} answer: every positional and keyword argument mutated, no hand-offs.
1214
+ def floor_every_census(census)
1215
+ BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
1216
+ census == :forwarding_edges ? NO_FORWARDS : EVERY_ARGUMENT
1217
+ end
1218
+
1219
+ # The {FLOOR_ALL} answer: every content parameter mutated, and (issue #2595) a named rest or keyword rest at
1220
+ # {ANY_SLOT} / {ANY_KEYWORD} ({#forwarding_slots}), no hand-offs.
1221
+ def floor_all_census(census, def_node)
1222
+ BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
1223
+ census == :forwarding_edges ? NO_FORWARDS : forwarding_slots(def_node)
1224
+ end
1225
+
1226
+ # The local-variable reads `call_node` passes to the parameters of `mutated`, a `{ name => slot }` census, the
1227
+ # first `offset` arguments not being the callee's (the `:name` of a `send` hand-off, {#resolve_self_callees}).
1228
+ def arguments_for_slots(call_node, mutated, offset)
543
1229
  return NO_ARGUMENT_READS if mutated.empty?
544
1230
 
545
1231
  argument_nodes = call_node.arguments.arguments
546
- mutated.values.uniq.filter_map do |slot|
547
- argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot]
1232
+ values = mutated.values.uniq
1233
+ reads = values.filter_map do |slot|
1234
+ next if slot == ANY_SLOT || slot == ANY_KEYWORD || (slot.is_a?(Integer) && (slot + offset).negative?)
1235
+
1236
+ argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot + offset]
548
1237
  argument = argument.value if argument.is_a?(Prism::ImplicitNode)
549
1238
  argument if argument.is_a?(Prism::LocalVariableReadNode)
550
1239
  end
1240
+ reads += rest_arguments(argument_nodes, offset) if values.include?(ANY_SLOT)
1241
+ reads += keyword_rest_arguments(argument_nodes) if values.include?(ANY_KEYWORD)
1242
+ reads.uniq
1243
+ end
1244
+
1245
+ # Every local read passed positionally from `offset` on: what a callee that hands its rest parameter to `super`
1246
+ # may mutate ({#super_forwarded_parameters}).
1247
+ def rest_arguments(argument_nodes, offset)
1248
+ argument_nodes.drop([offset, 0].max).grep(Prism::LocalVariableReadNode)
1249
+ end
1250
+
1251
+ # Every local read passed as a keyword value: what a callee that hands its keyword rest parameter to `super` may
1252
+ # mutate ({#super_forwarded_parameters}).
1253
+ def keyword_rest_arguments(argument_nodes)
1254
+ hash = argument_nodes.last
1255
+ return NO_ARGUMENT_READS unless hash.is_a?(Prism::KeywordHashNode)
1256
+
1257
+ hash.elements.filter_map do |pair|
1258
+ value = pair.value if pair.is_a?(Prism::AssocNode)
1259
+ value = value.value if value.is_a?(Prism::ImplicitNode)
1260
+ value if value.is_a?(Prism::LocalVariableReadNode)
1261
+ end
551
1262
  end
552
1263
 
1264
+ # The slot {#super_forwarded_parameters} gives a keyword rest parameter: every keyword value of the call.
1265
+ ANY_KEYWORD = :__any_keyword__
1266
+ private_constant :ANY_KEYWORD
1267
+
553
1268
  # The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
554
1269
  def keyword_argument(argument_nodes, name)
555
1270
  hash = argument_nodes.last
@@ -585,20 +1300,48 @@ module Rigor
585
1300
  # evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
586
1301
  # body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
587
1302
  # absence of evidence.
1303
+ #
1304
+ # On a receiver that is itself untracked (a `Dynamic` / `Top` constituent, the #544 test the narrowing
1305
+ # record takes: a parameter, an unbound ivar), the slot may hold what an earlier call stored, so the reading
1306
+ # keeps the rvalue only as far as its class is known ({#untracked_memo_value}, #2292): read as the fresh
1307
+ # literal, `(h[k] ||= {})[:name].upcase` reported a call on `nil` and `(h[k] ||= 1).upcase` one on `1`. A
1308
+ # tracked receiver keeps the literal, so a block fold over a captured `pool = {}`, whose in-place widening
1309
+ # reads `Hash[Dynamic[top], Dynamic[top]]`, still answers `find { |s| (pool[s] ||= s) == "b" }` as `"b"`.
588
1310
  def index_compound_write_value(node)
589
1311
  return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
590
1312
 
591
1313
  current = index_read_type(node, scope)
592
1314
  rhs = scope.type_of(node.value, tracer: tracer)
593
- return rhs if memoizing_index_read?(node, current, rhs)
1315
+ if memoizing_index_read?(node, current, rhs)
1316
+ return rhs if tracked_memo_receiver?(node.receiver)
1317
+
1318
+ memo = untracked_memo_value(rhs)
1319
+ return memo if memo
1320
+ end
594
1321
 
595
1322
  index_write_stored_type(node, scope, current: current, rhs: rhs)
596
1323
  end
597
1324
 
1325
+ # The value of `recv.each_with_object(memo) { |x, acc| … }` given `call_type`, the call's dispatched type
1326
+ # (issue #2411): the memo as the block's content mutations of `acc` leave it ({#each_with_object_return}).
1327
+ # `ExpressionTyper` reads it for every call, so a value position — a method's tail, which
1328
+ # {DefReturnTyper} types through `Scope#type_of` — answers what a statement does; read off the dispatch
1329
+ # alone, `def m = xs.each_with_object({}) { |x, h| h[x] = 1 }` returned the memo argument's `{}`.
1330
+ def each_with_object_value(call_node, call_type)
1331
+ each_with_object_return(call_node, call_type)
1332
+ end
1333
+
598
1334
  private
599
1335
 
600
1336
  attr_reader :scope, :tracer
601
1337
 
1338
+ # ADR-129 WD2 — the calling thread's {TypingState::State}, cached on the evaluator the first time a slot is read.
1339
+ # An evaluator kept across threads must be kept per state instead ({ExpressionTyper#typing_state}). A frame
1340
+ # install whose block's value is the answer calls {TypingState.current} instead: Rigor's own analysis does not
1341
+ # adopt this memoised reader's type at a self-call (#525's self-purity), so the install's answer would read as
1342
+ # `Dynamic` through it.
1343
+ def typing_state = @typing_state ||= TypingState.current
1344
+
602
1345
  # Thread the scope through every child statement in declaration order. The body's value is the type of the last
603
1346
  # statement (or `Constant[nil]` for an empty body); intermediate statements' types are discarded, but their scope
604
1347
  # effects are preserved.
@@ -608,7 +1351,7 @@ module Rigor
608
1351
  def eval_statements(node)
609
1352
  result_type = Type::Combinator.constant_of(nil)
610
1353
  current = scope
611
- raising = Thread.current[RETRY_FRAMES_KEY]
1354
+ raising = typing_state[RETRY_FRAMES]
612
1355
  node.body.each do |stmt|
613
1356
  result_type, current = sub_eval(stmt, current)
614
1357
  record_raise_points(raising, stmt, current) if raising
@@ -626,11 +1369,11 @@ module Rigor
626
1369
  end
627
1370
 
628
1371
  # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
629
- # propagate left-to-right) and binds `name` to the result type. Compound assignment forms (`+=` etc.) are deferred
630
- # to a follow-up; for now they degrade to "type the rhs, do not rebind" via the default branch in {#evaluate}.
1372
+ # propagate left-to-right) and binds `name` to the result type.
631
1373
  def eval_local_write(node)
632
1374
  rhs_type, post_rhs = sub_eval(node.value, scope)
633
1375
  bound = bind_local_write(node, rhs_type, post_rhs)
1376
+ bound = bound.with_construction_fact(node.name, :synthesised) if synthesised_write?(node, rhs_type)
634
1377
  # Issue #667 — `m = AppConfig::MODE` makes `m` a copy of a constant the reader's file does not
635
1378
  # declare, so `m == :production` folding to `true` is the project's configuration and not a logic
636
1379
  # error the author can see. Stamped LAST, across every binding path above: an RHS can be both an
@@ -641,6 +1384,53 @@ module Rigor
641
1384
  [rhs_type, bound.with_published_constant_mark(:local, node.name)]
642
1385
  end
643
1386
 
1387
+ # Issue #2555 L3 — whether the object a local write binds is one the engine built itself, so the binding
1388
+ # carries the `:synthesised` construction fact ({Scope#construction_fact?}): a collection literal (`[...]`,
1389
+ # `{...}`), or a constructor call on the core class (`Array.new(n, 0)`, `Hash.new(0)`, `Set.new(xs)`,
1390
+ # `Set[1]`) whose value is a carrier of that class. Nothing else is stamped: not a copy of another binding
1391
+ # (`b = a`), a call whose return a signature declares, a parameter, or an instance variable.
1392
+ # The fact is withheld from a local that may escape anywhere in the body ({ConstructionEscape}): a store through
1393
+ # another holder of the object is one no seam sees, and the fact would leave the carrier without the floor that
1394
+ # covered it.
1395
+ def synthesised_write?(node, rhs_type)
1396
+ synthesised_construction?(node.value, rhs_type) &&
1397
+ !ConstructionEscape.escaped?(scope.match_frame&.body, node.name, constructed_carrier_class(rhs_type), scope)
1398
+ end
1399
+
1400
+ def synthesised_construction?(value_node, rhs_type)
1401
+ case value_node
1402
+ when Prism::ArrayNode, Prism::HashNode then true
1403
+ when Prism::CallNode then core_constructor_call?(value_node, rhs_type)
1404
+ else false
1405
+ end
1406
+ end
1407
+
1408
+ CORE_CONSTRUCTOR_NAMES = %i[new []].to_set.freeze
1409
+ private_constant :CORE_CONSTRUCTOR_NAMES
1410
+
1411
+ # The constant receiver must resolve to the core class itself (a project's own `Admin::Set` does not), and the
1412
+ # value must be a carrier of that class.
1413
+ def core_constructor_call?(call, rhs_type)
1414
+ receiver = call.receiver
1415
+ return false unless CORE_CONSTRUCTOR_NAMES.include?(call.name)
1416
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
1417
+
1418
+ class_name = constructed_carrier_class(rhs_type)
1419
+ return false if class_name.nil?
1420
+
1421
+ receiver_type = scope.type_of(receiver, tracer: tracer)
1422
+ receiver_type.is_a?(Type::Singleton) && receiver_type.class_name == class_name
1423
+ end
1424
+
1425
+ def constructed_carrier_class(type)
1426
+ case type
1427
+ when Type::Tuple then "Array"
1428
+ when Type::HashShape then "Hash"
1429
+ when Type::Nominal then type.class_name if %w[Array Hash Set].include?(type.class_name)
1430
+ when Type::Constant then "Set" if type.value.is_a?(::Set)
1431
+ end
1432
+ end
1433
+
644
1434
  # The binding half of {#eval_local_write}: the three mutually exclusive provenance paths a local write
645
1435
  # can take, unchanged from when they were inline.
646
1436
  def bind_local_write(node, rhs_type, post_rhs)
@@ -710,7 +1500,7 @@ module Rigor
710
1500
  def rhs_origin(value_node, scope_after_rhs, rhs_type)
711
1501
  return nil unless rhs_type.is_a?(Type::Dynamic)
712
1502
 
713
- scope_after_rhs.dynamic_origins[value_node]
1503
+ scope_after_rhs.dynamic_origin(value_node)
714
1504
  end
715
1505
 
716
1506
  # The scope binding the written local with ADR-58's `:local` mark when the write copies a declaration-sourced
@@ -740,8 +1530,7 @@ module Rigor
740
1530
  # Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
741
1531
  # and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
742
1532
  # type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
743
- # `build_method_entry_scope`, so writes do not leak across method boundaries until cross-method ivar/cvar tracking
744
- # lands.
1533
+ # `build_method_entry_scope`; cross-method ivar and cvar state enters it through the class-level seeds.
745
1534
  def eval_ivar_write(node)
746
1535
  rhs_type, post_rhs = sub_eval(node.value, scope)
747
1536
  # Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
@@ -980,13 +1769,13 @@ module Rigor
980
1769
  # narrowing keyed on `(receiver_kind, receiver_name,
981
1770
  # literal_key)` when both receiver and key are stable
982
1771
  # (see {Inference::IndexedNarrowing}). Unstable shapes
983
- # fall through to "no scope effect", matching the old
984
- # `Prism::IndexOrWriteNode` default-branch behaviour.
1772
+ # record no narrowing.
985
1773
  #
986
1774
  # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
987
1775
  # post-`||=` value, not the rvalue alone.
988
1776
  def eval_index_or_write(node)
989
- _rhs_type, post_rhs = sub_eval(node.value, scope)
1777
+ operands = index_write_operand_scope(node)
1778
+ _rhs_type, post_rhs = sub_eval(node.value, operands)
990
1779
  result_type = index_write_stored_type(node, scope)
991
1780
 
992
1781
  # A narrowing is keyed on ONE literal slot — `a[k]` — but a multi-index `||=` reads and
@@ -994,8 +1783,7 @@ module Rigor
994
1783
  # record `a[0]`'s type as the region answer: `a[0, 1] ||= []` would claim `a[0]` non-nil
995
1784
  # where the store splices nothing and `a[0]` stays nil at runtime. Decline the record for
996
1785
  # any form but the single-index one.
997
- key_node = single_index_argument(node)
998
- address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
1786
+ address = recordable_slot_address(node)
999
1787
  # Issue #544 — a receiver with an untracked (Dynamic / Top) constituent can hold a caller-supplied
1000
1788
  # slot value the `||=` keeps, so the recorded default would invent a fact; decline the record.
1001
1789
  address = nil if address && !IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(node.receiver))
@@ -1004,7 +1792,9 @@ module Rigor
1004
1792
  # the reverse order would trade this feature away for the widening. The two are complementary — the shape
1005
1793
  # forgets that the collection is still empty, the narrowing remembers that THIS slot is now non-nil.
1006
1794
  post = IndexWriteWidening.widen(node: node, current_scope: post,
1007
- arg_types: index_write_arg_types(node, result_type))
1795
+ arg_types: index_write_arg_types(node, result_type, join_scope: operands))
1796
+ # Issue #1218 — a key that is not a literal (`h[k] ||= v`) may name a slot another `||=` narrowed.
1797
+ post = IndexedNarrowing.invalidate_indexed_write(node, post)
1008
1798
  post = post.with_indexed_narrowing(*address, result_type) if address
1009
1799
 
1010
1800
  # The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
@@ -1029,6 +1819,29 @@ module Rigor
1029
1819
  erased || stored
1030
1820
  end
1031
1821
 
1822
+ # The memoizing value on an untracked receiver, or nil when it keeps the stored `Dynamic[top] | v`. A `{}` /
1823
+ # `[]` rvalue (a shape, a tuple, a bare `Hash` / `Array`) erases to `Hash[untyped, untyped]` /
1824
+ # `Array[untyped]`, since the table is not empty after the first store. A construction (a nominal,
1825
+ # parameterised or not, or a `Data` / `Struct` instance, possibly with a `nil` / `false` arm) is kept as the
1826
+ # #1155 reading has it. Anything else carries literal precision (`1`, `:a`, an integer range) and yields nil.
1827
+ def untracked_memo_value(rhs)
1828
+ erased = erase_memo_literal(rhs)
1829
+ return erased if erased
1830
+
1831
+ truthy = Narrowing.narrow_truthy(rhs)
1832
+ members = truthy.is_a?(Type::Union) ? truthy.members : [truthy]
1833
+ rhs if members.all? { |member| MEMO_CONSTRUCTIONS.any? { |klass| member.is_a?(klass) } }
1834
+ end
1835
+
1836
+ MEMO_CONSTRUCTIONS = [Type::Nominal, Type::DataInstance, Type::StructInstance, Type::Dynamic, Type::Top].freeze
1837
+ private_constant :MEMO_CONSTRUCTIONS
1838
+
1839
+ def erase_memo_literal(type)
1840
+ return erase_memo_container(type) unless type.is_a?(Type::Nominal)
1841
+
1842
+ erase_memo_container(type) if type.type_args.empty? && %w[Hash Array].include?(type.class_name)
1843
+ end
1844
+
1032
1845
  def erase_memo_container(type)
1033
1846
  any = Type::Combinator.untyped
1034
1847
  case type
@@ -1047,14 +1860,76 @@ module Rigor
1047
1860
  # expression, scope untouched — and the receiver never widened. They store through `[]=` exactly as
1048
1861
  # `eval_index_or_write` does, so they take the same widening; the stored value is the compound result —
1049
1862
  # `falsey(h[k]) | v` for `&&=`, the dispatched `h[k] + v` for `+=` — not the rvalue alone.
1863
+ #
1864
+ # Issue #1218 — the widening carries the receiver's slot narrowings across its rebind
1865
+ # ({MutationRejoin.carry_slot_narrowings}), so a `h[:e] ||= 0` narrowing outlived `h[:e] += 1` and the slot
1866
+ # kept reading `0`. The store drops the narrowing on the slot it overwrote, as a `[]=` call does
1867
+ # ({IndexedNarrowing.invalidate_indexed_write}: every narrowing on the receiver when the index is not one
1868
+ # literal key). A slot that held a narrowing then records the value the write stored, which the read it
1869
+ # performed took from that narrowing; a slot with none records nothing new.
1050
1870
  def eval_index_write(node)
1051
- _rhs_type, post_rhs = sub_eval(node.value, scope)
1871
+ operands = index_write_operand_scope(node)
1872
+ _rhs_type, post_rhs = sub_eval(node.value, operands)
1052
1873
  stored = index_write_stored_type(node, scope)
1053
1874
  widened = IndexWriteWidening.widen(node: node, current_scope: post_rhs,
1054
- arg_types: index_write_arg_types(node, stored))
1875
+ arg_types: index_write_arg_types(node, stored, join_scope: operands))
1876
+ widened = IndexedNarrowing.invalidate_indexed_write(node, widened)
1877
+ address = recordable_slot_address(node)
1878
+ widened = widened.with_indexed_narrowing(*address, stored) if address && scope.indexed_narrowing(*address)
1055
1879
  [stored, forget_rebound_specials(widened, node)]
1056
1880
  end
1057
1881
 
1882
+ # The `(receiver, key)` slot a compound index write may record its stored value on, or nil. A narrowing is
1883
+ # keyed on ONE literal slot, so a multi-index form, which reads and stores a splice region, records none. The
1884
+ # receiver's `[]=` must leave the stored value at that slot ({IndexedNarrowing.verbatim_slot?}: a `Hash` or
1885
+ # `Array`, and no negative index on an `Array`), and the rvalue must not rebind the receiver variable
1886
+ # ({#rvalue_rebinds_receiver?}), since the store went to the object it named before (issue #1218).
1887
+ def recordable_slot_address(node)
1888
+ key_node = single_index_argument(node)
1889
+ address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
1890
+ return nil if address.nil?
1891
+ return nil unless IndexedNarrowing.verbatim_slot?(scope.type_of(node.receiver, tracer: tracer), address.last)
1892
+ return nil if rvalue_rebinds_receiver?(node)
1893
+
1894
+ address
1895
+ end
1896
+
1897
+ # Whether a compound index write's rvalue may rebind its stable receiver variable: it writes the variable
1898
+ # (`h[k] += (h = {}; 1)`, `@h ||= …`, a multiple-assignment target, in a block too:
1899
+ # {OperandEffects.written_variables}), or, for an instance variable, the intervening-call reset past the
1900
+ # rvalue's self calls rebinds it ({InterveningIvarReset.after_operand}). That reset widens only a binding
1901
+ # narrower than the class-ivar seed, and only for a call not proven to leave it alone, so
1902
+ # `@h = { k: nil }; @h[:k] += (reset; 1)` with a `reset` that assigns `@h` is a rebind, and `@t[:k] ||=
1903
+ # compute(1)` on a `@t` the class binds to one shape is not. A store into another slot of the receiver
1904
+ # (`h[:a] ||= (h[:b] ||= [])`) is no rebind either.
1905
+ def rvalue_rebinds_receiver?(node)
1906
+ receiver = node.receiver
1907
+ name = receiver.name
1908
+ return true if OperandEffects.written_variables(node.value).include?(name)
1909
+ return false unless receiver.is_a?(Prism::InstanceVariableReadNode)
1910
+
1911
+ !InterveningIvarReset.after_operand(node.value, scope).ivar(name).equal?(scope.ivar(name))
1912
+ end
1913
+
1914
+ # The scope a compound index write's rvalue runs from: Ruby evaluates the receiver, then the index arguments,
1915
+ # then the rvalue, so a write in either is threaded in that order ({#thread_operand}) before the rvalue. An
1916
+ # index write takes no block-pass argument (a syntax error since Ruby 3.4).
1917
+ #
1918
+ # Issue #1272 — `(h ||= {})[k] ||= v`, `(@h ||= {})[k] ||= v` and `h[(i = 1)] ||= v` left `h`, `@h` and `i`
1919
+ # on their pre-write bindings, so a later `h.fetch(k)` on a nil-initialised `h` reported a call on `nil`. A
1920
+ # receiver or argument holding no write answers the entry scope unchanged.
1921
+ #
1922
+ # The receiver's joinability is read from this scope ({#index_write_arg_types}' `join_scope`), since the
1923
+ # binding the store widens is the one the receiver's write left (`h` is `{}`, not the entry's `nil`). The read,
1924
+ # the stored value and the widening's argument types are still typed from the entry scope (TODO #3100): the
1925
+ # receiver expression's own value (`(h ||= {})` is the hash) answers what its write produced, but an rvalue
1926
+ # reading a variable an index argument writes (`h[(i = 1)] ||= i + 1`) is typed at its pre-write binding.
1927
+ def index_write_operand_scope(node)
1928
+ walk = OperandWalk.new(walk_recorder)
1929
+ after = thread_operand(node.receiver, scope, walk, scope)
1930
+ thread_operand(node.arguments, after, walk, scope)
1931
+ end
1932
+
1058
1933
  # `[index_type..., stored_value_type]` for an index-write node, shaped exactly like a `[]=`
1059
1934
  # call's argument list so the widening seam can join it the same way (issue #560) — a
1060
1935
  # two-index compound write (`a[0, 1] += v`) keeps BOTH index arguments ahead of the stored
@@ -1068,15 +1943,16 @@ module Rigor
1068
1943
  # The index arguments are typed, and the receiver's joinability read, in `type_scope`: the
1069
1944
  # evaluator's entry scope by default; a `for` index passes its post-collection scope and a
1070
1945
  # rescue reference its arm's entry scope, the nearest the engine has to where Ruby evaluates
1071
- # them (each iteration, the moment of the catch).
1946
+ # them (each iteration, the moment of the catch). A compound index write reads the joinability alone in
1947
+ # `join_scope`, the scope its receiver and index arguments left ({#index_write_operand_scope}).
1072
1948
  # There is deliberately NO `rescue` here. `Scope#type_of` is a total query over well-formed Prism input,
1073
1949
  # so a raise is an engine bug, and swallowing it would silently downgrade a live seam to "no evidence" —
1074
1950
  # the join would quietly stop happening with nothing to show for it. Let it reach the runner's
1075
1951
  # internal-error path, where it is visible.
1076
- def index_write_arg_types(node, stored_type, type_scope: scope)
1952
+ def index_write_arg_types(node, stored_type, type_scope: scope, join_scope: type_scope)
1077
1953
  args = node.arguments
1078
1954
  return MutationWidening::NO_ARG_TYPES if args.nil? || stored_type.nil?
1079
- return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, type_scope)
1955
+ return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, join_scope)
1080
1956
 
1081
1957
  list = args.respond_to?(:arguments) ? args.arguments : args
1082
1958
  # A splat argument is marked `nil` — its expansion decides the store's arity at
@@ -1113,6 +1989,28 @@ module Rigor
1113
1989
  end
1114
1990
  end
1115
1991
 
1992
+ # Whether a memoizing `||=` on `receiver` reads as the fresh rvalue ({#index_compound_write_value}): the
1993
+ # receiver's type passes the #544 test, and the receiver is a local the current body binds, captured into a
1994
+ # block or not, and no parameter of the enclosing method names ({MatchRebinding::Frame#body_local?}), or
1995
+ # an object fresh at every run (`Hash.new`, {RepeatedOrWrites.fresh_object?}) (issue
1996
+ # #2354). An ivar, cvar or global read, a parameter, or any other expression (a call result, a
1997
+ # parenthesised `||=`) is a table whose gradual slot another call, or an earlier run of this one, may have
1998
+ # filled — an ivar seeded `{}` in `initialize`, a parameter declared `Hash[Symbol, untyped]` — so it widens
1999
+ # through {#untracked_memo_value} like an untracked receiver. Without a frame (no method, class or file
2000
+ # body stamped one) a local receiver is treated as body-bound, as before #2354.
2001
+ def tracked_memo_receiver?(receiver)
2002
+ return false unless body_bound_memo_receiver?(receiver)
2003
+
2004
+ IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(receiver, tracer: tracer))
2005
+ end
2006
+
2007
+ def body_bound_memo_receiver?(receiver)
2008
+ return RepeatedOrWrites.fresh_object?(receiver) unless receiver.is_a?(Prism::LocalVariableReadNode)
2009
+
2010
+ frame = scope.match_frame
2011
+ frame.nil? || frame.body_local?(receiver.name)
2012
+ end
2013
+
1116
2014
  # True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
1117
2015
  # reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
1118
2016
  def memoizing_index_read?(node, current, rhs)
@@ -1160,29 +2058,113 @@ module Rigor
1160
2058
  # Both mutator-receiver widenings, against one typing of the call's arguments: the bindings the
1161
2059
  # receiver NAMES ({MutationWidening.widen_after_call}) and, when it names none because the
1162
2060
  # receiver is an element read into a local, that element's pin inside its container
1163
- # ({ElementReadWidening.widen_element_read}, issue #643).
1164
- def widen_mutated_receivers(call_node, current_scope)
2061
+ # ({ElementReadWidening.widen_element_read}, issue #643) — and, issue #2499, the String a stdlib
2062
+ # out-buffer call fills ({OutBufferMutation}), which names no receiver of the call at all.
2063
+ #
2064
+ # Issue #2335 — a literal-symbol `send` (`a.send(:push, x)`) widens as the call it makes, and a mutation through a
2065
+ # receiver that may be another local's object floors every local whose object another holder may reach
2066
+ # ({AliasMutationFloor}).
2067
+ def widen_mutated_receivers(node, current_scope)
2068
+ call_node = AliasMutationFloor.literal_send_target(node) || node
1165
2069
  arg_types = mutator_arg_types(call_node, current_scope)
1166
2070
  widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
1167
- arg_types: arg_types)
1168
- ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened, arg_types: arg_types)
2071
+ arg_types: arg_types,
2072
+ rewritten_element: rewritten_element(call_node, current_scope))
2073
+ widened = ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened,
2074
+ arg_types: arg_types)
2075
+ if AliasMutationFloor.candidate?(call_node)
2076
+ widened = AliasMutationFloor.floor_after_call(call_node, widened) do
2077
+ named_receiver_locals(call_node.receiver)
2078
+ end
2079
+ end
2080
+ # Issue #2499 — a stdlib call that fills a String argument (`io.read(n, buf)`, `StringIO.new(s)`).
2081
+ floor_ivar_mutating_callee(node, OutBufferMutation.floor(call_node, widened))
1169
2082
  end
1170
2083
 
1171
- # `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
1172
- def mutator_arg_types(call_node, current_scope)
1173
- unless ContentJoin::CONTENT_ADDERS.include?(call_node.name) || StringMutation::TRANSLATORS.include?(call_node.name)
1174
- return MutationWidening::NO_ARG_TYPES
1175
- end
1176
- unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
1177
- ElementReadWidening.joinable_element_read?(call_node.receiver, current_scope)
1178
- return MutationWidening::NO_ARG_TYPES
2084
+ # Issue #2335 — a call on a local that may hold another local's object (`box = Box.new(a)`) to a project method
2085
+ # that mutates one of its instance variables (`def add(v) = @items << v`) may mutate that object: every local
2086
+ # whose object another holder may reach takes one unknown store ({AliasMutationFloor.floor_after_written_local}).
2087
+ def floor_ivar_mutating_callee(call_node, current_scope)
2088
+ receiver = call_node.receiver
2089
+ return current_scope if MutationWidening::SHAPE_MUTATORS.include?(call_node.name) || send_hand_off?(call_node)
2090
+ return floor_self_ivar_callee(call_node, current_scope) if self_dispatch_call?(call_node)
2091
+ return current_scope unless receiver.is_a?(Prism::LocalVariableReadNode)
2092
+ return current_scope unless callee_bearing?(current_scope.local(receiver.name))
2093
+ return current_scope unless AliasMutationFloor.reaches_other_locals?(current_scope, receiver, call_node)
2094
+
2095
+ callees = receiver_callees_in(scope, call_node)
2096
+ return current_scope unless callees.any? { |def_node, _, _| AliasMutationFloor.mutates_ivar?(def_node) }
2097
+
2098
+ AliasMutationFloor.floor_after_written_local(current_scope, receiver, call_node)
2099
+ end
2100
+
2101
+ # The same for a call on `self` (`push_it(v)`, `self.push_it(v)`): its ivars hold whatever locals were handed to
2102
+ # them (`@items = a`, `a = @q`), so every aliased local holding a literal shape takes one unknown store.
2103
+ def floor_self_ivar_callee(call_node, current_scope)
2104
+ return current_scope unless AliasMutationFloor.holder_call_reaches?(current_scope, call_node)
2105
+ return current_scope unless resolve_self_callees(call_node).any? do |def_node, _, _|
2106
+ AliasMutationFloor.mutates_ivar?(def_node)
1179
2107
  end
1180
2108
 
1181
- content_arg_types(call_node, operand_scope, @operand_types)
2109
+ AliasMutationFloor.floor_after_holder_call(current_scope, call_node)
1182
2110
  end
1183
2111
 
1184
- # The index node of an index-write when it holds exactly one index argument — the only form
1185
- # whose stored value lands on a nameable slot (`a[k]`). Multi-index forms address a splice
2112
+ # A binding `receiver_callees_in` can resolve a project def on: a class or instance type, or a union of them. Any
2113
+ # other binding resolves none, so the floor skips the resolution for it.
2114
+ def callee_bearing?(type) = type.is_a?(Type::Nominal) || type.is_a?(Type::Singleton) || type.is_a?(Type::Union)
2115
+
2116
+ # The locals a mutator's receiver names ({ReceiverAlias.mutated_reads}), which the named path widens, and the
2117
+ # root of an element read (`c[0].shift`), whose element {ElementReadWidening} widens.
2118
+ def named_receiver_locals(receiver)
2119
+ return nil if receiver.nil?
2120
+ return receiver.name if receiver.is_a?(Prism::LocalVariableReadNode)
2121
+
2122
+ names = ReceiverAlias.mutated_reads(receiver).filter_map do |read|
2123
+ ReceiverAlias.read_name(read) if read.is_a?(Prism::LocalVariableReadNode)
2124
+ end
2125
+ root = ElementReadWidening.element_read_path(receiver)&.first
2126
+ root ? names << root.name : names
2127
+ end
2128
+
2129
+ # Issue #2427 — the element an element rewriter (`map!`, `collect!`, `flatten!`) leaves in its receiver, read
2130
+ # off what the non-bang counterpart answers for the same receiver, block and arguments
2131
+ # ({RewriteMutation::TYPED_COUNTERPARTS}): `a.map!(&:to_i)` stores what `a.map(&:to_i)` returns. `nil` when the
2132
+ # call is no such rewriter, or the counterpart has no single element; the widening then takes
2133
+ # {RewriteMutation}'s gradual arm.
2134
+ #
2135
+ # `nil` too when the receiver may name more than one binding (`(flag ? a : b).map!(&:to_i)`): Ruby rewrites
2136
+ # only the one the branch selects, and typing every candidate gave the other one an element it never holds.
2137
+ #
2138
+ # The block and receiver gates only save typing a counterpart nothing consumes: with no block `map` answers an
2139
+ # Enumerator, which has no element to read, and {RewriteMutation.typed} answers nothing for a receiver the
2140
+ # widening does not rewrite.
2141
+ def rewritten_element(call_node, current_scope)
2142
+ counterpart = RewriteMutation::TYPED_COUNTERPARTS[call_node.name]
2143
+ return nil if counterpart.nil?
2144
+ return nil if counterpart != :flatten && call_node.block.nil? # cost guard
2145
+ return nil unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) # cost guard
2146
+ return nil unless ReceiverAlias.mutated_reads(call_node.receiver).size == 1
2147
+
2148
+ RewriteMutation.counterpart_element(operand_scope.type_of(call_node.copy(name: counterpart)))
2149
+ end
2150
+
2151
+ # `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
2152
+ def mutator_arg_types(call_node, current_scope)
2153
+ name = call_node.name
2154
+ unless ContentJoin::CONTENT_ADDERS.include?(name) || StringMutation::TRANSLATORS.include?(name) ||
2155
+ SetMutation::CONTENT_ADDERS.include?(name)
2156
+ return MutationWidening::NO_ARG_TYPES
2157
+ end
2158
+ unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
2159
+ ElementReadWidening.joinable_element_read?(call_node.receiver, current_scope)
2160
+ return MutationWidening::NO_ARG_TYPES
2161
+ end
2162
+
2163
+ content_arg_types(call_node, operand_scope, @operand_types)
2164
+ end
2165
+
2166
+ # The index node of an index-write when it holds exactly one index argument — the only form
2167
+ # whose stored value lands on a nameable slot (`a[k]`). Multi-index forms address a splice
1186
2168
  # region and answer `nil`.
1187
2169
  def single_index_argument(node)
1188
2170
  args = node.arguments
@@ -1250,14 +2232,17 @@ module Rigor
1250
2232
  [scope.type_of(node, tracer: tracer), forget_rebound_specials(widened, node)]
1251
2233
  end
1252
2234
 
1253
- # The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, and
1254
- # the receiver-wide drop of recorded `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
2235
+ # The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, the
2236
+ # drop of every method-chain narrowing rooted at the receiver that `eval_call` makes after any call on it (issue
2237
+ # #2693: `x.r ||= v`, `x.r, y = …` and `for x.r in …` call `r=`), and the receiver-wide drop of recorded
2238
+ # `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
1255
2239
  def widen_attribute_write(receiver, writer, current_scope)
1256
2240
  widened = MutationWidening.widen_receiver_aliases(receiver, writer, current_scope)
1257
2241
  stable = IndexedNarrowing.stable_receiver(receiver)
1258
- return widened unless stable && IndexedNarrowing.mutator?(writer)
2242
+ return widened unless stable
1259
2243
 
1260
- widened.without_indexed_narrowings_for(*stable)
2244
+ widened = widened.without_method_chain_narrowings_for(*stable)
2245
+ IndexedNarrowing.mutator?(writer) ? widened.without_indexed_narrowings_for(*stable) : widened
1261
2246
  end
1262
2247
 
1263
2248
  # The attribute targets of a multi-write (`h.default, x = 0, 1`), nested ones included, each widening its
@@ -1337,11 +2322,7 @@ module Rigor
1337
2322
  return [Type::Combinator.union(then_type, else_type), then_scope] \
1338
2323
  if branch_terminates?(node.subsequent, else_type) && node.statements
1339
2324
 
1340
- [
1341
- Type::Combinator.union(then_type, else_type),
1342
- UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1343
- [node.statements, node.subsequent])
1344
- ]
2325
+ if_join(node.statements, node.subsequent, [then_type, then_scope], [else_type, else_scope], post_pred)
1345
2326
  end
1346
2327
 
1347
2328
  # `unless pred; t; else; e; end`. Same shape as `if`, but Prism exposes the else-branch as `else_clause` (no elsif
@@ -1373,11 +2354,22 @@ module Rigor
1373
2354
  return [Type::Combinator.union(then_type, else_type), then_scope] \
1374
2355
  if branch_terminates?(node.else_clause, else_type) && node.statements
1375
2356
 
1376
- [
1377
- Type::Combinator.union(then_type, else_type),
1378
- UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1379
- [node.statements, node.else_clause])
1380
- ]
2357
+ if_join(node.statements, node.else_clause, [then_type, then_scope], [else_type, else_scope], post_pred)
2358
+ end
2359
+
2360
+ # The `[type, post_scope]` an `if` / `unless` with both branches evaluated ends with, once the arms above found no
2361
+ # terminating branch by {#branch_terminates?}.
2362
+ #
2363
+ # Issue #1230 — the then-body (`then_node`) certainly exits beside an else (`else_node`): the only surviving path
2364
+ # is the else that RAN, so the continuation is the else's exit scope, the predicate's other edge PLUS the else's
2365
+ # writes, not a join with the exited body's writes. {#branch_certainly_exits?} is stricter than
2366
+ # {#branch_terminates?}, which the arms above use: a `bot`-typed helper call does not qualify (issue #3110).
2367
+ # Otherwise both exit scopes join with nil-injection.
2368
+ def if_join(then_node, else_node, (then_type, then_scope), (else_type, else_scope), post_pred)
2369
+ type = Type::Combinator.union(then_type, else_type)
2370
+ return [type, else_scope] if else_node && branch_certainly_exits?(then_node)
2371
+
2372
+ [type, UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred, [then_node, else_node])]
1381
2373
  end
1382
2374
 
1383
2375
  # Returns the `[type, post_scope]` of the live branch when the predicate is provably truthy / falsey, else nil so
@@ -1440,7 +2432,7 @@ module Rigor
1440
2432
  sub_eval(node.statements, scope)
1441
2433
  end
1442
2434
 
1443
- # `case pred; when ...; when ...; else; end` and the pattern- matching variant. The predicate's post-scope is
2435
+ # `case pred; when ...; when ...; else; end` and the pattern-matching variant. The predicate's post-scope is
1444
2436
  # shared with every branch (including the else); branches are evaluated independently and merged with
1445
2437
  # nil-injection so half-bound names degrade to `T | nil`.
1446
2438
  def eval_case(node)
@@ -1646,6 +2638,11 @@ module Rigor
1646
2638
  InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1647
2639
  when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
1648
2640
  false
2641
+ when Prism::GlobalVariableReadNode
2642
+ # Issue #2314 — the question the statement walk asks past a call ({#forget_reached_globals}).
2643
+ body = when_lambda_body(condition)
2644
+ GlobalWriterReach.may_write?(condition, name, before) ||
2645
+ (!body.nil? && GlobalWriterReach.may_write?(body, name, before))
1649
2646
  else
1650
2647
  body = when_lambda_body(condition)
1651
2648
  GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
@@ -1757,9 +2754,12 @@ module Rigor
1757
2754
  # while it ran, put back as this scope binds them ({ErrorInfo.restore}): the control those carry has left every
1758
2755
  # rescue clause the construct entered.
1759
2756
  def restoring_error_info
1760
- marks = [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
2757
+ state = typing_state
2758
+ marks = [@next_scope_sink&.size, state[BREAK_SINK]&.size]
1761
2759
  type, after = yield
1762
- [[@next_scope_sink, marks.first], [Thread.current[BREAK_SINK_KEY], marks.last]].each do |sink, mark|
2760
+ break_sink = state[BREAK_SINK]
2761
+ state.touch(BREAK_SINK) if break_sink && marks.last && marks.last < break_sink.size
2762
+ [[@next_scope_sink, marks.first], [break_sink, marks.last]].each do |sink, mark|
1763
2763
  next if sink.nil? || mark.nil?
1764
2764
 
1765
2765
  (mark...sink.size).each do |index|
@@ -1782,9 +2782,12 @@ module Rigor
1782
2782
  # after it, or a rescue clause, may have set `$_` enters with it forgotten; a rescue clause runs after any
1783
2783
  # prefix of the body, and so reads it forgotten whenever the body may set it.
1784
2784
  entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
1785
- primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge)
1786
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry),
1787
- edge: edge, writes: rescue_writes(node, edge))
2785
+ rescue_entry = rescue_entry_scope(node, entry)
2786
+ collector = begin_fact_collector(node, rescue_entry, edge)
2787
+ primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge, collector: collector)
2788
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry,
2789
+ edge: edge, writes: rescue_writes(node, edge),
2790
+ raise_scopes: collector&.raise_scopes)
1788
2791
 
1789
2792
  # B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
1790
2793
  # primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
@@ -1880,7 +2883,10 @@ module Rigor
1880
2883
  # none, so an `ensure` clause inside it, nested at any depth, is evaluated once rather than once per pass of
1881
2884
  # every enclosing clause holding a jump.
1882
2885
  def jump_sinks_installed?
1883
- !@next_scope_sink.nil? || JUMP_SINK_KEYS.any? { |key| Thread.current[key] }
2886
+ return true unless @next_scope_sink.nil?
2887
+
2888
+ state = typing_state
2889
+ JUMP_SINKS.any? { |slot| state[slot] }
1884
2890
  end
1885
2891
 
1886
2892
  # Whether `node` holds a `return`, `next` or `break` outside a nested `def`.
@@ -1936,12 +2942,8 @@ module Rigor
1936
2942
  end
1937
2943
 
1938
2944
  # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
1939
- def without_jump_sinks
1940
- previous = CLAUSE_SINK_KEYS.map { |key| Thread.current[key] }
1941
- CLAUSE_SINK_KEYS.each { |key| Thread.current[key] = nil }
1942
- yield
1943
- ensure
1944
- CLAUSE_SINK_KEYS.zip(previous).each { |key, value| Thread.current[key] = value } if previous
2945
+ def without_jump_sinks(&)
2946
+ TypingState.current.without(CLAUSE_SINKS, &)
1945
2947
  end
1946
2948
 
1947
2949
  # The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
@@ -1950,6 +2952,65 @@ module Rigor
1950
2952
  forget_guard_if_rebinds(LastLine.forget_if_set(entry, node.statements), node.statements)
1951
2953
  end
1952
2954
 
2955
+ # Issue #2691 — a rescue clause runs after any prefix of the `begin` body raised, so a fact the clause's
2956
+ # entry scope holds for an indexed read (`h[:k]`) or a single-hop chain (`x.r`) survives into it only where
2957
+ # EVERY scope the body can raise from still binds the identical type object. `raise_scopes` is that set —
2958
+ # the post-scope of each statement of the body's frame and the scope it ends with
2959
+ # ({#eval_begin_primary_under}'s collector) — not the fall-through exit alone: `eval_if` and kin discard a
2960
+ # branch that terminates, so a write on `if c; @x.r = nil; raise; end` never reaches the exit scope.
2961
+ # `equal?` keeps only an untouched fact: a re-narrowed or re-joined one is a new object and drops (the
2962
+ # conservative side).
2963
+ #
2964
+ # The residual is a fact the body drops and then re-establishes as the identical (interned) type object:
2965
+ # raise points after the re-establishment keep it, which is correct, and the only unsound window would be a
2966
+ # raise point whose recorded scope still binds the object although the fact was invalidated and not yet
2967
+ # re-established. No producer reaches that today — a rebind or a clause widening answers `Dynamic[top]`,
2968
+ # never the fact's type ({ClauseWidening}), and a widened slot read floors to `Dynamic` rather than
2969
+ # reinstalling the recorded object.
2970
+ #
2971
+ # The filter covers rescue entries only: an `ensure` clause enters from the scope past the `begin` — the
2972
+ # join of the fall-through exits — so it keeps a fact a terminating-branch write voided, the pre-#2691
2973
+ # hole left open because collecting raise points for every `ensure`-only `begin` costs a frame walk.
2974
+ def clause_fact_scope(entry_scope, raise_scopes)
2975
+ return entry_scope if entry_scope.indexed_narrowings.empty? && entry_scope.method_chain_narrowings.empty?
2976
+
2977
+ indexed = entry_scope.indexed_narrowings.reduce(entry_scope) do |acc, (key, type)|
2978
+ if raise_scopes.all? { |point| point.indexed_narrowings[key].equal?(type) }
2979
+ acc
2980
+ else
2981
+ acc.without_indexed_narrowing(key.receiver_kind, key.receiver_name, key.key)
2982
+ end
2983
+ end
2984
+ indexed.method_chain_narrowings.reduce(indexed) do |acc, (key, type)|
2985
+ if raise_scopes.all? { |point| point.method_chain_narrowings[key].equal?(type) }
2986
+ acc
2987
+ else
2988
+ acc.without_method_chain_narrowing(key.receiver_kind, key.receiver_name, key.method_name)
2989
+ end
2990
+ end
2991
+ end
2992
+
2993
+ # Issue #2691 — the {BodyRaiseScopes} {#eval_begin_primary_under} fills while evaluating `node`'s primary
2994
+ # body, or nil when {#clause_fact_scope} has nothing to filter: no rescue clause, no body, or a `clause_entry`
2995
+ # (the scope the chain enters with — `rescue_entry_scope`'s result, or the retry-widened entry under an
2996
+ # edge) holding no indexed or method-chain fact. The frame walk is skipped unless a fact could drop, so a
2997
+ # `begin` whose entry binds none pays nothing.
2998
+ def begin_fact_collector(node, clause_entry, edge)
2999
+ return nil unless node.rescue_clause && node.statements
3000
+ return nil if clause_entry.indexed_narrowings.empty? && clause_entry.method_chain_narrowings.empty?
3001
+
3002
+ frame = edge ? edge.frame : primary_body_frame(node.statements)
3003
+ BodyRaiseScopes.new(frame: frame, raise_scopes: [])
3004
+ end
3005
+
3006
+ # The set {#walk_primary_frame} collects for a primary body — the nodes whose post-statement scopes are the
3007
+ # body's raise points — without the write-name pass a retry edge also needs.
3008
+ def primary_body_frame(statements)
3009
+ frame = Set.new.compare_by_identity
3010
+ walk_primary_frame(statements, true, frame, Set.new)
3011
+ frame
3012
+ end
3013
+
1953
3014
  # Rescue arms that never fall through contribute neither a type fragment NOR a scope to the post-begin flow —
1954
3015
  # control left the `begin` via that arm. That is an arm ending in `return`, `next`, `break`, `raise`, `throw`,
1955
3016
  # `exit`, `abort` or `fail`, and one whose type is `bot` ({#branch_terminates?}): an arm ending in `retry`, or in
@@ -1968,7 +3029,7 @@ module Rigor
1968
3029
  def ensure_jump_marks(node)
1969
3030
  return nil unless node.ensure_clause
1970
3031
 
1971
- [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
3032
+ [@next_scope_sink&.size, typing_state[BREAK_SINK]&.size]
1972
3033
  end
1973
3034
 
1974
3035
  # A `next` or `break` inside `begin … ensure` leaves only once the `ensure` clause has run, so each jump scope the
@@ -1978,7 +3039,10 @@ module Rigor
1978
3039
  def carry_jumps_through_ensure(ensure_clause, marks)
1979
3040
  next_mark, break_mark = marks
1980
3041
  carry_through_ensure(ensure_clause, @next_scope_sink, next_mark)
1981
- carry_through_ensure(ensure_clause, Thread.current[BREAK_SINK_KEY], break_mark)
3042
+ state = typing_state
3043
+ break_sink = state[BREAK_SINK]
3044
+ state.touch(BREAK_SINK) if break_sink && break_mark && break_mark < break_sink.size
3045
+ carry_through_ensure(ensure_clause, break_sink, break_mark)
1982
3046
  end
1983
3047
 
1984
3048
  def carry_through_ensure(ensure_clause, sink, mark)
@@ -1997,13 +3061,23 @@ module Rigor
1997
3061
  # `edge`, when given, collects every scope the primary body could raise from: the scope after each statement of
1998
3062
  # its frame ({#record_raise_points}), and the scope it ends with. The else-clause is not among them: what it
1999
3063
  # raises is not rescued here.
2000
- def eval_begin_primary_under(node, entry_scope, edge: nil)
3064
+ #
3065
+ # `collector`, when given, is a {BodyRaiseScopes} the same recording fills for {#clause_fact_scope} — built
3066
+ # only when the clause entry still holds an indexed or method-chain fact a raise-point write could
3067
+ # invalidate ({#begin_fact_collector}). A retry `edge` collects the same scopes for its own widening; the
3068
+ # collector is separate so each evaluation pass filters against exactly its own raise points.
3069
+ #
3070
+ # Returns `[type, scope]`: the construct's value and exit scope (the else-clause's when present). The scope the
3071
+ # primary body alone ended with is recorded as a raise point before the `else` runs — the `else` runs only when
3072
+ # nothing raised, so its effects are no raise point's.
3073
+ def eval_begin_primary_under(node, entry_scope, edge: nil, collector: nil)
2001
3074
  body_type, body_scope =
2002
3075
  if node.statements
2003
- with_raise_frame(edge) { sub_eval(node.statements, entry_scope) }
3076
+ with_raise_frame(edge, collector) { sub_eval(node.statements, entry_scope) }
2004
3077
  else
2005
3078
  [Type::Combinator.constant_of(nil), entry_scope]
2006
3079
  end
3080
+ collector.raise_scopes << body_scope if collector
2007
3081
  edge.raise_scopes << body_scope if edge
2008
3082
 
2009
3083
  if node.else_clause
@@ -2027,6 +3101,13 @@ module Rigor
2027
3101
  end
2028
3102
  private_constant :RetryEdge
2029
3103
 
3104
+ # Issue #2691 — the raise-point scopes one evaluation of a `begin` primary body collects for
3105
+ # {#clause_fact_scope}: {RetryEdge}'s `frame` / `raise_scopes` pair without the retry bookkeeping, so
3106
+ # {#record_raise_points} records into it exactly as into an edge. `frame` is the body's own frame — shared
3107
+ # with the retry edge when one exists, since both walks collect the same nodes.
3108
+ BodyRaiseScopes = Data.define(:frame, :raise_scopes)
3109
+ private_constant :BodyRaiseScopes
3110
+
2030
3111
  # The entry and edge one widening reads, whether it widens to the Nominal envelope or keeps literals, and, per
2031
3112
  # name, the rebinds it has already weighed: a statement-by-statement body shares one binding object across every
2032
3113
  # scope until the name is rebound, and weighing each copy again is what made a long body quadratic.
@@ -2035,17 +3116,29 @@ module Rigor
2035
3116
  end
2036
3117
  private_constant :RetryWidening
2037
3118
 
2038
- # The thread-local stack of the {RetryEdge}s whose primary body is being evaluated, innermost last, or nil.
2039
- RETRY_FRAMES_KEY = :rigor_retry_frames
2040
- private_constant :RETRY_FRAMES_KEY
3119
+ # The thread-local stack of the raise-point frames whose primary body is being evaluated, innermost last, or
3120
+ # nil: a {RetryEdge} and/or a {BodyRaiseScopes}. The
3121
+ # frozen stack is the Frame; a statement's raise point recorded into a frame is a Resource touch
3122
+ # ({#record_raise_points}).
3123
+ RETRY_FRAMES = TypingState.frame(
3124
+ :retry_frames,
3125
+ contents: :resource, reason: "installed per retrying body; each raise point records into it",
3126
+ witness: "spec/fixtures/typing_state_witnesses/retry_frames.rb"
3127
+ )
3128
+ private_constant :RETRY_FRAMES
2041
3129
 
2042
3130
  # The thread-local sinks a widened clause pass ({#without_jump_sinks}) leaves uninstalled.
2043
- CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
2044
- private_constant :CLAUSE_SINK_KEYS
3131
+ CLAUSE_SINKS = [RETURN_SINK, NEXT_SINK, BREAK_SINK, BREAK_VALUE_SINK, RETRY_FRAMES].freeze
3132
+ private_constant :CLAUSE_SINKS
2045
3133
 
2046
3134
  # The thread-local sinks a `return`, `next` or `break` is collected into.
2047
- JUMP_SINK_KEYS = (CLAUSE_SINK_KEYS - [RETRY_FRAMES_KEY]).freeze
2048
- private_constant :JUMP_SINK_KEYS
3135
+ JUMP_SINKS = (CLAUSE_SINKS - [RETRY_FRAMES]).freeze
3136
+ private_constant :JUMP_SINKS
3137
+
3138
+ # The sinks a caller's construct installs and a callee's body must not record into: a `next`, `break` or raise
3139
+ # point in the callee belongs to the callee's own constructs, never to the caller's block, loop or retrying body.
3140
+ CALLER_SINKS = [NEXT_SINK, BREAK_SINK, BREAK_VALUE_SINK, RETRY_FRAMES].freeze
3141
+ private_constant :CALLER_SINKS
2049
3142
 
2050
3143
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
2051
3144
  # no `retry` targets.
@@ -2123,8 +3216,11 @@ module Rigor
2123
3216
  end
2124
3217
 
2125
3218
  def eval_begin_paths(node, entry, edge)
2126
- primary = eval_begin_primary_under(node, entry, edge: edge)
2127
- [*primary, collect_rescue_chain_results(node.rescue_clause, entry, edge: edge)]
3219
+ collector = begin_fact_collector(node, entry, edge)
3220
+ primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge, collector: collector)
3221
+ [primary_type, primary_scope,
3222
+ collect_rescue_chain_results(node.rescue_clause, entry,
3223
+ edge: edge, raise_scopes: collector&.raise_scopes)]
2128
3224
  end
2129
3225
 
2130
3226
  # B2.1 — the entry scope widened by what crosses the retry edge, or nil when nothing does. A local or ivar bound
@@ -2149,28 +3245,33 @@ module Rigor
2149
3245
  widened
2150
3246
  end
2151
3247
 
2152
- # Runs `block` with `edge` on top of the thread-local stack {#record_raise_points} reads.
2153
- def with_raise_frame(edge)
2154
- return yield unless edge
3248
+ # Runs `block` with `frames` — a retry edge and/or a {BodyRaiseScopes}, nils skipped — on top of the
3249
+ # thread-local stack {#record_raise_points} reads.
3250
+ def with_raise_frame(first, second, &)
3251
+ return yield if first.nil? && second.nil?
2155
3252
 
2156
- previous = Thread.current[RETRY_FRAMES_KEY]
2157
- Thread.current[RETRY_FRAMES_KEY] = previous ? [*previous, edge] : [edge]
2158
- begin
2159
- yield
2160
- ensure
2161
- Thread.current[RETRY_FRAMES_KEY] = previous
2162
- end
3253
+ frames = first && second ? [first, second] : [first || second]
3254
+ state = TypingState.current
3255
+ previous = state[RETRY_FRAMES]
3256
+ state.with(RETRY_FRAMES, (previous ? previous + frames : frames).freeze, &)
2163
3257
  end
2164
3258
 
2165
- # The scope after each statement of a retrying primary body's frame is a point the body can raise from, the next
2166
- # statement's being the one after. Together they carry every rebind a retry can re-enter with, including one on a
2167
- # branch that then raises and so never reaches the body's exit scope (`if bad; tries += 1; raise; end`), and a
2168
- # write threaded into the raising call's own operands (`raise Retry.new(tries += 1) if flaky?`). A statement of a
2169
- # nested block or lambda body is not in the frame (a block parameter can shadow the counter); the block's effect
3259
+ # The scope after each statement of a primary body's frame is a point the body can raise from, the next
3260
+ # statement's being the one after — into every frame on the stack: a retrying begin's edge, which carries
3261
+ # every rebind a retry can re-enter with, and a {BodyRaiseScopes}, which {#clause_fact_scope} intersects
3262
+ # over. Both see a rebind on a branch that then raises and so never reaches the body's exit scope
3263
+ # (`if bad; tries += 1; raise; end`), and a write threaded into the raising call's own operands
3264
+ # (`raise Retry.new(tries += 1) if flaky?`). A statement of a nested block or lambda body is not in the
3265
+ # frame (a block parameter can shadow the counter); the block's effect
2170
3266
  # on this frame shows in the post-scope of the statement holding it. Recording from the evaluator rather than
2171
3267
  # `on_enter` keeps a statement reached with the index recorder off (a threaded operand, a loop fixpoint pass).
2172
3268
  def record_raise_points(frames, stmt, stmt_scope)
2173
- frames.each { |edge| edge.raise_scopes << stmt_scope if edge.frame.include?(stmt) }
3269
+ frames.each do |collector|
3270
+ next unless collector.frame.include?(stmt)
3271
+
3272
+ typing_state.touch(RETRY_FRAMES)
3273
+ collector.raise_scopes << stmt_scope
3274
+ end
2174
3275
  end
2175
3276
 
2176
3277
  # An `on_enter` that records, besides forwarding to the installed one, the entry scope of each `retry` of `edge`
@@ -2327,7 +3428,14 @@ module Rigor
2327
3428
  # Issue #1231 — `writes` widens the scope each clause enters with ({#widen_clause_entry}) before its
2328
3429
  # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2329
3430
  # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2330
- def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
3431
+ # `raise_scopes`, when given, is every scope the primary body can raise from — the post-scope of each
3432
+ # statement of its frame and the scope it ends with ({#eval_begin_primary_under}'s collector): the rescue
3433
+ # entry keeps only the indexed and method-chain facts every one of them still binds verbatim
3434
+ # ({#clause_fact_scope}).
3435
+ def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil, raise_scopes: nil)
3436
+ return [] if rescue_node.nil?
3437
+
3438
+ entry_scope = clause_fact_scope(entry_scope, raise_scopes) if raise_scopes
2331
3439
  on_enter = edge ? retry_scope_recorder(edge) : @on_enter
2332
3440
  results = []
2333
3441
  current = rescue_node
@@ -2375,7 +3483,7 @@ module Rigor
2375
3483
  # True when the clause's scope `after` still binds `$_` to the `Dynamic[top]` the entry gave it and the clause
2376
3484
  # holds nothing that may set it ({LastLine.may_set?}).
2377
3485
  def last_line_kept?(after, node)
2378
- after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
3486
+ Type::Combinator.untyped?(after.global(:$_)) && !LastLine.may_set?(node.statements, scope)
2379
3487
  end
2380
3488
 
2381
3489
  # Issue #1889 — {#eval_for} with a literal-name `instance_variable_set` in the body joining its value into the
@@ -2649,15 +3757,9 @@ module Rigor
2649
3757
  # Installs a fresh thread-local break sink around `yield` (a loop-body evaluation), returning `[collected,
2650
3758
  # yield_result]`. Stacks: the previous sink is restored on exit so a nested loop's breaks do not leak to the
2651
3759
  # enclosing loop.
2652
- def collect_break_scopes
2653
- previous = Thread.current[BREAK_SINK_KEY]
3760
+ def collect_break_scopes(&)
2654
3761
  sink = []
2655
- Thread.current[BREAK_SINK_KEY] = sink
2656
- begin
2657
- result = yield
2658
- ensure
2659
- Thread.current[BREAK_SINK_KEY] = previous
2660
- end
3762
+ result = TypingState.current.with(BREAK_SINK, sink, &)
2661
3763
  [sink, result]
2662
3764
  end
2663
3765
 
@@ -2734,7 +3836,7 @@ module Rigor
2734
3836
  # from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
2735
3837
  # caller's test on it folded always-truthy on correct code.
2736
3838
  def later_pass_returns?(statements)
2737
- !Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
3839
+ !typing_state[RETURN_SINK].nil? && ReturnBarrier.exits_method?(statements)
2738
3840
  end
2739
3841
 
2740
3842
  # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
@@ -2746,12 +3848,11 @@ module Rigor
2746
3848
  def join_break_scopes(continuation, breaks, names)
2747
3849
  return continuation if breaks.empty? || names.empty?
2748
3850
 
2749
- floor = Type::Combinator.untyped
2750
3851
  breaks.reduce(continuation) do |cont, break_scope|
2751
3852
  names.reduce(cont) do |acc, name|
2752
3853
  break_value = CapturedLocals.bound_type(break_scope, name)
2753
3854
  current = CapturedLocals.bound_type(acc, name)
2754
- next acc if break_value.nil? || current == floor
3855
+ next acc if break_value.nil? || Type::Combinator.untyped?(current)
2755
3856
 
2756
3857
  bind_capture(acc, name, current ? Type::Combinator.union(current, break_value) : break_value)
2757
3858
  end
@@ -3068,7 +4169,9 @@ module Rigor
3068
4169
  types = [coll_type]
3069
4170
  name = for_collection_variable(node)
3070
4171
  types << CapturedLocals.bound_type(continuation, name) if name
3071
- deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index))
4172
+ deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index)) do
4173
+ DeepErasure.holds_set?(coll_type)
4174
+ end
3072
4175
  Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
3073
4176
  end
3074
4177
 
@@ -3133,14 +4236,14 @@ module Rigor
3133
4236
  forget_guard_if_rebinds(body_entry, node.statements)
3134
4237
  end
3135
4238
 
3136
- # `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
3137
- # block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
3138
- # structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
4239
+ # `for x in coll` is semantically `coll.each { |x| ... }`. The structural `collection_element_type` answers first;
4240
+ # only when it is untyped do we ask the method dispatcher for `coll.each`'s expected block parameter types, which
4241
+ # consult RBS and the iterator dispatch table (they know, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
3139
4242
  # the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
3140
- # signature, unknown receiver) we fall back to the structural extractor.
4243
+ # signature, unknown receiver) the structural answer stands.
3141
4244
  def for_iteration_element_type(coll_type)
3142
4245
  structural = collection_element_type(coll_type)
3143
- return structural unless structural.equal?(Type::Combinator.untyped)
4246
+ return structural unless Type::Combinator.untyped?(structural)
3144
4247
 
3145
4248
  block_params = MethodDispatcher.expected_block_param_types(
3146
4249
  receiver_type: coll_type,
@@ -3174,13 +4277,17 @@ module Rigor
3174
4277
  scope.with_ivar(index_node.name, element_type).without_ivar_guard(index_node.name)
3175
4278
  when Prism::GlobalVariableTargetNode
3176
4279
  scope.with_global(index_node.name, element_type)
4280
+ when Prism::ClassVariableTargetNode
4281
+ scope.with_cvar(index_node.name, element_type)
3177
4282
  when Prism::IndexTargetNode
3178
4283
  widen_index_target(index_node, element_type, scope, type_scope: scope)
4284
+ when Prism::CallTargetNode
4285
+ widen_attribute_write(index_node.receiver, index_node.name, scope)
3179
4286
  when Prism::MultiTargetNode
3180
4287
  bound = MultiTargetBinder.bind_marked(index_node, element_type, scope: scope)
3181
- widen_index_targets(bound, bound.apply_to(scope), type_scope: scope)
4288
+ widen_attribute_targets(index_node, widen_index_targets(bound, bound.apply_to(scope), type_scope: scope))
3182
4289
  when Prism::SplatNode
3183
- bind_for_splat_index(index_node, scope)
4290
+ bind_for_splat_index(index_node, element_type, scope)
3184
4291
  else
3185
4292
  scope
3186
4293
  end
@@ -3188,18 +4295,34 @@ module Rigor
3188
4295
 
3189
4296
  # `for *h[:a] in pairs` — Prism gives a bare splat index as a `SplatNode`, not a `MultiTargetNode`, so the
3190
4297
  # binder never sees it. The store is `*h[:a] = element`, an array of the element's `to_ary` parts; the receiver
3191
- # widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. A bare `*name` target stays
3192
- # unbound here, as before.
3193
- def bind_for_splat_index(splat, scope)
4298
+ # widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. `for *x.r in pairs` calls `r=`
4299
+ # and widens as `for x.r in` does (issue #2693).
4300
+ #
4301
+ # Issue #1217 — a bare `*a` / `*@a` / `*@@a` / `*$a` binds the name to the rest
4302
+ # {MultiTargetBinder.decompose_slots} reads off the element: a `Tuple` element exactly, an `Array[T]` element as
4303
+ # `Array[T]`, and anything else — a union, a value Ruby would wrap as `[element]` — the `Dynamic[top]` floor.
4304
+ # Left unbound, a read after the loop folded on the pre-loop binding.
4305
+ def bind_for_splat_index(splat, element_type, scope)
3194
4306
  target = splat.expression
3195
- return scope unless target.is_a?(Prism::IndexTargetNode)
3196
-
3197
- widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
4307
+ case target
4308
+ when Prism::CallTargetNode
4309
+ widen_attribute_write(target.receiver, target.name, scope)
4310
+ when Prism::IndexTargetNode
4311
+ widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
4312
+ when Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
4313
+ Prism::ClassVariableTargetNode, Prism::GlobalVariableTargetNode
4314
+ _, rest, = MultiTargetBinder.decompose_slots(
4315
+ element_type, front_count: 0, back_count: 0, rest_present: true, scope: scope
4316
+ )
4317
+ bind_for_index(target, rest, scope)
4318
+ else
4319
+ scope
4320
+ end
3198
4321
  end
3199
4322
 
3200
4323
  # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
3201
4324
  # elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
3202
- # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
4325
+ # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; a `Range` constant reads the
3203
4326
  # literal range's element class. Anything else falls back to `untyped`.
3204
4327
  def collection_element_type(type)
3205
4328
  case type
@@ -3500,8 +4623,9 @@ module Rigor
3500
4623
 
3501
4624
  # `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
3502
4625
  # class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
3503
- # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
3504
- # the existing context and accept that nested defs degrade to the `Dynamic[Top]` default.
4626
+ # rather than `instance_method`. For an expression that is neither `self` nor a constant we cannot statically
4627
+ # resolve the receiver, so we keep the existing context and accept that nested defs degrade to the `Dynamic[Top]`
4628
+ # default.
3505
4629
  # Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
3506
4630
  # `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
3507
4631
  # `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
@@ -3530,13 +4654,9 @@ module Rigor
3530
4654
  # false positives in `prism-1.9.0`'s auto-generated `copy` methods alone. A nested `def` is a return barrier:
3531
4655
  # its body's `return`s belong to the inner method, not the one currently being inferred. Suspend the return sink
3532
4656
  # across the nested body so `eval_return` does not record them into the outer method's return type.
3533
- outer_sink = Thread.current[RETURN_SINK_KEY]
3534
- Thread.current[RETURN_SINK_KEY] = nil
3535
- begin
4657
+ typing_state.with(RETURN_SINK, nil) do
3536
4658
  sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
3537
4659
  sub_eval(node.body, body_scope, class_context: @class_context) if node.body
3538
- ensure
3539
- Thread.current[RETURN_SINK_KEY] = outer_sink
3540
4660
  end
3541
4661
  [Type::Combinator.constant_of(node.name), scope]
3542
4662
  end
@@ -3567,11 +4687,9 @@ module Rigor
3567
4687
  invoked = call_operand_scope(node, walk, scope)
3568
4688
  invoked = forget_operand_specials(node, invoked)
3569
4689
  operand_types = walk.types(tracer)
4690
+ # The `each_with_object` memo widening (ADR-56 slice C, B3) is part of the call's type, applied by
4691
+ # `ExpressionTyper#call_type_for` ({#each_with_object_value}).
3570
4692
  call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
3571
- # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
3572
- # types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
3573
- # block-param and adopt it as the call's return type.
3574
- call_type = each_with_object_return(node, call_type)
3575
4693
  [call_type, invoke_from(node, invoked, call_type, operand_types)]
3576
4694
  end
3577
4695
 
@@ -3760,8 +4878,7 @@ module Rigor
3760
4878
  # B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
3761
4879
  # of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
3762
4880
  # binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
3763
- # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3764
- # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
4881
+ # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
3765
4882
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
3766
4883
  # Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
3767
4884
  # above, which would join a literal-name write back into the class seed.
@@ -3795,9 +4912,32 @@ module Rigor
3795
4912
  post_scope.forget_guard_narrowings
3796
4913
  end
3797
4914
  end
4915
+ post_scope = forget_reached_globals(node, post_scope)
3798
4916
  forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
3799
4917
  end
3800
4918
 
4919
+ def guard_narrowed_global?(scope, name)
4920
+ scope.guard_narrowed? && scope.guard_records.key?([:global, name])
4921
+ end
4922
+
4923
+ # Issue #2314 — `post_scope` with each global a project method may write rebound to `Dynamic[top]` when the
4924
+ # statement call `node` may reach that method ({GlobalWriterReach.may_write?}): `$g = nil; reset!; $g + 1`,
4925
+ # with `reset!` assigning `$g`, is `2` in Ruby. A global no reachable method writes keeps its binding. A
4926
+ # `case` whose `when` test may rebind a global subject reads it the same way ({#when_test_rebinds_subject?}).
4927
+ # A global a guard narrowed where the operands started keeps the union issue #1429's restore reads instead
4928
+ # (`return unless $sep; puts(reset_sep); $sep.length` reads `$sep` as the union of its seed and `","`).
4929
+ def forget_reached_globals(node, post_scope)
4930
+ names = GlobalWriterReach.candidates(post_scope)
4931
+ return post_scope if names.empty?
4932
+
4933
+ names.reduce(post_scope) do |acc, name|
4934
+ next acc if guard_narrowed_global?(operand_scope, name)
4935
+ next acc unless GlobalWriterReach.may_write?(node, name, operand_scope)
4936
+
4937
+ acc.with_global(name, Type::Combinator.untyped)
4938
+ end
4939
+ end
4940
+
3801
4941
  # True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
3802
4942
  # its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
3803
4943
  # so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
@@ -3917,7 +5057,8 @@ module Rigor
3917
5057
 
3918
5058
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
3919
5059
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3920
- # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
5060
+ # External-receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged, apart from
5061
+ # the literal-block case below.
3921
5062
  # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3922
5063
  #
3923
5064
  # Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
@@ -3925,11 +5066,19 @@ module Rigor
3925
5066
  # calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
3926
5067
  # code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
3927
5068
  # (`@items << yield`, `puts(yield)`) widens every narrowed ivar ({InterveningIvarReset.after_operands}).
5069
+ #
5070
+ # Issue #2039 — an implicit-self or `self.` call marks on the same rule only when its write set is unproven
5071
+ # and its callee's body shows no possibly-nil write of the ivar (`mark: :unproven`, {DirectIvarWrites}):
5072
+ # `v2?`, an `attr_reader` call or `respond_to?(:x)` forgets the narrowing without making the ivar newly
5073
+ # nil-reportable, while a callee that assigns it where the program shows (`clear_tag`) widens unmarked.
3928
5074
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3929
5075
  widened =
3930
5076
  if intervening_call_candidate?(call_node)
3931
- mark = !InterveningIvarReset.self_call?(call_node)
3932
- InterveningIvarReset.widen(current_scope, mark: mark) { self_call_ivar_writes(call_node) }
5077
+ mark = InterveningIvarReset.self_call?(call_node) ? :unproven : true
5078
+ direct_writes = -> { DirectIvarWrites.of(call_node, operand_scope) }
5079
+ InterveningIvarReset.widen(current_scope, mark: mark, direct_writes: direct_writes) do
5080
+ self_call_ivar_writes(call_node)
5081
+ end
3933
5082
  else
3934
5083
  InterveningIvarReset.after_block(call_node, current_scope)
3935
5084
  end
@@ -4119,7 +5268,7 @@ module Rigor
4119
5268
  # `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
4120
5269
  # method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
4121
5270
  # cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
4122
- # plugin-recognised calls) lives behind a follow-up slice that introduces `:local` / `:argument_at` target kinds.
5271
+ # plugin-recognised calls) lives behind a follow-up slice that introduces an `:argument_at` target kind.
4123
5272
  def apply_plugin_assertions(call_node, current_scope)
4124
5273
  registry = current_scope.environment&.plugin_registry
4125
5274
  return current_scope if registry.nil? || registry.empty?
@@ -4274,7 +5423,7 @@ module Rigor
4274
5423
  def lookup_post_return_arg(call_node, method_def, target_name)
4275
5424
  # Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
4276
5425
  # directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
4277
- # mapping (`:local` / `:argument_at`) is a follow-up slice.
5426
+ # mapping (`:argument_at`) is a follow-up slice.
4278
5427
  return nil if method_def.nil?
4279
5428
 
4280
5429
  arguments = call_node.arguments&.arguments || []
@@ -4301,8 +5450,15 @@ module Rigor
4301
5450
  narrowed = Narrowing.safe_navigation_block_scope(node, scope)
4302
5451
  return enter_call_block(node, block) if narrowed.equal?(scope)
4303
5452
 
4304
- evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
4305
- .send(:enter_call_block, node, block)
5453
+ narrowed_evaluator = evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
5454
+ narrowed_evaluator.send(:enter_call_block_carrying, node, block, @value_position_block)
5455
+ end
5456
+
5457
+ # The safe-navigation arm of {#evaluate_block_if_present}: the narrowed evaluator enters the block carrying the
5458
+ # value-position mark {#evaluate_block_of} set on this one, which {#block_self_entry} reads.
5459
+ def enter_call_block_carrying(node, block, value_position_block)
5460
+ @value_position_block = value_position_block
5461
+ enter_call_block(node, block)
4306
5462
  end
4307
5463
 
4308
5464
  def enter_call_block(node, block)
@@ -4360,14 +5516,8 @@ module Rigor
4360
5516
  end
4361
5517
 
4362
5518
  # Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
4363
- def without_return_sink
4364
- outer_sink = Thread.current[RETURN_SINK_KEY]
4365
- Thread.current[RETURN_SINK_KEY] = nil
4366
- begin
4367
- yield
4368
- ensure
4369
- Thread.current[RETURN_SINK_KEY] = outer_sink
4370
- end
5519
+ def without_return_sink(&)
5520
+ TypingState.current.with(RETURN_SINK, nil, &)
4371
5521
  end
4372
5522
 
4373
5523
  # Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
@@ -4471,20 +5621,470 @@ module Rigor
4471
5621
  acc = floor_callee_escaped_args_for_call(receiver, acc)
4472
5622
  receiver = receiver.receiver
4473
5623
  end
4474
- acc
5624
+ # Issue #2397 — and every self-call in the call's own non-escaping block.
5625
+ floor_block_callee_captures(node, acc)
4475
5626
  end
4476
5627
 
4477
5628
  # Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
4478
5629
  # before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
4479
- def floor_callee_escaped_args_for_call(node, base_scope)
4480
- arguments = content_mutated_arguments(node)
5630
+ #
5631
+ # Issue #2296 — two more channels follow the bare-carrier floor, each keeping the argument's element type
5632
+ # ({UnknownStoreWidening.callee_block_floor} / {UnknownStoreWidening.callee_set_floor}): a parameter the callee
5633
+ # mutates inside a block (`2.times { |i| acc[i] = v }`), and a Set argument the callee grows through a Set-only
5634
+ # adder (`s.add(x)`, `s.merge(xs)`), which the bare census leaves out because `Hash#merge` does not mutate.
5635
+ #
5636
+ # Issue #2297 — a call may resolve to more than one def ({#resolve_self_callees}); each channel floors through
5637
+ # every one of them before the next channel runs, so a bare-census mutation in any of them still goes first.
5638
+ #
5639
+ # `climbing`, when given, keeps only the argument reads deeper than it: those that climb out of a block that
5640
+ # many levels in ({#floor_block_callee_captures}).
5641
+ def floor_callee_escaped_args_for_call(node, base_scope, climbing: nil)
5642
+ callees = argument_mutating_callees(node)
5643
+ return base_scope if callees.empty?
5644
+
5645
+ acc = floor_callee_channel(node, callees, :callee_content_mutated_parameters, base_scope, climbing) do |type|
5646
+ content_floor_for(type)
5647
+ end
5648
+ acc = floor_splat_arguments(node, callees, acc)
5649
+ # Each closure below is only worth computing when an argument holds something its floor could widen.
5650
+ if floorable_local_argument?(node, acc) { |type| UnknownStoreWidening.callee_floorable?(type) }
5651
+ acc = floor_callee_channel(node, callees, :callee_block_mutated_parameters, acc, climbing) do |type|
5652
+ UnknownStoreWidening.callee_block_floor(type)
5653
+ end
5654
+ end
5655
+ return acc unless floorable_local_argument?(node, acc) { |type| SetMutation.carrier_in?(type) }
5656
+
5657
+ callees.each do |def_node, offset, owner|
5658
+ acc = floor_arguments(node, census_of(owner, :callee_set_mutated_parameters, def_node), acc, offset,
5659
+ climbing) do |type|
5660
+ UnknownStoreWidening.callee_set_floor(type)
5661
+ end
5662
+ end
5663
+ acc
5664
+ end
5665
+
5666
+ # Every callee's `census` channel floors the arguments it names with the block's floor, made gradual when the
5667
+ # floor is an over-approximation ({#callee_gradual_floor}). Only a String result is split; the Set channel and
5668
+ # the container elements a floor widens (#2647) are not.
5669
+ def floor_callee_channel(node, callees, census, base_scope, climbing)
5670
+ several = callees.size > 1
5671
+ acc = base_scope
5672
+ callees.each do |def_node, offset, owner|
5673
+ acc = floor_arguments(node, census_of(owner, census, def_node), acc, offset, climbing) do |type|
5674
+ callee_gradual_floor(yield(type), def_node, owner, several)
5675
+ end
5676
+ end
5677
+ acc
5678
+ end
5679
+
5680
+ # Issue #2397 — the continuation of a `:non_escaping` block whose body hands a captured outer local to a
5681
+ # self-call whose callee mutates it: `acc = [7, 9]; [1].each { poke(acc) }`. The body's own statement floor
5682
+ # rebinds `acc` only inside the block, and neither write-back reads it there (the rebind write-back follows
5683
+ # local writes, the content join follows mutator calls), so the continuation kept the literal and
5684
+ # `acc[0].upcase` reported on correct code.
5685
+ #
5686
+ # Every call the statement `node` makes before it returns is visited: `node` itself and the calls in its
5687
+ # receiver chain and arguments (`[1].each { poke(acc) }.size`, `foo([1].each { poke(acc) })`), never inside a
5688
+ # block or lambda of them. For each one with a `:non_escaping` block not run on another object
5689
+ # ({#foreign_self_block?}), each self-call in the block floors as at a statement, by position or keyword, its
5690
+ # arguments limited to reads that climb out of the block: a block parameter or a nested block's parameter
5691
+ # shadowing the outer name is not the outer local. The body walk ({#each_node_in_method_scope}) leaves out a
5692
+ # lambda and the block of a nested `instance_eval` / `define_method` / …, whose self-calls are not ours.
5693
+ #
5694
+ # Two readings are conservative, since the floor only forgets: a block that may run zero times floors, as the
5695
+ # escaping path ({#floor_block_body_callee_escaped_args}) does, and the floor applies after the block's
5696
+ # write-back, so a rebind later in the body (`poke(acc); acc = [1, 2]`) is floored too.
5697
+ def floor_block_callee_captures(node, post_scope)
5698
+ acc = post_scope
5699
+ each_node_outside_nested_scopes(node) do |call|
5700
+ next unless call.is_a?(Prism::CallNode) && call.block.is_a?(Prism::BlockNode) && call.block.body
5701
+
5702
+ acc = floor_block_body_callee_captures(call, acc)
5703
+ end
5704
+ acc
5705
+ end
5706
+
5707
+ def floor_block_body_callee_captures(call_node, post_scope)
5708
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
5709
+ return post_scope if foreign_self_block?(call_node)
5710
+
5711
+ acc = post_scope
5712
+ each_node_in_caller_block(call_node.block.body, 0) do |node, depth|
5713
+ acc = floor_callee_escaped_args_for_call(node, acc, climbing: depth)
5714
+ end
5715
+ acc
5716
+ end
5717
+
5718
+ # Yields every call under `node` with the number of blocks and lambdas it sits in, for
5719
+ # {#floor_block_callee_captures}. A lambda is entered, as the escaping path floors one: it may be called in the
5720
+ # block (`l.call`, `-> { ... }.call`). A nested def is not. Nor is the block of a nested call whose self-calls
5721
+ # are provably not the caller's: one run on another object ({#foreign_block_call?}), a `define_method` /
5722
+ # `define_singleton_method` method body, or a `Class.new` / `Module.new` / `Struct.new` / `Data.define` block.
5723
+ # An `instance_eval` / `class_exec` / … on any other receiver (none, `self`, an instance) is entered.
5724
+ def each_node_in_caller_block(node, depth, &)
5725
+ return if node.nil?
5726
+
5727
+ yield node, depth if node.is_a?(Prism::CallNode)
5728
+ skip_block = node.is_a?(Prism::CallNode) && foreign_block_call?(node)
5729
+ node.rigor_each_child do |child|
5730
+ next if child.is_a?(Prism::DefNode)
5731
+
5732
+ if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::LambdaNode)
5733
+ each_node_in_caller_block(child, depth + 1, &) unless skip_block && child.is_a?(Prism::BlockNode)
5734
+ else
5735
+ each_node_in_caller_block(child, depth, &)
5736
+ end
5737
+ end
5738
+ end
5739
+
5740
+ def foreign_block_call?(call_node)
5741
+ return false unless self_rebinding_block_call?(call_node)
5742
+ return true if %i[define_method define_singleton_method].include?(call_node.name)
5743
+ return true unless AnonymousMetaClass.block_form_receiver(call_node).nil?
5744
+
5745
+ foreign_self_block?(call_node)
5746
+ end
5747
+
5748
+ # Issue #2515 — the floor for a `*x` argument whose `x` is a local holding an array (`args = [parser, options];
5749
+ # Opts.add_format(*args)`). The hash travels inside the array, so the caller's `options` is the array's element,
5750
+ # not an argument, and the per-parameter floor never sees it. An element the callee writes is floored at its place
5751
+ # in the array's type and in every local bound to an equal type (the element's source: only the type is known, so
5752
+ # an equal literal in another local is floored too).
5753
+ #
5754
+ # A known tuple whose splat sits at a fixed position (only plain positional arguments before it) binds element
5755
+ # `j` to the callee's slot `index - offset + j` and floors it when that slot is in the channel's census. Any other
5756
+ # case floors every element when the callee writes any positional parameter: an array that is no longer a tuple
5757
+ # (it was mutated), a splat after another splat, an `offset` that swallows the splat. An element that reaches the
5758
+ # callee's rest or post parameter floors when the callee may write it ({#rest_parameter_written?}). A splat of
5759
+ # anything but a local (a call result, an ivar) is not followed.
5760
+ def floor_splat_arguments(node, callees, base_scope)
5761
+ arguments = node.arguments.arguments
5762
+ return base_scope unless arguments.any?(Prism::SplatNode)
5763
+
5764
+ fixed = true
5765
+ arguments.each_with_index.reduce(base_scope) do |acc, (argument, index)|
5766
+ next acc unless argument.is_a?(Prism::SplatNode)
5767
+
5768
+ read = argument.expression
5769
+ at = fixed ? index : nil
5770
+ fixed = false
5771
+ next acc unless read.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(read.name)
5772
+
5773
+ floor_splat_local(read.name, at, callees, acc)
5774
+ end
5775
+ end
5776
+
5777
+ # Every callee's channels in turn. The callees are a call's own ({#argument_mutating_callees}), never the nil
5778
+ # sentinel {CAPPED_HAND_OFF}, which only a hand-off inside a def answers ({#rest_hand_off_writes?} reads it).
5779
+ def floor_splat_local(name, at, callees, base_scope)
5780
+ channels = splat_channels
5781
+ origin = base_scope.local(name)
5782
+ callees.reduce(base_scope) do |acc, (def_node, offset, owner)|
5783
+ rest = rest_parameter_written?(def_node, owner)
5784
+ channels.reduce(acc) do |inner, (census, floor)|
5785
+ mutated = census_of(owner, census, def_node)
5786
+ writer = ->(count, position) { splat_written_elements(count, position, offset, def_node, mutated, rest) }
5787
+ floor_splat_arrays(name, at, writer, floor, inner, origin)
5788
+ end
5789
+ end
5790
+ end
5791
+
5792
+ # `base_scope` with the elements of the array local `name` the callee writes floored. The local may be a union
5793
+ # (`cond ? [p, o] : [p, q]`, or an array rebound in a block) and may include nil (`args = [...] if cond`): each
5794
+ # array member floors on its own, and a union of arrays, whose position is unknown, takes the any-slot rule. A
5795
+ # local bound to an equal array type (`args2 = args`) is rebuilt too.
5796
+ def floor_splat_arrays(name, at, writer, floor, base_scope, origin)
5797
+ current = base_scope.local(name)
5798
+ members = current.is_a?(Type::Union) ? current.members : [current]
5799
+ arrays = members.count { |member| splat_elements(member) }
5800
+ return base_scope if arrays.zero?
5801
+
5802
+ origins = splat_origin_members(origin, members)
5803
+ acc = base_scope
5804
+ floored = members.each_with_index.map do |member, index|
5805
+ elements = splat_elements(member)
5806
+ next member if elements.nil?
5807
+
5808
+ position = member.is_a?(Type::Tuple) && arrays == 1 ? at : nil
5809
+ written = writer.call(elements.size, position)
5810
+ next member if written.empty?
5811
+
5812
+ source = splat_elements(origins[index]) || elements
5813
+ rebuilt, acc = floor_array_elements(member, elements, [written, source], floor, acc, name)
5814
+ rebuilt
5815
+ end
5816
+ return acc if floored == members
5817
+
5818
+ rebind_array_aliases(name, current, Type::Combinator.union(*floored), acc)
5819
+ end
5820
+
5821
+ # The members of the array local as it was before the channels ran, aligned with `members`, or `members` itself
5822
+ # when a channel merged or dropped some.
5823
+ def splat_origin_members(origin, members)
5824
+ origin_members = splat_parts(origin)
5825
+ origin_members.size == members.size ? origin_members : members
5826
+ end
5827
+
5828
+ # `scope_in` with `name`, and every local bound to the same array type `before`, rebound to `after`.
5829
+ def rebind_array_aliases(name, before, after, scope_in)
5830
+ aliased = scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
5831
+ other != name && bound == before ? acc.with_mutated_local(other, after) : acc
5832
+ end
5833
+ aliased.with_mutated_local(name, after)
5834
+ end
5835
+
5836
+ def splat_channels
5837
+ [[:callee_content_mutated_parameters, method(:content_floor_for)],
5838
+ [:callee_block_mutated_parameters, UnknownStoreWidening.method(:callee_block_floor)],
5839
+ [:callee_set_mutated_parameters, UnknownStoreWidening.method(:callee_set_floor)]]
5840
+ end
5841
+
5842
+ # The element types of `type` when it is an array a splat spreads, or nil: a tuple's, or a nominal `Array[T]`'s.
5843
+ def splat_elements(type)
5844
+ if type.is_a?(Type::Tuple) then type.elements
5845
+ elsif type.is_a?(Type::Nominal) && type.class_name == "Array" && type.type_args.size == 1 then type.type_args
5846
+ end
5847
+ end
5848
+
5849
+ # The indexes of the `count` array elements the callee writes, for a splat at the call's argument `at` (nil: a
5850
+ # position the call does not show, or an array of unknown length). `rest` is true when the callee may write its
5851
+ # rest parameter ({#rest_parameter_written?}).
5852
+ def splat_written_elements(count, at, offset, def_node, mutated, rest)
5853
+ slots = mutated.values
5854
+ return NO_ELEMENTS unless rest || slots.any? { |slot| slot.is_a?(Integer) || slot == ANY_SLOT }
5855
+ return (0...count).to_a if at.nil? || at < offset || slots.include?(ANY_SLOT)
5856
+
5857
+ named = content_parameter_slots(def_node).each_value.grep(Integer).size
5858
+ (0...count).select do |j|
5859
+ slot = at - offset + j
5860
+ slot >= named ? rest : slots.include?(slot)
5861
+ end
5862
+ end
5863
+
5864
+ NO_ELEMENTS = [].freeze
5865
+ private_constant :NO_ELEMENTS
5866
+
5867
+ # `[array, scope]`: the array type `member` with its `written` elements floored by `floor`, and `scope_in` with
5868
+ # every caller local (other than `name`) the floored elements could be floored too. A union element floors member
5869
+ # by member; every element of the array holding a floored type is rebuilt (`[options, options]`, one slot
5870
+ # written); a local is floored (gradually, {#floor_locals_accepted_by}) when a floored part accepts its type.
5871
+ # Locals are matched against the `source` elements, the array as it was before an earlier channel floored it,
5872
+ # so a floored `Hash[untyped, untyped]` element does not match every hash.
5873
+ def floor_array_elements(member, elements, written_and_source, floor, scope_in, name)
5874
+ written, source = written_and_source
5875
+ acc = scope_in
5876
+ pairs = []
5877
+ written.each do |j|
5878
+ splat_parts(source[j]).each { |part| acc = floor_locals_accepted_by(part, name, floor, acc) }
5879
+ splat_parts(elements[j]).each do |part|
5880
+ replacement = floor.call(part)
5881
+ pairs << [part, replacement] unless replacement.nil? || replacement == part
5882
+ end
5883
+ end
5884
+ return [member, acc] if pairs.empty?
5885
+
5886
+ floored = elements.map { |element| replace_splat_parts(element, pairs) }
5887
+ rebuilt = if member.is_a?(Type::Tuple)
5888
+ Type::Combinator.tuple_of(*floored)
5889
+ else
5890
+ Type::Combinator.nominal_of("Array", type_args: floored)
5891
+ end
5892
+ [rebuilt, acc]
5893
+ end
5894
+
5895
+ def splat_parts(type) = type.is_a?(Type::Union) ? type.members : [type]
5896
+
5897
+ # `element` with the floored parts replaced, gradually ({#gradual_floor}): an element is floored by
5898
+ # over-approximation as often as by a precise slot (a repeated type, an unknown position, a rest read as written),
5899
+ # and one the callee never writes must not start failing against a literal parameter type.
5900
+ def replace_splat_parts(element, pairs)
5901
+ parts = splat_parts(element)
5902
+ replaced = parts.map do |part|
5903
+ replacement = pairs.find { |from, _| from == part }&.last
5904
+ replacement ? gradual_floor(replacement) : part
5905
+ end
5906
+ replaced == parts ? element : Type::Combinator.union(*replaced)
5907
+ end
5908
+
5909
+ # `scope_in` with each carrier local other than `except` whose type `part` accepts (it is a subtype of, or equal
5910
+ # to, the element: `args << options` widens the pushed `{ format: "text" }` to `Hash[Symbol, String]`) floored
5911
+ # with `floor`. Such a local is only a guess at the element's source (the type is all the array records), so its
5912
+ # floor is gradual ({#gradual_floor}): it may forget a fact, never add a finding. A `Dynamic` part matches no
5913
+ # local: it stands for any value and would floor every carrier in scope.
5914
+ def floor_locals_accepted_by(part, except, floor, scope_in)
5915
+ return scope_in if part.is_a?(Type::Dynamic)
5916
+
5917
+ scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
5918
+ next acc if other == except || !UnknownStoreWidening.callee_floorable?(bound)
5919
+ next acc unless Acceptance.accepts(part, bound).yes?
5920
+
5921
+ floored = floor.call(bound)
5922
+ floored.nil? || floored == bound ? acc : acc.with_mutated_local(other, gradual_floor(floored))
5923
+ end
5924
+ end
5925
+
5926
+ # `type` with a bare `String` carrier made gradual (`Dynamic[String]`): a floor to the nominal `String` fails
5927
+ # against a literal parameter type (`("text") -> String`) that the unmutated local satisfied.
5928
+ def gradual_floor(type)
5929
+ return Type::Combinator.union(*type.members.map { |member| gradual_floor(member) }) if type.is_a?(Type::Union)
5930
+
5931
+ string_carrier?(type) ? Type::Combinator.dynamic(type) : type
5932
+ end
5933
+
5934
+ # `floored`, a callee floor (content or block channel) of an argument of a call to `def_node` under `owner`,
5935
+ # made gradual when the floor is an over-approximation ({#over_approximating_callee?}) or the call has several
5936
+ # callees. The one place both channels take the rule.
5937
+ def callee_gradual_floor(floored, def_node, owner, several)
5938
+ # Only a String carrier turns gradual, so the callee is walked only for one (this runs per floored argument).
5939
+ return floored unless string_carrier_in?(floored)
5940
+
5941
+ several || over_approximating_callee?(def_node, owner) ? gradual_floor(floored) : floored
5942
+ end
5943
+
5944
+ def string_carrier_in?(type)
5945
+ type.is_a?(Type::Union) ? type.members.any? { |member| string_carrier?(member) } : string_carrier?(type)
5946
+ end
5947
+
5948
+ # Issue #2515 — true when the floor a call applies to `def_node`'s arguments is an over-approximation, with no
5949
+ # proof that the callee writes the argument it floors: the owner is {FLOOR_ALL} (an inherited def, a
5950
+ # `method_missing`) or {FLOOR_EVERY} (a top-level def that may not be the one called), or the callee hands a
5951
+ # parameter on, through any hop, to one of those, by name or to several candidates. A def read as written that
5952
+ # writes the argument keeps the
5953
+ # precise floor, which is what a later `Integer` parameter or `no_such_method` on the String needs to report.
5954
+ # The walk is bounded by {CalleeMutationChain::MAX_NODES}; past it the answer is true.
5955
+ def over_approximating_callee?(def_node, owner)
5956
+ return true if OVER_APPROXIMATING.include?(owner)
5957
+
5958
+ seen = {}.compare_by_identity
5959
+ queue = [[def_node, owner]]
5960
+ until queue.empty?
5961
+ node, node_owner = queue.shift
5962
+ next if (seen[node] ||= {}).key?(node_owner)
5963
+
5964
+ seen[node][node_owner] = true
5965
+ return true if seen.sum { |_, owners| owners.size } > CalleeMutationChain::MAX_NODES
5966
+
5967
+ census_of(node_owner, :forwarding_edges, node).each do |edge|
5968
+ return true if edge.approx || OVER_APPROXIMATING.include?(edge.owner)
5969
+
5970
+ queue << [edge.callee, edge.owner || node_owner]
5971
+ end
5972
+ end
5973
+ false
5974
+ end
5975
+
5976
+ def string_carrier?(type) = type.is_a?(Type::Nominal) && type.class_name == "String"
5977
+
5978
+ # True when `def_node` has a rest, post or `...` parameter, so a slot no longer names the parameter an element
5979
+ # binds.
5980
+ def variable_arity?(def_node)
5981
+ params = def_node.parameters
5982
+ return false if params.nil?
5983
+
5984
+ !params.rest.nil? || !params.posts.empty? || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
5985
+ end
5986
+
5987
+ # Issue #2515 — true when a callee's rest parameter (`*args`, `*`, `...`) may be written, so an element a splat
5988
+ # binds to it floors: the body reads it anywhere but as a splat argument of a call (`args.first[:k] = 1`), or
5989
+ # hands it on (`b(*args)`) to a call that resolves to a def which writes a positional parameter or has a rest
5990
+ # parameter of its own that it hands on. A rest handed to a call that resolves to no user def hands off nothing,
5991
+ # as for any hand-off. A callee reached through an inherited lookup ({FLOOR_ALL}) or one that may not be the def
5992
+ # the call runs ({FLOOR_EVERY}) reads as written, as its parameters do: neither owner is a {CalleeOwner}, so its
5993
+ # body's hand-offs cannot be read under it.
5994
+ def rest_parameter_written?(def_node, owner)
5995
+ return true if def_node.nil?
5996
+ return false unless variable_arity?(def_node)
5997
+ return true if [FLOOR_ALL, FLOOR_EVERY].include?(owner)
5998
+
5999
+ other, calls = def_census(:rest_uses, def_node) { rest_uses(def_node) }
6000
+ return true if other
6001
+
6002
+ evaluator = owner.nil? ? self : owner_evaluator(owner)
6003
+ calls.any? { |call_node, depth| evaluator.__send__(:rest_hand_off_writes?, call_node, def_node, depth) }
6004
+ end
6005
+
6006
+ # True when a call that splats a rest parameter hands it to a def that writes a positional parameter or has a
6007
+ # rest or post parameter of its own, read in this evaluator, the one the call's def runs in. A nil callee is
6008
+ # {CAPPED_HAND_OFF}'s sentinel (a name with too many defs) and reads as written.
6009
+ def rest_hand_off_writes?(call_node, def_node, depth)
6010
+ hand_off_callees(call_node, def_node, depth).any? do |callee, _offset, callee_owner|
6011
+ next true if callee.nil? || variable_arity?(callee)
6012
+
6013
+ census = census_of(callee_owner, :callee_content_mutated_parameters, callee)
6014
+ census.value?(ANY_SLOT) || census.each_value.any?(Integer)
6015
+ end
6016
+ end
6017
+
6018
+ # `[other, calls]` for `def_node`'s rest parameter: `other` is true when it is read except as a splat argument
6019
+ # (`*args`, `*`) of a call, `calls` the calls that splat it ({#rest_parameter_written?}). An AST property.
6020
+ def rest_uses(def_node)
6021
+ params = def_node.parameters
6022
+ name = params.rest.is_a?(Prism::RestParameterNode) ? params.rest.name : nil
6023
+ forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
6024
+ reads = 0
6025
+ splatted = 0
6026
+ calls = []
6027
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
6028
+ reads += 1 if name && splat_reads?(node, name, depth)
6029
+ next unless node.is_a?(Prism::CallNode) && node.arguments
6030
+
6031
+ spread = node.arguments.arguments.select { |a| rest_splat?(a, name, forwarding, depth) }
6032
+ next if spread.empty?
6033
+
6034
+ splatted += spread.count { |a| a.is_a?(Prism::SplatNode) && a.expression }
6035
+ calls << [node, depth]
6036
+ end
6037
+ [reads > splatted, calls.freeze].freeze
6038
+ end
6039
+
6040
+ def rest_splat?(argument, name, forwarding, depth)
6041
+ case argument
6042
+ when Prism::ForwardingArgumentsNode then forwarding
6043
+ when Prism::SplatNode
6044
+ argument.expression.nil? ? name.nil? : !name.nil? && splat_reads?(argument.expression, name, depth)
6045
+ else false
6046
+ end
6047
+ end
6048
+
6049
+ def splat_reads?(node, name, depth)
6050
+ node.is_a?(Prism::LocalVariableReadNode) && node.name == name && node.depth == depth
6051
+ end
6052
+
6053
+ # True when a local `node` passes (positionally or as a keyword's value) is bound in `scope` to a type the
6054
+ # block accepts.
6055
+ def floorable_local_argument?(node, scope, &)
6056
+ node.arguments.arguments.any? do |argument|
6057
+ if argument.is_a?(Prism::KeywordHashNode)
6058
+ argument.elements.any? do |pair|
6059
+ pair.is_a?(Prism::AssocNode) && floorable_local?(pair.value, scope, &)
6060
+ end
6061
+ else
6062
+ floorable_local?(argument, scope, &)
6063
+ end
6064
+ end
6065
+ end
6066
+
6067
+ def floorable_local?(node, scope)
6068
+ node = node.value if node.is_a?(Prism::ImplicitNode)
6069
+ node.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(node.name) && yield(scope.local(node.name))
6070
+ end
6071
+
6072
+ # `base_scope` with each local `node` passes to a parameter of `mutated` rebound to what the block answers for
6073
+ # its binding; a nil answer, or an answer equal to the binding, leaves it. Issue #2335 — an argument that may be
6074
+ # another local's object (a block parameter, a copy) floors every local another holder may reach
6075
+ # ({AliasMutationFloor.floor_after_written_local}), a block-local one included.
6076
+ def floor_arguments(node, mutated, base_scope, offset, climbing = nil)
6077
+ arguments = arguments_for_slots(node, mutated, offset)
4481
6078
  return base_scope if arguments.empty?
4482
6079
 
4483
6080
  arguments.reduce(base_scope) do |acc, argument|
6081
+ acc = AliasMutationFloor.floor_after_written_local(acc, argument, node)
4484
6082
  next acc unless acc.locals.key?(argument.name)
6083
+ next acc if climbing && argument.depth <= climbing
4485
6084
 
4486
- floored = content_floor_for(acc.local(argument.name))
4487
- floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
6085
+ current = acc.local(argument.name)
6086
+ floored = yield(current)
6087
+ floored.nil? || floored == current ? acc : acc.with_mutated_local(argument.name, floored)
4488
6088
  end
4489
6089
  end
4490
6090
 
@@ -4496,33 +6096,112 @@ module Rigor
4496
6096
  def callee_content_mutated_parameters(def_node)
4497
6097
  cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
4498
6098
  cache[def_node] ||= resolved_census(:close, def_node) do
4499
- CalleeMutationChain.close(def_node, own: method(:own_content_parameters), edges: method(:forwarding_edges))
6099
+ CalleeMutationChain.close(def_node, own: hop_census(:own_content_parameters),
6100
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
6101
+ end
6102
+ end
6103
+
6104
+ # Issue #2515 — `census` as the `(def, owner)` callback {CalleeMutationChain} reads a hop through: `owner` is the
6105
+ # evaluator a cross-class hand-off resolves in, nil for the evaluator the closure started in.
6106
+ def hop_census(census) = ->(def_node, owner) { census_of(owner, census, def_node) }
6107
+
6108
+ # ADR-127 WD2 — the run's successor store for {CalleeMutationChain} keyed by discovery: `def => owner => name =>
6109
+ # [callee, name, owner, ...]`, a graph fact (the nodes a parameter is handed to), never a walk's conclusion. The
6110
+ # edges do not depend on which census a closure reads, so one store serves all three. The nil owner is stored
6111
+ # under the side and class the closure runs in, the same context {#resolved_census} keys by. Bypassed (nothing
6112
+ # read or written) under {#hand_off_memo_bypassed?}, as the memos it sits beside.
6113
+ def callee_chain_store
6114
+ return NO_CHAIN_STORE if hand_off_memo_bypassed?
6115
+
6116
+ table = typing_state.weak_key_map(CHAIN_STORE)
6117
+ self_type = scope.self_type
6118
+ { store: (table[scope.discovery] ||= {}.compare_by_identity),
6119
+ ctx: [self_type.is_a?(Type::Singleton), enclosing_class_name_for(self_type)] }
6120
+ end
6121
+
6122
+ CHAIN_STORE = TypingState.memo(
6123
+ :callee_chain_successors,
6124
+ run_scoped: true, audit: "successor",
6125
+ reason: "graph facts keyed by discovery, the same whichever census reads them"
6126
+ )
6127
+ NO_CHAIN_STORE = {}.freeze
6128
+ private_constant :CHAIN_STORE, :NO_CHAIN_STORE
6129
+
6130
+ # Issue #2296 — the `{ name => slot }` parameters the callee mutates inside a block in its body
6131
+ # ({#own_block_content_parameters}), closed over hand-offs as {#callee_content_mutated_parameters} is.
6132
+ def callee_block_mutated_parameters(def_node)
6133
+ cache = (@callee_block_param_cache ||= {}.compare_by_identity)
6134
+ cache[def_node] ||= resolved_census(:close_block, def_node) do
6135
+ CalleeMutationChain.close(def_node, own: hop_census(:own_block_content_parameters),
6136
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
4500
6137
  end
4501
6138
  end
4502
6139
 
4503
- # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callee_def}), so it
6140
+ # Issue #2296 — the `{ name => slot }` parameters the callee grows through a Set-only adder
6141
+ # ({#own_set_content_parameters}), closed over hand-offs the same way.
6142
+ def callee_set_mutated_parameters(def_node)
6143
+ cache = (@callee_set_param_cache ||= {}.compare_by_identity)
6144
+ cache[def_node] ||= resolved_census(:close_set, def_node) do
6145
+ CalleeMutationChain.close(def_node, own: hop_census(:own_set_content_parameters),
6146
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
6147
+ end
6148
+ end
6149
+
6150
+ # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callees}), so it
4504
6151
  # reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
4505
- # resolve through. Those two key it: the index by identity, as it is immutable (one slot, as a file's analysis
4506
- # reads one index; a scope indexed for another file replaces it), the class by name. A run builds hundreds of
6152
+ # resolve through. Those two key it: the index by identity, as it is immutable (one entry per live index, held
6153
+ # weakly: a table dropped with its index), the class by name. A run builds hundreds of
4507
6154
  # thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
4508
- # thirty times over (#2295's cost on the release gate).
6155
+ # thirty times over (#2295's cost on the release gate). Issue #2297 — the side self-calls resolve on
6156
+ # ({#resolve_self_callees}) keys it too: a singleton-side table apart from the instance-side one.
4509
6157
  #
4510
6158
  # While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
4511
- # and in each capture window open at the time, and a hit would leave that read out of them.
4512
- def resolved_census(kind, def_node)
4513
- return yield if Analysis::DependencyRecorder.active?
6159
+ # and in each capture window open at the time, and a hit would leave that read out of them. It is bypassed, read
6160
+ # and write, while this scope carries guard narrowings of constants ({#hand_off_memo_bypassed?}, #2524): they
6161
+ # survive `with_self_type` and change what a constant in the def's body types as, so the census is no longer
6162
+ # the def's alone, and the first caller's narrowing must not become every later caller's answer.
6163
+ def resolved_census(kind, def_node, &)
6164
+ # An audit recomputation bypasses the memo but still opens the site's declaration (ADR-129 WD1).
6165
+ return MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &) if MemoAudit.suspended?
6166
+ return yield if hand_off_memo_bypassed?
4514
6167
 
4515
6168
  discovery = scope.discovery
4516
- slot = Thread.current[RESOLVED_CENSUS_KEY]
4517
- slot = Thread.current[RESOLVED_CENSUS_KEY] = [discovery, {}] unless slot&.first.equal?(discovery)
4518
- by_class = (slot.last[kind] ||= {})
4519
- class_name = enclosing_class_name_for(scope.self_type)
6169
+ table = typing_state.weak_key_map(RESOLVED_CENSUS)
6170
+ slot = (table[discovery] ||= [{}, {}])
6171
+ self_type = scope.self_type
6172
+ by_class = (slot[self_type.is_a?(Type::Singleton) ? 1 : 0][kind] ||= {})
6173
+ class_name = enclosing_class_name_for(self_type)
4520
6174
  by_def = (by_class[class_name] ||= {}.compare_by_identity)
4521
- by_def.fetch(def_node) { by_def[def_node] = yield }
6175
+ unless by_def.key?(def_node)
6176
+ return by_def[def_node] = MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &)
6177
+ end
6178
+
6179
+ MemoAudit.check("resolved census", by_def[def_node], kind, class_name) do
6180
+ MemoAudit.key_audit("resolved census", RESOLVED_CENSUS_KEY_AUDIT, &)
6181
+ end
4522
6182
  end
4523
6183
 
4524
- RESOLVED_CENSUS_KEY = :rigor_callee_resolved_census
4525
- private_constant :RESOLVED_CENSUS_KEY
6184
+ RESOLVED_CENSUS = TypingState.memo(
6185
+ :callee_resolved_census,
6186
+ run_scoped: true, audit: "resolved census",
6187
+ reason: "keyed by discovery, side, kind, class and def, all its answer reads; audited by MemoAudit"
6188
+ )
6189
+ private_constant :RESOLVED_CENSUS
6190
+
6191
+ # ADR-129 WD1's key audit of a resolved-census miss. A census types a callee's receivers ({#receiver_callees_in})
6192
+ # under the frames its caller installed, and keys on none of them: a known gap, #2867.
6193
+ CENSUS_CALLER_FRAMES = %i[
6194
+ threaded_block_body key_presence_walk return_sink next_sink break_sink break_value_sink retry_frames
6195
+ interface_erasure interface_dispatch_off
6196
+ ].freeze
6197
+ private_constant :CENSUS_CALLER_FRAMES
6198
+ RESOLVED_CENSUS_KEY_AUDIT = MemoAudit.key_declaration(
6199
+ gaps: CENSUS_CALLER_FRAMES.to_h do |name|
6200
+ [name, "#2867: the census types a callee's receivers under the caller's frames, and its key carries " \
6201
+ "none of them"]
6202
+ end
6203
+ ).freeze
6204
+ private_constant :RESOLVED_CENSUS_KEY_AUDIT
4526
6205
 
4527
6206
  # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
4528
6207
  # run later / repeatedly) AND those mutated directly in the method body during the call itself
@@ -4530,18 +6209,269 @@ module Rigor
4530
6209
  # ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
4531
6210
  # call into it.
4532
6211
  def own_content_parameters(def_node)
4533
- def_census(:own, def_node) { escaped_content_parameters(def_node).merge(direct_content_parameters(def_node)) }
6212
+ def_census(:own, def_node) do
6213
+ own = escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
6214
+ written = callee_write_census(def_node).written
6215
+ own = own.merge(written_slots(def_node, written)) unless written.empty?
6216
+ handed = super_forwarded_parameters(def_node)
6217
+ handed.empty? ? own : own.merge(handed)
6218
+ end
6219
+ end
6220
+
6221
+ # Issue #2595 — the parameter writes the content scans above miss: a shape mutator they do not count
6222
+ # (`h.merge!(x)`), a write through an alias (`other = h`, `h.tap { |x| }`, `[h].each { |x| }`, `@x = h`), and
6223
+ # the aliases themselves, which {#forwarded_parameters} follows as hand-offs ({CalleeWriteCensus}). A property
6224
+ # of the def's AST alone, memoised per def node for the thread.
6225
+ def callee_write_census(def_node)
6226
+ def_census(:writes, def_node) do
6227
+ CalleeWriteCensus.scan(def_node.body, census_seeds(def_node),
6228
+ tables: CENSUS_TABLES, rebinds: parameter_rebinds(def_node),
6229
+ returns: returns_counted?(def_node), anonymous: anonymous_rests?(def_node))
6230
+ end
6231
+ end
6232
+
6233
+ # Issue #2336 — the calls whose block is a method body of the class (`define_method(:go) { poke(h) }`): the
6234
+ # hand-off walk leaves it out ({#each_node_in_method_scope}), so the write census reads a call in it that passes
6235
+ # a parameter as written ({CalleeWriteCensus}). A block run on another `self` (`instance_exec`, `Class.new`)
6236
+ # stays out: a self-call there is not a call on the method's receiver.
6237
+ METHOD_BODY_BLOCK_CALLS = %i[define_method define_singleton_method].to_set.freeze
6238
+ private_constant :METHOD_BODY_BLOCK_CALLS
6239
+
6240
+ # Issue #2336 — whether the value a def returns counts as a write of the parameter it is ({CalleeWriteCensus}):
6241
+ # not for `initialize`, whose value `new` drops, nor for a setter (`x=`, `[]=`), whose value an assignment drops.
6242
+ def returns_counted?(def_node)
6243
+ name = def_node.name
6244
+ name != :initialize && name != :[]= && !SETTER_NAME.match?(name)
6245
+ end
6246
+
6247
+ # An attribute writer's name: an identifier followed by `=` (`format=`), not an operator (`==`, `<=`, `===`).
6248
+ SETTER_NAME = /\A[A-Za-z_][A-Za-z0-9_]*=\z/
6249
+ private_constant :SETTER_NAME
6250
+
6251
+ # Issue #2336 — true when `def_node` has an anonymous rest (`*`), keyword rest (`**`) or `...`, whose arguments
6252
+ # an opaque sink may receive ({CalleeWriteCensus::ANONYMOUS_REST}).
6253
+ def anonymous_rests?(def_node)
6254
+ params = def_node.parameters
6255
+ return false if params.nil?
6256
+
6257
+ (params.rest.is_a?(Prism::RestParameterNode) && params.rest.name.nil?) ||
6258
+ (params.keyword_rest.is_a?(Prism::KeywordRestParameterNode) && params.keyword_rest.name.nil?) ||
6259
+ params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
4534
6260
  end
4535
6261
 
6262
+ # The parameters {#callee_write_census} follows: each content parameter holds the caller's argument itself
6263
+ # (`:same`); a named rest (`*args`) or keyword rest (`**kw`) holds the caller's arguments as elements (`:held`).
6264
+ def census_seeds(def_node)
6265
+ seeds = content_parameter_slots(def_node).transform_values { :same }
6266
+ rest_parameter_names(def_node).each_key { |name| seeds[name] = :held }
6267
+ seeds
6268
+ end
6269
+
6270
+ # `{ name => census key }` for a named rest (`*args` reads as {REST_PARAMETER}) and keyword rest (`**kw` reads as
6271
+ # {KEYWORD_REST_PARAMETER}) of `def_node`.
6272
+ def rest_parameter_names(def_node)
6273
+ params = def_node.parameters
6274
+ return NO_PARAMETER_SLOTS if params.nil?
6275
+
6276
+ rest = params.rest
6277
+ keyword_rest = params.keyword_rest
6278
+ names = {}
6279
+ names[rest.name] = REST_PARAMETER if rest.is_a?(Prism::RestParameterNode) && rest.name
6280
+ names[keyword_rest.name] = KEYWORD_REST_PARAMETER if keyword_rest.is_a?(Prism::KeywordRestParameterNode) &&
6281
+ keyword_rest.name
6282
+ names
6283
+ end
6284
+
6285
+ # The own-census entries for the parameter names {#callee_write_census} wrote: a content parameter at its slot,
6286
+ # a rest at {ANY_SLOT} and a keyword rest at {ANY_KEYWORD}, the slots every argument they bind reaches.
6287
+ #
6288
+ # Issue #2336 — an anonymous rest, keyword rest or `...` an opaque sink received
6289
+ # ({CalleeWriteCensus::ANONYMOUS_REST}) is written at its slot too.
6290
+ def written_slots(def_node, written)
6291
+ found = content_parameter_slots(def_node).slice(*written)
6292
+ rest_parameter_names(def_node).each do |name, key|
6293
+ found[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD if written.include?(name)
6294
+ end
6295
+ rests = rest_parameters(def_node)
6296
+ if written.include?(CalleeWriteCensus::ANONYMOUS_REST) && rests.include?(REST_PARAMETER)
6297
+ found[REST_PARAMETER] = ANY_SLOT
6298
+ end
6299
+ if written.include?(CalleeWriteCensus::ANONYMOUS_KEYWORD_REST) && rests.include?(KEYWORD_REST_PARAMETER)
6300
+ found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD
6301
+ end
6302
+ found
6303
+ end
6304
+
6305
+ # Issue #2521 (#2570 review) — the parameters `def_node` hands to the method it overrides: every content
6306
+ # parameter under a zsuper (`super`, which passes them all), and each one an explicit `super(...)` passes as an
6307
+ # argument or keyword value, at the block depth the call sits at. The overridden def is not followed; the
6308
+ # parameter reads as mutated, so a literal handed through an override that forwards it is floored.
6309
+ #
6310
+ # A rest parameter (`*`, `*args`, `...`) handed on whole (a zsuper, `super(*args)`, `super(...)`) reads as
6311
+ # mutated under {REST_PARAMETER}, the name an argument the call binds to the rest parameter reaches
6312
+ # ({#compute_forwarding_edges}), at {ANY_SLOT} ({#arguments_for_slots}).
6313
+ def super_forwarded_parameters(def_node)
6314
+ # A cheap gate first: the walk below runs for the few defs whose source mentions `super` at all.
6315
+ body = def_node.body
6316
+ return NO_PARAMETER_SLOTS if body.nil? || !body.slice.include?("super")
6317
+
6318
+ slots = content_parameter_slots(def_node)
6319
+ rests = rest_parameters(def_node)
6320
+ return NO_PARAMETER_SLOTS if slots.empty? && rests.empty?
6321
+
6322
+ names = super_forwarded_names(def_node, slots, rests)
6323
+ return NO_PARAMETER_SLOTS if names.nil?
6324
+
6325
+ found = slots.slice(*names)
6326
+ found[REST_PARAMETER] = ANY_SLOT if names.include?(REST_PARAMETER)
6327
+ found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD if names.include?(KEYWORD_REST_PARAMETER)
6328
+ found
6329
+ end
6330
+
6331
+ # The parameter names `def_node` hands to `super` ({#super_forwarded_parameters}), or nil when it calls none.
6332
+ # `rests` holds the rest names the def has ({#rest_parameters}).
6333
+ def super_forwarded_names(def_node, slots, rests)
6334
+ names = nil
6335
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
6336
+ case node
6337
+ when Prism::ForwardingSuperNode
6338
+ (names ||= Set.new).merge(slots.keys.reject { |name| after_rebind?(def_node, name, node) }).merge(rests)
6339
+ when Prism::SuperNode
6340
+ handed = super_argument_parameters(node, slots, depth).reject { |name| after_rebind?(def_node, name, node) }
6341
+ (names ||= Set.new).merge(handed)
6342
+ names.merge(rests & splatted_rests(node))
6343
+ end
6344
+ end
6345
+ names
6346
+ end
6347
+
6348
+ # The names {#super_forwarded_parameters} records a positional and a keyword rest parameter handed to `super`
6349
+ # under; no local can be named with a leading `*`.
6350
+ REST_PARAMETER = :"*rest"
6351
+ KEYWORD_REST_PARAMETER = :"**rest"
6352
+ REST_NAMES = [REST_PARAMETER].freeze
6353
+ KEYWORD_REST_NAMES = [KEYWORD_REST_PARAMETER].freeze
6354
+ private_constant :REST_PARAMETER, :KEYWORD_REST_PARAMETER, :REST_NAMES, :KEYWORD_REST_NAMES
6355
+
6356
+ # The rest names an argument the call binds to no named parameter of `callee` reaches, when `callee` hands that
6357
+ # rest parameter to `super` ({#super_forwarded_parameters}); empty otherwise. `slot` is the call's positional
6358
+ # index, or the keyword.
6359
+ def handed_rest_names(callee, slot)
6360
+ own = own_content_parameters(callee)
6361
+ if slot.is_a?(Symbol) then own.key?(KEYWORD_REST_PARAMETER) ? KEYWORD_REST_NAMES : NO_FORWARDS
6362
+ else own.key?(REST_PARAMETER) ? REST_NAMES : NO_FORWARDS
6363
+ end
6364
+ end
6365
+
6366
+ # The rest parameters `def_node` has, as {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}: `*args` / `*`, `**kw` /
6367
+ # `**`, both for `...`.
6368
+ def rest_parameters(def_node)
6369
+ params = def_node.parameters
6370
+ return NO_FORWARDS if params.nil?
6371
+
6372
+ forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
6373
+ rests = []
6374
+ rests << REST_PARAMETER if forwarding || params.rest.is_a?(Prism::RestParameterNode)
6375
+ rests << KEYWORD_REST_PARAMETER if forwarding || params.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
6376
+ rests
6377
+ end
6378
+
6379
+ # The rests an explicit `super(...)` passes on whole: a splat (`*args`, `*`), a double splat (`**kw`, `**`) in its
6380
+ # keywords, or `...` for both.
6381
+ def splatted_rests(node)
6382
+ (node.arguments&.arguments || []).flat_map do |argument|
6383
+ case argument
6384
+ when Prism::SplatNode then REST_NAMES
6385
+ when Prism::ForwardingArgumentsNode then [REST_PARAMETER, KEYWORD_REST_PARAMETER]
6386
+ when Prism::KeywordHashNode
6387
+ argument.elements.any?(Prism::AssocSplatNode) ? KEYWORD_REST_NAMES : NO_FORWARDS
6388
+ else NO_FORWARDS
6389
+ end
6390
+ end
6391
+ end
6392
+
6393
+ def super_argument_parameters(node, slots, depth)
6394
+ arguments = node.arguments&.arguments || []
6395
+ arguments.flat_map do |argument|
6396
+ if argument.is_a?(Prism::KeywordHashNode)
6397
+ argument.elements.filter_map do |pair|
6398
+ parameter_read_name(pair.value, slots, depth) if pair.is_a?(Prism::AssocNode)
6399
+ end
6400
+ else
6401
+ [parameter_read_name(argument, slots, depth)].compact
6402
+ end
6403
+ end
6404
+ end
6405
+
6406
+ # Issue #2296 — the parameters `def_node` content-mutates inside a block of its body, at any nesting: a
6407
+ # mutation whose receiver reads the parameter itself at the block depth it sits at, so a block parameter or
6408
+ # block-local of the same name, and a block-local copy (`t = [acc[i]]; t[0] = v`), do not count. A lambda or
6409
+ # nested def is not entered (its body is not run by the call), nor is a block that runs on another `self`
6410
+ # ({#each_node_in_method_scope}). The walk counts the method body too: a mutation there, or in an escaping
6411
+ # block, is one the bare census ({#own_content_parameters}) also counts, and its floor runs first, leaving this
6412
+ # one nothing to widen. A property of the AST alone, memoised per def node for the thread.
6413
+ def own_block_content_parameters(def_node) = block_and_set_parameters(def_node)[0]
6414
+
6415
+ # Issue #2296 — the parameters `def_node` grows through a Set-only adder ({SET_ONLY_ADDERS}) in its body or a
6416
+ # block in it, read at the depth it sits at as above. The name alone does not say the receiver is a Set
6417
+ # (`Hash#merge` returns a new Hash), so only a Set argument is floored on it
6418
+ # ({UnknownStoreWidening.callee_set_floor}).
6419
+ def own_set_content_parameters(def_node) = block_and_set_parameters(def_node)[1]
6420
+
6421
+ # Both censuses above, `[block, set]`, from one walk of the method scope, memoised per def node for the thread.
6422
+ def block_and_set_parameters(def_node)
6423
+ def_census(:block_and_set, def_node) do
6424
+ slots = content_parameter_slots(def_node)
6425
+ next NO_BLOCK_AND_SET if slots.empty?
6426
+
6427
+ block = nil
6428
+ set = nil
6429
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
6430
+ name = content_mutation_target(node) { |r| r.depth == depth }.first
6431
+ (block ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
6432
+ name = set_only_adder_target(node, depth)
6433
+ (set ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
6434
+ end
6435
+ [block.nil? ? NO_PARAMETER_SLOTS : slots.slice(*block), set.nil? ? NO_PARAMETER_SLOTS : slots.slice(*set)]
6436
+ end
6437
+ end
6438
+
6439
+ # True when `name`, the local the mutation `node` targets, is one of `slots`' parameters and `node` runs before
6440
+ # any rebind of it ({#after_rebind?}).
6441
+ def counted_mutation?(def_node, slots, name, node)
6442
+ !name.nil? && slots.key?(name) && !after_rebind?(def_node, name, node)
6443
+ end
6444
+
6445
+ # The local `node` grows through a Set-only adder when its receiver reads that local at `depth`, else nil.
6446
+ def set_only_adder_target(node, depth)
6447
+ return unless node.is_a?(Prism::CallNode) && SET_ONLY_ADDERS.include?(node.name)
6448
+
6449
+ receiver = node.receiver
6450
+ receiver.name if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
6451
+ end
6452
+
6453
+ NO_PARAMETER_SLOTS = {}.freeze
6454
+ NO_BLOCK_AND_SET = [NO_PARAMETER_SLOTS, NO_PARAMETER_SLOTS].freeze
6455
+ private_constant :NO_PARAMETER_SLOTS, :NO_BLOCK_AND_SET
6456
+
4536
6457
  # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
4537
- def def_census(kind, def_node)
4538
- table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
6458
+ def def_census(kind, def_node, &)
6459
+ # An audit recomputation bypasses the memo, so it reads no stored entry (ADR-127 WD3, #3071).
6460
+ return MemoAudit.key_audit("def census", &) if MemoAudit.suspended?
6461
+
6462
+ table = typing_state.weak_key_map(DEF_CENSUS)
4539
6463
  entry = (table[def_node] ||= {})
4540
- entry.fetch(kind) { entry[kind] = yield }
6464
+ return entry[kind] = MemoAudit.key_audit("def census", &) unless entry.key?(kind)
6465
+
6466
+ MemoAudit.check("def census", entry[kind], kind, &)
4541
6467
  end
4542
6468
 
4543
- DEF_CENSUS_KEY = :rigor_callee_def_census
4544
- private_constant :DEF_CENSUS_KEY
6469
+ DEF_CENSUS = TypingState.memo(
6470
+ :callee_def_census,
6471
+ run_scoped: true, audit: "def census",
6472
+ reason: "keyed by def node, the only input it reads; audited by MemoAudit"
6473
+ )
6474
+ private_constant :DEF_CENSUS
4545
6475
 
4546
6476
  # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
4547
6477
  # a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
@@ -4553,19 +6483,39 @@ module Rigor
4553
6483
  cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
4554
6484
  end
4555
6485
 
6486
+ # True for a hand-off answer with more than one candidate, or resolved by name ({UnionCallees}): its edges are
6487
+ # over-approximations ({CalleeMutationChain::Edge#approx}).
6488
+ def approximate_hand_off?(callees) = callees.size > 1 || callees.is_a?(UnionCallees)
6489
+
6490
+ # True for a hand-off answer that reads the handed parameter as written whatever the callee is: a name with too
6491
+ # many defs ({CAPPED_HAND_OFF}) or a top-level def that may not be the one called ({FLOOR_EVERY}).
6492
+ def floors_outright?(callees)
6493
+ callees.equal?(CAPPED_HAND_OFF) || callees.any? { |_callee, _offset, owner| owner == FLOOR_EVERY }
6494
+ end
6495
+
4556
6496
  def compute_forwarding_edges(def_node)
4557
- slots = content_parameter_slots(def_node)
6497
+ slots = forwarding_slots(def_node)
4558
6498
  edges = []
4559
- each_forwarding_call(def_node, slots) do |call_node, forwarded|
4560
- callee = resolve_self_callee_def(call_node)
4561
- next if callee.nil?
6499
+ each_forwarding_call(def_node, slots) do |call_node, forwarded, depth|
6500
+ callees = hand_off_callees(call_node, def_node, depth)
6501
+ if floors_outright?(callees)
6502
+ forwarded.map(&:first).each do |name|
6503
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: def_node,
6504
+ callee_names: [name], owner: FLOOR_ALL)
6505
+ end
6506
+ next
6507
+ end
4562
6508
 
4563
- callee_slots = content_parameter_slots(callee)
4564
- forwarded.each do |name, callee_slot|
4565
- names = bound_parameter_names(callee_slots, callee_slot)
4566
- next if names.empty?
6509
+ callees.each do |callee, offset, owner|
6510
+ callee_slots = content_parameter_slots(callee)
6511
+ forwarded.each do |name, index|
6512
+ names = bound_parameter_names(callee_slots, index.is_a?(Integer) ? index - offset : index)
6513
+ names = handed_rest_names(callee, index) if names.empty? && index != ANY_SLOT
6514
+ next if names.empty?
4567
6515
 
4568
- edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names)
6516
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names,
6517
+ owner: owner, approx: approximate_hand_off?(callees))
6518
+ end
4569
6519
  end
4570
6520
  end
4571
6521
  edges.empty? ? NO_FORWARDS : edges
@@ -4581,11 +6531,13 @@ module Rigor
4581
6531
 
4582
6532
  def forwarding_calls(def_node, slots)
4583
6533
  calls = []
6534
+ context = HandOffContext.new(census: callee_write_census(def_node), seeds: census_seeds(def_node),
6535
+ rests: rest_parameter_names(def_node), rebinds: parameter_rebinds(def_node))
4584
6536
  each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
4585
- next unless self_dispatch_call?(descendant)
6537
+ next unless self_dispatch_call?(descendant) || hand_off_receiver?(descendant)
4586
6538
 
4587
- forwarded = forwarded_parameters(descendant, slots, depth)
4588
- calls << [descendant, forwarded] unless forwarded.empty?
6539
+ forwarded = forwarded_parameters(descendant, slots, depth, context)
6540
+ calls << [descendant, forwarded, depth] unless forwarded.empty?
4589
6541
  end
4590
6542
  calls.empty? ? NO_FORWARDS : calls
4591
6543
  end
@@ -4596,8 +6548,11 @@ module Rigor
4596
6548
  # `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
4597
6549
  # `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
4598
6550
  # argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
4599
- # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index.
4600
- def forwarded_parameters(call_node, slots, depth)
6551
+ # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index among the call's
6552
+ # arguments, which {#compute_forwarding_edges} shifts by each callee's offset ({#resolve_self_callees}).
6553
+ # Issue #2595 — an argument that reads an alias of a parameter (`other = h; poke(other)`, `@x = h; poke(@x)`, a
6554
+ # block parameter of `h.tap`) hands on that parameter too (`census`, {#callee_write_census}).
6555
+ def forwarded_parameters(call_node, slots, depth, context = NO_HAND_OFF_CONTEXT)
4601
6556
  args = call_node.arguments
4602
6557
  return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
4603
6558
 
@@ -4605,11 +6560,14 @@ module Rigor
4605
6560
  forwarded = []
4606
6561
  args.arguments.each_with_index do |argument, index|
4607
6562
  if argument.is_a?(Prism::KeywordHashNode)
4608
- forwarded.concat(forwarded_keywords(argument, slots, depth))
6563
+ forwarded.concat(forwarded_keywords(argument, slots, depth, context))
4609
6564
  elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
4610
6565
  spread = true
4611
- elsif (name = parameter_read_name(argument, slots, depth))
4612
- forwarded << [name, spread ? ANY_SLOT : index]
6566
+ rest = splatted_rest(argument, depth, context)
6567
+ forwarded << [rest, ANY_SLOT] if rest
6568
+ else
6569
+ slot = spread ? ANY_SLOT : index
6570
+ handed_names(argument, slots, depth, context) { |name| forwarded << [name, slot] }
4613
6571
  end
4614
6572
  end
4615
6573
  forwarded
@@ -4617,25 +6575,93 @@ module Rigor
4617
6575
 
4618
6576
  # The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
4619
6577
  ANY_SLOT = :__any_positional__
4620
- private_constant :ANY_SLOT
6578
+
6579
+ # The census {FLOOR_EVERY} answers: the rest and keyword-rest slots, which {#arguments_for_slots} reads as every
6580
+ # positional argument and every keyword value of the call.
6581
+ EVERY_ARGUMENT = { REST_PARAMETER => ANY_SLOT, KEYWORD_REST_PARAMETER => ANY_KEYWORD }.freeze
6582
+ private_constant :ANY_SLOT, :EVERY_ARGUMENT
4621
6583
 
4622
6584
  # The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
4623
6585
  # may reach: the one at that slot, or every positional one for {ANY_SLOT}.
4624
6586
  def bound_parameter_names(callee_slots, slot)
4625
6587
  if slot == ANY_SLOT
4626
6588
  callee_slots.filter_map { |name, at| name if at.is_a?(Integer) }
6589
+ elsif slot == ANY_KEYWORD
6590
+ callee_slots.filter_map { |name, at| name if at.is_a?(Symbol) }
4627
6591
  else
4628
6592
  callee_slots.filter_map { |name, at| name if at == slot }
4629
6593
  end
4630
6594
  end
4631
6595
 
4632
- def forwarded_keywords(hash_node, slots, depth)
4633
- hash_node.elements.filter_map do |pair|
4634
- next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
6596
+ # Issue #2336 — a double splat of the def's own keyword rest (`poke(**kw)`) hands it on whole, at {ANY_KEYWORD}.
6597
+ def forwarded_keywords(hash_node, slots, depth, context)
6598
+ found = []
6599
+ hash_node.elements.each do |pair|
6600
+ if pair.is_a?(Prism::AssocSplatNode)
6601
+ rest = splatted_keyword_rest(pair, depth, context)
6602
+ found << [rest, ANY_KEYWORD] if rest
6603
+ end
6604
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
6605
+
6606
+ keyword = pair.key.unescaped.to_sym
6607
+ handed_names(pair.value, slots, depth, context) { |name| found << [name, keyword] }
6608
+ end
6609
+ found
6610
+ end
6611
+
6612
+ # Yields each parameter `node` may hand on: the one it reads ({#parameter_read_name}) and those it may be through
6613
+ # an alias or an expression ({CalleeWriteCensus.handed_aliases}). An element of a named rest or keyword rest
6614
+ # (`x = args[0]; poke(x)`, `poke(kw[:h])`) hands on that rest, under {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}
6615
+ # ({#forwarding_slots}).
6616
+ #
6617
+ # Issue #2336 — a read of the parameter after the def rebound it to a new object (`acc = acc.dup; poke(acc)`)
6618
+ # hands on the copy, not the caller's argument ({#after_rebind?}).
6619
+ def handed_names(node, slots, depth, context)
6620
+ name = parameter_read_name(node, slots, depth)
6621
+ yield name if name && !CalleeWriteCensus.rebound?(context.rebinds, name, node)
6622
+ node = node.value if node.is_a?(Prism::ImplicitNode)
6623
+ aliases = CalleeWriteCensus.handed_aliases(context.census, context.seeds, node, depth,
6624
+ fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS)
6625
+ aliases.each do |aliased|
6626
+ next if aliased == name
6627
+
6628
+ if slots.key?(aliased) then yield aliased
6629
+ elsif (rest = context.rests[aliased]) then yield rest
6630
+ end
6631
+ end
6632
+ end
6633
+
6634
+ # The keyword-rest key a `**kw` double splat of the def's own named keyword rest hands on whole, or nil.
6635
+ def splatted_keyword_rest(pair, depth, context)
6636
+ read = pair.value
6637
+ return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
6638
+
6639
+ rest = context.rests[read.name]
6640
+ rest if rest == KEYWORD_REST_PARAMETER
6641
+ end
6642
+
6643
+ # The rest key a `*args` splat of the def's own named rest hands on whole (`poke(*args)`), or nil.
6644
+ def splatted_rest(argument, depth, context)
6645
+ read = argument.is_a?(Prism::SplatNode) ? argument.expression : nil
6646
+ return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
6647
+
6648
+ rest = context.rests[read.name]
6649
+ rest if rest == REST_PARAMETER
6650
+ end
6651
+
6652
+ # What {#forwarded_parameters} reads a def's hand-offs with: its write census, its census seeds, its named
6653
+ # rests and its parameter rebinds ({#parameter_rebinds}).
6654
+ HandOffContext = Data.define(:census, :seeds, :rests, :rebinds)
6655
+ NO_HAND_OFF_CONTEXT = HandOffContext.new(census: CalleeWriteCensus::EMPTY, seeds: {}.freeze, rests: {}.freeze,
6656
+ rebinds: {}.freeze)
6657
+ private_constant :HandOffContext, :NO_HAND_OFF_CONTEXT
4635
6658
 
4636
- name = parameter_read_name(pair.value, slots, depth)
4637
- [name, pair.key.unescaped.to_sym] if name
4638
- end
6659
+ # The slots a hand-off's forwarded name maps to: each content parameter's, and {ANY_SLOT} / {ANY_KEYWORD} for the
6660
+ # rest keys an element of a named rest or keyword rest hands on under ({#handed_names}).
6661
+ def forwarding_slots(def_node)
6662
+ slots = content_parameter_slots(def_node)
6663
+ rest_parameter_names(def_node).each_value { |key| slots[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD }
6664
+ slots
4639
6665
  end
4640
6666
 
4641
6667
  # The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
@@ -4647,16 +6673,168 @@ module Rigor
4647
6673
  node.name if slots.key?(node.name)
4648
6674
  end
4649
6675
 
4650
- # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
4651
- # `Scope#user_def_for` reads; no ancestor walk (the boundary-escape idiom is same-class), keeping this off the hot
4652
- # path for the overwhelming majority of self-calls that resolve to nothing escaping.
4653
- def resolve_self_callee_def(node)
4654
- class_name = enclosing_class_name_for(scope.self_type)
4655
- return scope.top_level_def_for(node.name) if class_name.nil?
6676
+ # The `[def, offset]` pairs a self-dispatch `node` resolves to in the enclosing class, `offset` being how many
6677
+ # leading arguments of `node` are not the def's. Issue #2521 — the instance def is looked up along the class's
6678
+ # ancestors ({Scope#user_def_through_ancestors}), as Ruby dispatches the call: an inherited def is now walked
6679
+ # under the receiver's class, so a self-call inside it to another inherited def must still be found there.
6680
+ #
6681
+ # Issue #2297 — in a singleton-side body (`def self.x`, a `def` in `class << self`), whose `self` is the class
6682
+ # object, a self-call resolves to the singleton def of that name AND to the same-named instance def, so the floor
6683
+ # applies when either mutates. Ruby calls only the singleton side, but the hand-offs that reach an instance def
6684
+ # from there (`new.call(acc)`, `instance.helper(acc)`) and the singleton defs the index does not record
6685
+ # (`module_function :poke`, `define_singleton_method`, `alias_method` in `class << self`) are not followed, and
6686
+ # dropping the instance def floored too little on correct code
6687
+ # (`approval-queue.d/callee-floor-singleton-side.md`).
6688
+ # An instance-side body resolves to instance defs only.
6689
+ #
6690
+ # A `send(:name, ...)` / `public_send(:name, ...)` with a symbol literal resolves as the self-call `name(...)`,
6691
+ # offset 1, on each side that has no def of the `send` / `public_send` name itself; on a side that has one, it is
6692
+ # an ordinary self-call to that def, offset 0.
6693
+ def resolve_self_callees(node)
6694
+ self_type = scope.self_type
6695
+ class_name = enclosing_class_name_for(self_type)
6696
+ return top_level_callees(node, nil) if class_name.nil?
6697
+
6698
+ instance = ancestor_side_callee(node, class_name) do |name|
6699
+ scope.user_def_through_ancestors(class_name, name)
6700
+ end
6701
+ side = self_type.is_a?(Type::Singleton) ? :singleton : :instance
6702
+ singleton = inherited_singleton_callee(node, self_type) if side == :singleton
6703
+ return unanswered_self_callees(node, class_name, side) unless instance || singleton
6704
+ return [instance] if singleton.nil?
6705
+ return [singleton] if instance.nil?
6706
+ return [instance] if singleton.first.equal?(instance.first)
6707
+
6708
+ [singleton, instance]
6709
+ end
6710
+
6711
+ # Issue #2515 — the `[def, offset, owner]` a receiverless call resolves to in the project's top-level defs: a
6712
+ # top-level def is a private method on `Object`, so a call outside any class body, and a call in a class whose
6713
+ # chain does not answer the name, reaches it. `class_name` is nil outside a class (the callee's own hand-offs
6714
+ # resolve in this evaluator, owner nil), else the class and side the chain was searched on. The def is read as
6715
+ # written only when nothing else can answer it ({ToplevelDefCensus.reliable?}, and for a class the chain
6716
+ # {#chain_may_answer?}); otherwise it reads as {FLOOR_EVERY}, every argument written. Nothing when no top-level
6717
+ # def has the name.
6718
+ def top_level_callees(node, class_name, side = nil)
6719
+ target = nil
6720
+ found = side_callee(node) do |name|
6721
+ def_node = scope.top_level_def_for(name)
6722
+ target = name unless def_node.nil?
6723
+ def_node
6724
+ end
6725
+ return NO_CALLEES if found.nil?
6726
+
6727
+ def_node, offset = found
6728
+ floor = !ToplevelDefCensus.reliable?(scope, target) ||
6729
+ (!class_name.nil? && chain_may_answer?(class_name, side, target))
6730
+ [[def_node, offset, if floor
6731
+ FLOOR_EVERY
6732
+ else
6733
+ (class_name.nil? ? nil : TOPLEVEL_OWNER)
6734
+ end]]
6735
+ end
6736
+
6737
+ # A self-call the class's own chain found no def for: the chain's `method_missing` answers it, else the project's
6738
+ # top-level def of that name (Ruby searches `Object` before `method_missing`, so with both the top-level def runs
6739
+ # and the `method_missing` reading is the over-approximation; both are returned and the top-level one floors
6740
+ # every argument).
6741
+ #
6742
+ # Issue #2336 — a name nothing above answers floors every argument when a `define_method` /
6743
+ # `define_singleton_method` / `alias_method` call gives it a body ({#self_ownerless_body?}).
6744
+ def unanswered_self_callees(node, class_name, side)
6745
+ missing = self_method_missing_callees(node, class_name, side)
6746
+ top = top_level_callees(node, class_name, side)
6747
+ if missing.empty? && top.empty?
6748
+ name = send_hand_off?(node) ? send_target(node) : node.name
6749
+ return self_ownerless_body?(name, class_name, side) ? OPAQUE_CALLEES : NO_CALLEES
6750
+ end
6751
+ return top if missing.empty?
6752
+ return missing if top.empty?
6753
+
6754
+ missing + top.map { |def_node, offset, _owner| [def_node, offset, FLOOR_EVERY] }
6755
+ end
6756
+
6757
+ # True when something besides `Object` may answer a receiverless call of `name` in `class_name`'s `side`: the
6758
+ # class is no project class, a definer other than a `def` sits on its chain (`attr_*`, `define_method`, a budget
6759
+ # cut), or an ancestor the project does not declare is unknown to RBS or declares the name there (a
6760
+ # `< ActiveRecord::Base` the environment has no RBS for may define anything). The project's own `def`s were
6761
+ # searched already ({#resolve_self_callees}).
6762
+ def chain_may_answer?(class_name, side, name)
6763
+ return true unless project_class?(class_name)
6764
+ return true if scope.discovered_method_through_ancestors?(class_name, name, side)
6765
+ return true if rbs_declares?(class_name, name) || (side == :singleton && rbs_declares?("Class", name))
6766
+
6767
+ external = scope.external_ancestor_name_candidates(class_name)
6768
+ external += scope.singleton_extends_of(class_name).map { |module_name| [module_name] } if side == :singleton
6769
+ external.any? do |candidates|
6770
+ known = candidates.find { |candidate| Reflection.rbs_class_known?(candidate, scope: scope) }
6771
+ known.nil? ? candidates.none? { |candidate| project_class?(candidate) } : rbs_declares?(known, name)
6772
+ end
6773
+ end
6774
+
6775
+ # A class or module the project declares, even one with no method of its own.
6776
+ def project_class?(name) = scope.known_user_class?(name) || scope.declared_class?(name)
6777
+
6778
+ def rbs_declares?(class_name, name)
6779
+ !Reflection.instance_method_definition(class_name, name, scope: scope).nil? ||
6780
+ !Reflection.singleton_method_definition(class_name, name, scope: scope).nil?
6781
+ end
6782
+
6783
+ # The `[def, offset, owner]` a singleton-side self-call resolves to along the singleton chain: owner nil for a
6784
+ # def of the class itself, {FLOOR_ALL} for one it inherits ({#walked_owner}).
6785
+ def inherited_singleton_callee(node, self_type)
6786
+ class_name = self_type.class_name
6787
+ ancestor_side_callee(node, class_name) do |name|
6788
+ scope.singleton_def_through_ancestors(class_name, name)
6789
+ end
6790
+ end
6791
+
6792
+ # The `[def, offset, owner]` `node` resolves to through a lookup on `class_name` that answers `[def, defining]`
6793
+ # (the block), owner nil, or {FLOOR_ALL} ({#walked_owner}); nil when nothing answers.
6794
+ def ancestor_side_callee(node, class_name)
6795
+ defining = nil
6796
+ found = side_callee(node) do |name|
6797
+ def_node, owner = yield(name)
6798
+ defining = owner unless def_node.nil?
6799
+ def_node
6800
+ end
6801
+ found && [*found, walked_owner(defining, class_name, nil)]
6802
+ end
6803
+
6804
+ # {#method_missing_callees} for a self-call `node` that resolves to nothing; a `send(:name, ...)` hand-off
6805
+ # passes the name as `method_missing`'s own first argument.
6806
+ def self_method_missing_callees(node, class_name, side)
6807
+ if send_hand_off?(node)
6808
+ method_missing_callees(class_name, side, node.arguments.arguments.first.unescaped.to_sym, 1)
6809
+ else
6810
+ method_missing_callees(class_name, side, node.name, 0)
6811
+ end
6812
+ end
6813
+
6814
+ # The `[def, offset]` `node` resolves to on one side, whose def table the block looks a name up in, or nil.
6815
+ def side_callee(node)
6816
+ if send_hand_off?(node)
6817
+ own = yield(node.name)
6818
+ return [own, 0] if own
6819
+
6820
+ target = yield(node.arguments.arguments.first.unescaped.to_sym)
6821
+ return target && [target, 1]
6822
+ end
6823
+
6824
+ found = yield(node.name)
6825
+ found && [found, 0]
6826
+ end
4656
6827
 
4657
- scope.user_def_for(class_name, node.name)
6828
+ # True for a `send` / `public_send` / `__send__` whose first argument is a literal target name: a symbol, or
6829
+ # (issue #2336) a string (`send("poke", h)`).
6830
+ def send_hand_off?(node)
6831
+ SEND_HAND_OFFS.include?(node.name) && CalleeWriteCensus.literal_send?(node)
4658
6832
  end
4659
6833
 
6834
+ SEND_HAND_OFFS = %i[send public_send __send__].freeze
6835
+ NO_CALLEES = [].freeze
6836
+ private_constant :SEND_HAND_OFFS, :NO_CALLEES
6837
+
4660
6838
  def self_dispatch_call?(node)
4661
6839
  return false unless node.is_a?(Prism::CallNode)
4662
6840
 
@@ -4664,7 +6842,7 @@ module Rigor
4664
6842
  end
4665
6843
 
4666
6844
  # The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
4667
- # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
6845
+ # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-mutated"
4668
6846
  # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
4669
6847
  # non-escaping.
4670
6848
  def escaped_content_parameters(def_node)
@@ -4681,8 +6859,10 @@ module Rigor
4681
6859
  next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
4682
6860
  next if syntactically_non_escaping_call?(descendant)
4683
6861
 
4684
- collect_content_mutations(descendant.block.body).each_key do |name|
4685
- mutated << name if positions.key?(name)
6862
+ collect_content_mutations(descendant.block.body).each do |name, nodes|
6863
+ next unless positions.key?(name)
6864
+
6865
+ mutated << name if nodes.any? { |node| !after_rebind?(def_node, name, node) }
4686
6866
  end
4687
6867
  end
4688
6868
  positions.slice(*mutated)
@@ -4694,9 +6874,9 @@ module Rigor
4694
6874
  # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
4695
6875
  # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
4696
6876
  #
4697
- # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted: the bare-collection floor
4698
- # it would apply turned haml's `AttributeCompiler#compile` (a `temple` array a helper fills in a `times` block,
4699
- # returned against a declared tuple) into a `def.return-type-mismatch` (af3efef33, and again under #2104).
6877
+ # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted here: the bare-collection
6878
+ # floor this census applies dropped the element type (af3efef33, and again under #2104). Issue #2296 counts it in
6879
+ # {#own_block_content_parameters}, whose floor keeps the element type.
4700
6880
  def direct_content_parameters(def_node)
4701
6881
  cache = (@direct_param_cache ||= {}.compare_by_identity)
4702
6882
  cache[def_node] ||= compute_direct_content_parameters(def_node)
@@ -4709,11 +6889,110 @@ module Rigor
4709
6889
  mutated = Set.new
4710
6890
  each_node_outside_nested_scopes(def_node.body) do |descendant|
4711
6891
  name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
4712
- mutated << name if !name.nil? && positions.key?(name)
6892
+ mutated << name if !name.nil? && positions.key?(name) && !after_rebind?(def_node, name, descendant)
4713
6893
  end
4714
6894
  positions.slice(*mutated)
4715
6895
  end
4716
6896
 
6897
+ # Issue #2296 — true when `node` runs after the def has rebound its parameter `name` to a fresh object
6898
+ # (`acc = acc.dup` as a statement of the method body, ahead of `node`): from there `name` holds another object,
6899
+ # so a mutation of it does not reach the caller's argument. Only an unconditional rebind counts, a write that is
6900
+ # itself a statement of the body, not one under a branch, a loop, a block or `||=`. Its value MUST provably be a
6901
+ # new object ({#fresh_value?}): `acc = acc.to_a` or `s = s.to_s` hands back the same object, so a mutation after
6902
+ # it still reaches the caller. A def whose body has a `rescue` or `ensure` clause never rebinds, since the clause
6903
+ # runs with whichever binding the raise left. And only a node that starts after the write ends counts, so
6904
+ # `acc = poke(acc)` still passes the original.
6905
+ def after_rebind?(def_node, name, node)
6906
+ offset = parameter_rebinds(def_node)[name]
6907
+ !offset.nil? && node.location.start_offset >= offset
6908
+ end
6909
+
6910
+ # `{ name => end_offset }` of the first unconditional fresh rebind of each parameter ({#after_rebind?}),
6911
+ # memoised per def node for the thread.
6912
+ def parameter_rebinds(def_node)
6913
+ def_census(:rebinds, def_node) do
6914
+ slots = content_parameter_slots(def_node)
6915
+ rebinds = nil
6916
+ body_statements(def_node.body).each do |statement|
6917
+ next unless fresh_rebind?(statement, slots)
6918
+
6919
+ (rebinds ||= {})[statement.name] ||= statement.location.end_offset
6920
+ end
6921
+ rebinds || NO_PARAMETER_SLOTS
6922
+ end
6923
+ end
6924
+
6925
+ def fresh_rebind?(statement, slots)
6926
+ case statement
6927
+ when Prism::LocalVariableWriteNode
6928
+ statement.depth.zero? && slots.key?(statement.name) && fresh_value?(statement.value, statement.name)
6929
+ when Prism::LocalVariableOperatorWriteNode
6930
+ statement.depth.zero? && slots.key?(statement.name) &&
6931
+ FRESH_OPERATORS.include?(statement.binary_operator)
6932
+ else false
6933
+ end
6934
+ end
6935
+
6936
+ # A value that is always a new object: a `dup` / `clone`, an Array / Hash / String literal, `new` on a core
6937
+ # collection class ({#core_collection_constant?}), or one of {FRESH_COPIERS} called on the parameter itself. Any
6938
+ # other call may return its receiver.
6939
+ def fresh_value?(value, name)
6940
+ case value
6941
+ when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode then true
6942
+ when Prism::CallNode then fresh_call?(value, name)
6943
+ else false
6944
+ end
6945
+ end
6946
+
6947
+ def fresh_call?(call, name)
6948
+ return true if FRESH_DUPLICATORS.include?(call.name)
6949
+ return core_collection_constant?(call.receiver) if call.name == :new
6950
+
6951
+ receiver = call.receiver
6952
+ FRESH_COPIERS.include?(call.name) && receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
6953
+ end
6954
+
6955
+ # `::Array` / `::Hash` / `::String` / `::Set`, written `::`-rooted: the core classes whose `new` always builds a
6956
+ # new object. A project class's `new` may hand back its argument (`Ident.new(acc)`) or wrap it (`Wrap.new(acc)`,
6957
+ # whose `[]=` writes through), so it is no fresh rebind. The census reads the AST alone and cannot resolve a bare
6958
+ # `Array`, which a namespace may shadow (`M::Array`), so only the rooted spelling counts.
6959
+ def core_collection_constant?(node)
6960
+ node.is_a?(Prism::ConstantPathNode) && node.parent.nil? && FRESH_CONSTRUCTED.include?(node.name)
6961
+ end
6962
+
6963
+ FRESH_DUPLICATORS = %i[dup clone].to_set.freeze
6964
+ FRESH_CONSTRUCTED = %i[Array Hash String Set].to_set.freeze
6965
+ FRESH_COPIERS = %i[map collect select reject merge + -].to_set.freeze
6966
+ FRESH_OPERATORS = %i[+ -].to_set.freeze
6967
+ # The calls whose result {#callee_write_census} reads as a new object rather than an alias of the receiver.
6968
+ # The calls {#callee_write_census} reads as a new container of the receiver's elements, on an Array, a Hash and a
6969
+ # String alike: a write to the result misses the receiver, an element of it may be a part. `to_a`, `to_h`
6970
+ # (without a block; `to_h { }` is read as one in {CalleeWriteCensus}), `to_s`, `each_slice`, `each_cons` and
6971
+ # `each_with_index` stay out: each returns its receiver on one of them.
6972
+ CENSUS_COPIERS = %i[
6973
+ slice except compact transform_values transform_keys filter_map group_by partition sort sort_by uniq flatten
6974
+ reverse keys values inspect zip filter flat_map collect_concat take drop
6975
+ ].to_set.freeze
6976
+ # The calls whose result {#callee_write_census} reads as a new object holding nothing of the receiver's.
6977
+ FRESH_RESULTS = (FRESH_DUPLICATORS | FRESH_COPIERS | %i[join]).freeze
6978
+ private_constant :FRESH_DUPLICATORS, :FRESH_CONSTRUCTED, :FRESH_COPIERS, :FRESH_OPERATORS, :CENSUS_COPIERS,
6979
+ :FRESH_RESULTS
6980
+
6981
+ # The statements a def body runs in order: a statements list, with a parenthesised list (an endless def's
6982
+ # `= (a; b)`) read in place, or the lone expression. A body with a `rescue` / `ensure` clause (a `BeginNode`)
6983
+ # answers none, so it never rebinds.
6984
+ def body_statements(body)
6985
+ case body
6986
+ when nil, Prism::BeginNode then NO_FORWARDS
6987
+ when Prism::StatementsNode then body.body.flat_map { |statement| body_statements_of(statement) }
6988
+ else [body]
6989
+ end
6990
+ end
6991
+
6992
+ def body_statements_of(statement)
6993
+ statement.is_a?(Prism::ParenthesesNode) ? body_statements(statement.body) : [statement]
6994
+ end
6995
+
4717
6996
  # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
4718
6997
  # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
4719
6998
  # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
@@ -4766,7 +7045,7 @@ module Rigor
4766
7045
  end
4767
7046
 
4768
7047
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
4769
- # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
7048
+ # a callee body where the block-owning call's receiver TYPE is not available. A call whose method name is a known
4770
7049
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
4771
7050
  # captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
4772
7051
  # unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
@@ -4804,14 +7083,21 @@ module Rigor
4804
7083
  # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
4805
7084
  # the current scope — a cheap pre-filter so the def resolution / body scan only runs for calls that could actually
4806
7085
  # floor something.
7086
+ # A local bound in the current scope, or (issue #2335) one whose object another local may share, which the
7087
+ # alias floor reads a callee's write through ({AliasMutationFloor.reaches_other_locals?}).
7088
+ def passed_local?(read, node)
7089
+ scope.locals.key?(read.name) || AliasMutationFloor.reaches_other_locals?(scope, read, node)
7090
+ end
7091
+
4807
7092
  def call_passes_local_argument?(node)
4808
7093
  args = node.arguments
4809
7094
  return false unless args.respond_to?(:arguments)
4810
7095
 
4811
7096
  args.arguments.any? do |arg|
4812
7097
  case arg
4813
- when Prism::LocalVariableReadNode
4814
- scope.locals.key?(arg.name)
7098
+ when Prism::LocalVariableReadNode then passed_local?(arg, node)
7099
+ when Prism::SplatNode
7100
+ arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
4815
7101
  when Prism::KeywordHashNode
4816
7102
  arg.elements.any? do |pair|
4817
7103
  next false unless pair.is_a?(Prism::AssocNode)
@@ -4835,7 +7121,7 @@ module Rigor
4835
7121
  while receiver.is_a?(Prism::CallNode)
4836
7122
  if receiver.block.is_a?(Prism::BlockNode) &&
4837
7123
  classify_closure_escape(receiver) != :non_escaping
4838
- acc = widen_escaping_content_captures(receiver.block, acc)
7124
+ acc = widen_escaping_content_captures(receiver.block, acc, foreign_self: foreign_self_block?(receiver))
4839
7125
  end
4840
7126
  receiver = receiver.receiver
4841
7127
  end
@@ -4853,7 +7139,14 @@ module Rigor
4853
7139
  # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String -> `String`. The seed's element/key/value
4854
7140
  # precision is forgotten; only the carrier survives. Read-only captures and locals the block merely rebinds
4855
7141
  # (already floored by `drop_captured_narrowing`) are untouched.
4856
- def widen_escaping_content_captures(block_node, post_scope)
7142
+ #
7143
+ # An instance variable of this scope's `self` the body mutates in place is floored too (issue #1208): a callback
7144
+ # that runs `@opts[:format] = "json"` later left `@opts[:format]` folding to `"text"`, and so are a class variable
7145
+ # and a global (issue #3096). Each floors member by member ({#member_content_floor}, issue #3095), so a lazily
7146
+ # initialised `@buf = flag ? [1] : nil` keeps its `nil`. An instance variable under a block that runs on another
7147
+ # object is that object's, and so is every one when `foreign_self` says the closure itself does; a class
7148
+ # variable resolves lexically and a global is global, so neither depends on `self`.
7149
+ def widen_escaping_content_captures(block_node, post_scope, foreign_self: false)
4857
7150
  body = block_node.body
4858
7151
  return post_scope if body.nil?
4859
7152
 
@@ -4861,13 +7154,83 @@ module Rigor
4861
7154
  # `local[k] = v` write, which `collect_content_mutations` cannot see (see below).
4862
7155
  post_scope = floor_block_body_callee_escaped_args(body, post_scope)
4863
7156
 
4864
- mutations = collect_content_mutations(body)
4865
- return post_scope if mutations.empty?
7157
+ post_scope = collect_content_mutations(body).keys.reduce(post_scope) do |acc, name|
7158
+ floored = content_floor_for(CapturedLocals.bound_type(acc, name))
7159
+ floored.nil? ? acc : bind_mutated_capture(acc, name, floored)
7160
+ end
7161
+
7162
+ floor_non_local_contents(escaping_non_local_content_mutations(body, post_scope, foreign_self), post_scope)
7163
+ end
7164
+
7165
+ # `scope` with each instance variable, class variable or global in `names` floored member by member.
7166
+ def floor_non_local_contents(names, scope)
7167
+ names.reduce(scope) do |acc, name|
7168
+ bound = CapturedLocals.bound_type(acc, name)
7169
+ floored = member_content_floor(bound)
7170
+ floored.equal?(bound) ? acc : CapturedLocals.bind(acc, name, floored)
7171
+ end
7172
+ end
7173
+
7174
+ # `type` with each member {#content_floor_for} recognises floored to its bare carrier and every other member
7175
+ # kept (issue #3095): `Array[1]?` reads `Array[Dynamic[top]]?` and `3 | [1]` reads `3 | Array[Dynamic[top]]`,
7176
+ # so the floor neither drops the `nil` a callback that never runs leaves nor narrows the binding. `type` itself
7177
+ # when no member floors.
7178
+ def member_content_floor(type)
7179
+ members = type.is_a?(Type::Union) ? type.members : [type]
7180
+ floored = members.map { |member| content_floor_for(member) || member }
7181
+ return type if floored.each_with_index.all? { |member, index| member.equal?(members[index]) }
7182
+
7183
+ Type::Combinator.union(*floored)
7184
+ end
7185
+
7186
+ # The instance variables of `scope`'s `self`, the class variables and the globals an escaping body mutates in
7187
+ # place, on the terms {#capturable_non_local?} reads against `scope` — the continuation the floor writes into.
7188
+ def escaping_non_local_content_mutations(body, scope, foreign_self)
7189
+ names = nil
7190
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
7191
+ name, content = non_local_shape_mutation(node)
7192
+ next if name.nil?
7193
+ next unless (content && capturable_non_local?(name, ancestors, scope, foreign_self: foreign_self)) ||
7194
+ block_defined_method_capture?(name, ancestors, scope, foreign_self)
7195
+
7196
+ (names ||= []) << name
7197
+ end
7198
+ names ? names.uniq : NO_REWRITES
7199
+ end
7200
+
7201
+ # `[name, content]` when `node` mutates an instance variable, class variable or global in place, else nil.
7202
+ # `content` says a content scan counts the mutation ({CONTENT_MUTATORS}, or an index write). A method the block
7203
+ # defines counts every name the straight-line widening responds to as well ({CALLEE_WRITE_MUTATORS}: `merge!`,
7204
+ # `map!`, `clear`, …), because its floor reads no evidence from the call (issue #3163).
7205
+ def non_local_shape_mutation(node)
7206
+ content = index_write?(node)
7207
+ unless content
7208
+ return nil unless node.is_a?(Prism::CallNode) && CALLEE_WRITE_MUTATORS.include?(node.name)
7209
+
7210
+ content = CONTENT_MUTATORS.include?(node.name)
7211
+ end
7212
+ receiver = node.receiver
7213
+ NON_LOCAL_READ_NODES.include?(receiver.class) ? [receiver.name, content] : nil
7214
+ end
7215
+
7216
+ # Issue #3163 — `post_scope` with every instance variable, class variable and global floored member by member
7217
+ # ({#member_content_floor}) that a method defined in `call_node`'s non-escaping block mutates in place
7218
+ # ({#block_defined_method_capture?}). The block, or anything it calls, may call that method before it returns:
7219
+ # `$buf = []; [1].each { def h; $buf << 1; end; h }` leaves `$buf` as `[1]`, and the content join reads no
7220
+ # evidence from a method body, which it types in no scope of the block's. No call is followed, so a method the
7221
+ # block defines and nothing calls floors too. An escaping block floors the same names
7222
+ # ({#escaping_non_local_content_mutations}).
7223
+ def floor_block_defined_method_mutations(call_node, block, post_scope)
7224
+ foreign = foreign_self_block?(call_node)
7225
+ names = nil
7226
+ Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
7227
+ name, = non_local_shape_mutation(node)
7228
+ # Read against the continuation: a variable the block itself first binds has no pre-call binding.
7229
+ next if name.nil? || !block_defined_method_capture?(name, ancestors, post_scope, foreign)
4866
7230
 
4867
- mutations.keys.reduce(post_scope) do |acc, name|
4868
- floored = content_floor_for(acc.local(name))
4869
- floored.nil? ? acc : acc.with_mutated_local(name, floored)
7231
+ (names ||= []) << name
4870
7232
  end
7233
+ names.nil? ? post_scope : floor_non_local_contents(names.uniq, post_scope)
4871
7234
  end
4872
7235
 
4873
7236
  # Inside an ESCAPING block body, a captured outer local can be content- mutated transitively: the body is (or
@@ -4907,6 +7270,18 @@ module Rigor
4907
7270
  Type::Combinator.untyped])
4908
7271
  elsif arrayish?(type)
4909
7272
  Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
7273
+ elsif setish?(type)
7274
+ Type::Combinator.nominal_of("Set", type_args: [Type::Combinator.untyped])
7275
+ end
7276
+ end
7277
+
7278
+ # A `Set` carrier, constant-folded or nominal, for the callee floor above (issue #2294).
7279
+ def setish?(type)
7280
+ case type
7281
+ when Type::Constant then SetMutation.constant?(type)
7282
+ when Type::Nominal then type.class_name == "Set"
7283
+ when Type::Union then type.members.any? { |m| setish?(m) }
7284
+ else false
4910
7285
  end
4911
7286
  end
4912
7287
 
@@ -4981,7 +7356,7 @@ module Rigor
4981
7356
  # continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
4982
7357
  # so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
4983
7358
  def collect_repeated_block_returns(call_node, post_scope)
4984
- return if Thread.current[RETURN_SINK_KEY].nil?
7359
+ return if typing_state[RETURN_SINK].nil?
4985
7360
 
4986
7361
  block = call_node.block
4987
7362
  return unless repeated_block_return_candidate?(call_node, block)
@@ -5021,7 +7396,7 @@ module Rigor
5021
7396
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
5022
7397
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
5023
7398
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
5024
- # — `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
7399
+ # — `[].each { … }` — stays sound), value-pinned widened on the final permitted iteration, and floored to
5025
7400
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
5026
7401
  #
5027
7402
  # The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
@@ -5118,16 +7493,16 @@ module Rigor
5118
7493
  end
5119
7494
  end
5120
7495
 
5121
- # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
5122
- # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
5123
- # rebinds no ivar leaves the locals exactly as before.
7496
+ # Binds a name from `CapturedLocals.writes`. An ivar (or any other non-local) goes through
7497
+ # {CapturedLocals.bind}, which keeps an ivar's issue #286 optimistic mark. A local keeps the plain `with_local`
7498
+ # these seams have always used, so a block that rebinds no ivar leaves the locals exactly as before.
5124
7499
  #
5125
7500
  # Issue #1402 — `optimistic` is a mark a write-back pass's exit held ({#with_capture_exit_marks}); a name that has
5126
7501
  # one binds through {CapturedLocals.bind} whatever its kind, which adds it.
5127
7502
  def bind_capture(scope, name, type, optimistic: nil)
5128
7503
  return CapturedLocals.bind(scope, name, type, optimistic: optimistic) if optimistic
5129
7504
 
5130
- CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
7505
+ CapturedLocals.local_name?(name) ? scope.with_local(name, type) : CapturedLocals.bind(scope, name, type)
5131
7506
  end
5132
7507
 
5133
7508
  # A block-level `break` ends the CALL, so the binding it leaves with starts no further iteration and is no input
@@ -5155,9 +7530,8 @@ module Rigor
5155
7530
  end
5156
7531
  return converged if arms.empty?
5157
7532
 
5158
- floor = Type::Combinator.untyped
5159
7533
  converged.to_h do |name, type|
5160
- next [name, type] if type == floor
7534
+ next [name, type] if Type::Combinator.untyped?(type)
5161
7535
 
5162
7536
  [name, Type::Combinator.union(type, *arms.filter_map { |arm| CapturedLocals.bound_type(arm, name) })]
5163
7537
  end
@@ -5216,22 +7590,48 @@ module Rigor
5216
7590
  block = call_node.block
5217
7591
  return post_scope unless block.is_a?(Prism::BlockNode)
5218
7592
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
7593
+ return post_scope if block.body.nil?
5219
7594
 
5220
- body = block.body
5221
- return post_scope if body.nil?
7595
+ post_scope = join_block_content_captures(call_node, block, post_scope, seed_scope)
7596
+ # Issue #3163 — a method the block defines may run during the block, so its in-place mutations of an instance
7597
+ # variable, class variable or global floor that binding. The join reads no evidence from a method body.
7598
+ floor_block_defined_method_mutations(call_node, block, post_scope)
7599
+ end
5222
7600
 
7601
+ # The join {#content_writeback_block_captures} documents, for a non-escaping `block` with a body.
7602
+ def join_block_content_captures(call_node, block, post_scope, seed_scope)
7603
+ body = block.body
5223
7604
  shadows = {}.compare_by_identity
5224
- mutations = captured_content_mutations(block, shadows)
7605
+ mutations = captured_content_mutations(block, shadows, call_node)
5225
7606
  return post_scope if mutations.empty?
5226
7607
 
5227
7608
  seeds = mutations.to_h do |name, _calls|
5228
- [name, lookup_mutated_seed(body, name, seed_scope.local(name)) { |depth, nesting| depth > nesting }]
7609
+ seed = CapturedLocals.bound_type(seed_scope, name)
7610
+ [name, lookup_mutated_seed(body, name, seed) { |depth, nesting| depth > nesting }]
5229
7611
  end
5230
- shadow_rebound_reads(block, mutations, seeds, shadows)
7612
+ shadow_rebound_reads(block, mutations, seeds, shadows, call_node)
5231
7613
  joined = join_content_to_fixpoint(mutations, seeds, build_block_entry_scope(call_node, block), shadows)
5232
7614
  rewrites = local_rewrites(block.body) { |receiver, ancestors| receiver.depth > scope_nesting(ancestors) }
5233
7615
  joined.reduce(post_scope) do |acc, (name, type)|
5234
- acc.with_mutated_local(name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
7616
+ bind_mutated_capture(acc, name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
7617
+ end
7618
+ end
7619
+
7620
+ # `scope` with a capture the block mutated in place bound to its joined or floored content: a local through
7621
+ # `Scope#with_mutated_local`, which keeps the marks a source-level write drops, and an instance variable, class
7622
+ # variable or global through {CapturedLocals.bind}, which keeps an ivar's optimistic nil-freeness mark. The
7623
+ # binding still names the same object.
7624
+ def bind_mutated_capture(scope, name, type)
7625
+ CapturedLocals.local_name?(name) ? scope.with_mutated_local(name, type) : CapturedLocals.bind(scope, name, type)
7626
+ end
7627
+
7628
+ # `scope` with a content seam's working binding for `name` — its join so far, or `Dynamic[top]` for a read the
7629
+ # body rebinds — whatever kind of variable it is.
7630
+ def bind_capture_slot(scope, name, type)
7631
+ case CapturedLocals.variable_kind(name)
7632
+ when :local then scope.with_local(name, type)
7633
+ when :ivar then scope.with_ivar(name, type)
7634
+ else CapturedLocals.bind(scope, name, type)
5235
7635
  end
5236
7636
  end
5237
7637
 
@@ -5240,19 +7640,29 @@ module Rigor
5240
7640
 
5241
7641
  # The {RewriteMutation} names `root` calls on each local its block admits — `a.map!(&:to_s)` beside an `a << x`.
5242
7642
  # The receiver test is the one the caller's content-mutation walk applies.
5243
- def local_rewrites(root)
7643
+ def local_rewrites(root, &)
5244
7644
  rewrites = {}
5245
7645
  Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
5246
7646
  next unless node.is_a?(Prism::CallNode) && RewriteMutation.rewriter?(node.name)
5247
7647
 
5248
7648
  receiver = node.receiver
5249
- next unless receiver.is_a?(Prism::LocalVariableReadNode) && yield(receiver, ancestors)
7649
+ next unless rewritten_capture_receiver?(receiver, ancestors, &)
5250
7650
 
5251
7651
  (rewrites[receiver.name] ||= []) << node.name
5252
7652
  end
5253
7653
  rewrites
5254
7654
  end
5255
7655
 
7656
+ # A local the caller's test admits, or an instance variable, class variable or global outside a body that makes
7657
+ # it another variable ({#under_scope_body?}): the names the content walks can collect.
7658
+ def rewritten_capture_receiver?(receiver, ancestors)
7659
+ case receiver
7660
+ when Prism::LocalVariableReadNode then yield(receiver, ancestors)
7661
+ when *NON_LOCAL_READ_NODES then !under_scope_body?(ancestors, CapturedLocals.variable_kind(receiver.name))
7662
+ else false
7663
+ end
7664
+ end
7665
+
5256
7666
  # The slice-C join (and the loop seam's) rebuilds a collection from its SEED, so the rewrite the body's own
5257
7667
  # widening applied is gone from it: `a = [1]; [0].each { a.map!(&:to_s); a << "x" }` read `Array["x" | 1]`, and
5258
7668
  # `a[0] == "1"` folded always-falsey. Each rewrite the body makes on the local is re-applied here, on the seam's
@@ -5279,10 +7689,14 @@ module Rigor
5279
7689
  # - joined with the block-entry typing, it still stores exit values no store reads;
5280
7690
  # - one more walk of the body to the store inherits every gap in the engine's in-body flow.
5281
7691
  #
7692
+ # An instance variable the body rebinds is one too (issue #1235): `@total += x; out << @total` stored the
7693
+ # pre-call `@total`, and so are a class variable and a global (issue #3096). Each counts on the terms the content
7694
+ # walk takes one ({#capturable_non_local?}).
7695
+ #
5282
7696
  # See ADR-56 WD2.13.
5283
- def shadow_rebound_reads(block, sites, seeds, shadows)
7697
+ def shadow_rebound_reads(block, sites, seeds, shadows, call_node)
5284
7698
  entry_names = scope.locals.keys | CapturedLocals.introduced_locals(block).to_a
5285
- written = scope_local_writes(block) & entry_names
7699
+ written = (scope_local_writes(block) & entry_names) | rebound_capturable_non_locals(block, call_node)
5286
7700
  return if written.empty?
5287
7701
 
5288
7702
  sites.each do |name, nodes|
@@ -5296,6 +7710,23 @@ module Rigor
5296
7710
  end
5297
7711
  end
5298
7712
 
7713
+ # The instance variables, class variables and globals the block body writes that {#capturable_non_local?}
7714
+ # accepts; no instance variable for a block that runs on another object. Every self-rebinding call classifies
7715
+ # `:unknown` today, so neither content seam that calls this meets one; the guard keeps the answer right should
7716
+ # the escape analysis learn to call one non-escaping.
7717
+ def rebound_capturable_non_locals(block, call_node)
7718
+ return NO_REWRITES if block.body.nil?
7719
+
7720
+ foreign = foreign_self_block?(call_node)
7721
+ names = []
7722
+ Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
7723
+ next unless CapturedLocals::NON_LOCAL_WRITE_NODES.include?(node.class)
7724
+
7725
+ names << node.name if capturable_non_local?(node.name, ancestors, scope, foreign_self: foreign)
7726
+ end
7727
+ names.uniq
7728
+ end
7729
+
5299
7730
  # The locals a store reads to build what it stores. An Array index write's index arguments are left out, and so
5300
7731
  # is any name they read: the join classifies the store as an element or a splice from the index's type, and a
5301
7732
  # `Dynamic` index reads as both, so `grid[i] = [x, x]` would join `x` itself as a member of `grid` beside the
@@ -5334,6 +7765,29 @@ module Rigor
5334
7765
  SCOPE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
5335
7766
  private_constant :SCOPE_BODY_NODES
5336
7767
 
7768
+ CLASS_BODY_NODES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
7769
+ private_constant :CLASS_BODY_NODES
7770
+
7771
+ # The bodies under a block body where a non-local of each kind is no longer the one the block's caller sees, or
7772
+ # does not run while the block does. A `def` body runs only when called. A class or module body is a class of
7773
+ # its own for an instance variable and a class variable, but runs at once and reads the one global. `class <<
7774
+ # self` keeps the enclosing class's class variables, so only an instance variable leaves there.
7775
+ NON_LOCAL_SCOPE_BODY_NODES = {
7776
+ ivar: SCOPE_BODY_NODES,
7777
+ cvar: [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze,
7778
+ global: [Prism::DefNode].freeze
7779
+ }.freeze
7780
+ private_constant :NON_LOCAL_SCOPE_BODY_NODES
7781
+
7782
+ # True when a non-local of `kind` (`:ivar`, `:cvar` or `:global`) at `ancestors` under a block body sits in a body
7783
+ # the block defines that makes it another variable or does not run during the block
7784
+ # ({NON_LOCAL_SCOPE_BODY_NODES}): for an instance variable, any method, class or module body, where `self` is that
7785
+ # body's own.
7786
+ def under_scope_body?(ancestors, kind = :ivar)
7787
+ bodies = NON_LOCAL_SCOPE_BODY_NODES.fetch(kind)
7788
+ ancestors.any? { |a| bodies.any? { |k| a.is_a?(k) } }
7789
+ end
7790
+
5337
7791
  # True when the local `node` names lives in the scope the walk started in or an outer one.
5338
7792
  def same_scope_local?(node, ancestors)
5339
7793
  return false if ancestors.any? { |ancestor| SCOPE_BODY_NODES.any? { |klass| ancestor.is_a?(klass) } }
@@ -5350,16 +7804,37 @@ module Rigor
5350
7804
  ].freeze
5351
7805
  private_constant :LOCAL_READ_NODES
5352
7806
 
5353
- # Every local `node` reads from the scope it sits in: a read inside an inner block of a name that block
5354
- # introduces is a different variable.
7807
+ # The same for an instance variable, a class variable and a global.
7808
+ NON_LOCAL_VALUE_READ_NODES = Set[
7809
+ Prism::InstanceVariableReadNode,
7810
+ Prism::InstanceVariableOperatorWriteNode,
7811
+ Prism::InstanceVariableOrWriteNode,
7812
+ Prism::InstanceVariableAndWriteNode,
7813
+ Prism::ClassVariableReadNode,
7814
+ Prism::ClassVariableOperatorWriteNode,
7815
+ Prism::ClassVariableOrWriteNode,
7816
+ Prism::ClassVariableAndWriteNode,
7817
+ Prism::GlobalVariableReadNode,
7818
+ Prism::GlobalVariableOperatorWriteNode,
7819
+ Prism::GlobalVariableOrWriteNode,
7820
+ Prism::GlobalVariableAndWriteNode
7821
+ ].freeze
7822
+ private_constant :NON_LOCAL_VALUE_READ_NODES
7823
+
7824
+ # Every local `node` reads from the scope it sits in, and every instance variable, class variable or global
7825
+ # outside a nested method or class body: a read inside an inner block of a name that block introduces is a
7826
+ # different variable. The caller intersects the answer with the names the body writes, which
7827
+ # {#rebound_capturable_non_locals} has already filtered.
5355
7828
  def local_reads(node)
5356
7829
  return [] if node.nil?
5357
7830
 
5358
7831
  names = []
5359
7832
  Source::NodeWalker.each_with_ancestors(node) do |n, ancestors|
5360
- next unless LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
5361
-
5362
- names << n.name if same_scope_local?(n, ancestors)
7833
+ if LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
7834
+ names << n.name if same_scope_local?(n, ancestors)
7835
+ elsif NON_LOCAL_VALUE_READ_NODES.include?(n.class)
7836
+ names << n.name unless under_scope_body?(ancestors, CapturedLocals.variable_kind(n.name))
7837
+ end
5363
7838
  end
5364
7839
  names.uniq
5365
7840
  end
@@ -5445,7 +7920,7 @@ module Rigor
5445
7920
 
5446
7921
  def bind_content_joins(scope, kinds, seeds, evidence)
5447
7922
  kinds.reduce(scope) do |acc, (name, kind)|
5448
- acc.with_local(name, content_entry_binding(seeds[name], kind, name, evidence))
7923
+ bind_capture_slot(acc, name, content_entry_binding(seeds[name], kind, name, evidence))
5449
7924
  end
5450
7925
  end
5451
7926
 
@@ -5478,10 +7953,23 @@ module Rigor
5478
7953
  #
5479
7954
  # Each site nested in an inner block or lambda is recorded in `shadows` with the names those blocks bind
5480
7955
  # themselves (see {#join_content_to_fixpoint}).
5481
- def captured_content_mutations(block, shadows)
7956
+ #
7957
+ # Issue #1208 — an instance variable is a capture too: every block made in a method shares its `self`, so `@out
7958
+ # << x` in the body grows the method's `@out` exactly as `out << x` grows `out`. It counts on the terms
7959
+ # {CapturedLocals.writes} takes a rebound one: bound at the call site and not on its ADR-58 declaration seed
7960
+ # ({CapturedLocals.rebindable_ivar?}), outside a nested method or class body, and not in a block that runs on
7961
+ # another object ({CapturedLocals.under_foreign_self_block?}, or `call_node`'s own block).
7962
+ #
7963
+ # Issue #3096 — a class variable and a global are captures on the same terms, less the foreign-self one: Ruby
7964
+ # resolves a class variable lexically, and a global is the same one whatever `self` is.
7965
+ def captured_content_mutations(block, shadows, call_node)
5482
7966
  mutations = Hash.new { |h, k| h[k] = [] }
7967
+ # Every self-rebinding call classifies `:unknown` today, so this seam does not meet one; the guard keeps the
7968
+ # ivar arm right should the escape analysis learn to call one non-escaping.
7969
+ foreign = foreign_self_block?(call_node)
5483
7970
  Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
5484
7971
  name, site = content_mutation_target(node) { |receiver| receiver.depth > scope_nesting(ancestors) }
7972
+ name, site = non_local_content_mutation_target(node, ancestors, foreign) if name.nil?
5485
7973
  next if name.nil?
5486
7974
 
5487
7975
  mutations[name] << site
@@ -5490,6 +7978,89 @@ module Rigor
5490
7978
  mutations
5491
7979
  end
5492
7980
 
7981
+ # `[name, node]` when `node` mutates the content of an instance variable of this scope's `self`, a class
7982
+ # variable or a global that the content seams may join ({#captured_content_mutations}), else
7983
+ # {NO_CONTENT_MUTATION}. `foreign_self` says the seam's block runs on another object.
7984
+ def non_local_content_mutation_target(node, ancestors, foreign_self)
7985
+ is_call_mutator = node.is_a?(Prism::CallNode) && CONTENT_MUTATORS.include?(node.name)
7986
+ return NO_CONTENT_MUTATION unless is_call_mutator || index_write?(node)
7987
+
7988
+ receiver = node.receiver
7989
+ return NO_CONTENT_MUTATION unless NON_LOCAL_READ_NODES.include?(receiver.class)
7990
+ return NO_CONTENT_MUTATION unless capturable_non_local?(receiver.name, ancestors, scope, foreign_self:)
7991
+ return NO_CONTENT_MUTATION unless collection_only?(CapturedLocals.bound_type(scope, receiver.name))
7992
+
7993
+ [receiver.name, node]
7994
+ end
7995
+
7996
+ # The receiver reads of a variable the content seams take besides a local.
7997
+ NON_LOCAL_READ_NODES = Set[
7998
+ Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
7999
+ ].freeze
8000
+ private_constant :NON_LOCAL_READ_NODES
8001
+
8002
+ # True when every member of `type` is an Array, a Hash or a String carrier. The non-escaping join takes an
8003
+ # instance variable only then: it drops a seed's `nil` after a guarded mutation (#1219), so `@buf = flag ? [1] :
8004
+ # nil; [1].each { @buf << 2 if @buf }` would lose the `nil` the runtime keeps when the block never runs it. The
8005
+ # local spelling has the gap; an instance variable, the common lazily-initialised spelling, keeps its pre-call
8006
+ # binding until it closes. The escaping floor takes every instance variable ({#member_content_floor}).
8007
+ def collection_only?(type)
8008
+ return false if type.nil?
8009
+ return type.members.all? { |member| collection_only?(member) } if type.is_a?(Type::Union)
8010
+
8011
+ arrayish?(type) || hashish?(type) || UnknownStoreWidening.carrier_class(type) == "String"
8012
+ end
8013
+
8014
+ # True when the non-local `name`, read or written at `ancestors` under a block body, is one the content seams
8015
+ # take against `in_scope`: bound there ({CapturedLocals.rebindable_non_local?}, which keeps an instance
8016
+ # variable on its declaration seed out) and outside a body the block defines that makes it another variable or
8017
+ # runs later ({#under_scope_body?}).
8018
+ #
8019
+ # - An instance variable must be `self`'s: not under a block that runs on another object, nor in a seam whose
8020
+ # block does (`foreign_self`), nor in any method, class or module body.
8021
+ # - A class variable resolves lexically and a global is global, so `self` does not matter to either (issue
8022
+ # #3096). A `def` body runs only when called, so neither counts there. A class or module body runs at once:
8023
+ # it reads its own class variables but the one global, so a global counts there. `class << self` keeps the
8024
+ # enclosing class's class variables, so a class variable counts there.
8025
+ # - A global Ruby's own signatures declare (`$stdout`, `$;`, `$0`, the frame-local `$~` and `$_`, …) is left
8026
+ # alone: the interpreter owns it, and some of them read through a getter (`$0` answers a frozen copy).
8027
+ def capturable_non_local?(name, ancestors, in_scope, foreign_self: false)
8028
+ return false if under_scope_body?(ancestors, CapturedLocals.variable_kind(name))
8029
+
8030
+ non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8031
+ end
8032
+
8033
+ # Issue #3163 — true when the non-local `name`, mutated at `ancestors` under a block body, sits in a method the
8034
+ # block defines and is one the content floor takes against `in_scope`. {#capturable_non_local?} leaves a `def`
8035
+ # body out because it runs only when called, but the block may call it. The terms are that method's, less the
8036
+ # `def` boundary:
8037
+ #
8038
+ # - A class or module body or a `class << self` between the block and the mutation still leaves every kind out:
8039
+ # a method defined there is another class's, and the walks do not ask who calls it.
8040
+ # - An instance variable in a method defined on an explicit receiver other than `self` (`def other.m`) is that
8041
+ # object's. A plain `def` counts wherever it is written, so one in a class body's block, whose instance
8042
+ # variables are an instance's and not the class's, floors the class's too.
8043
+ def block_defined_method_capture?(name, ancestors, in_scope, foreign_self)
8044
+ defs = ancestors.grep(Prism::DefNode)
8045
+ return false if defs.empty? || ancestors.any? { |a| CLASS_BODY_NODES.any? { |k| a.is_a?(k) } }
8046
+
8047
+ foreign_def = defs.any? { |d| !d.receiver.nil? && !d.receiver.is_a?(Prism::SelfNode) }
8048
+ return false if foreign_def && CapturedLocals.variable_kind(name) == :ivar
8049
+
8050
+ non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8051
+ end
8052
+
8053
+ # The half of {#capturable_non_local?} that does not read the bodies between the block and the non-local.
8054
+ def non_local_of_scope?(name, ancestors, in_scope, foreign_self)
8055
+ case CapturedLocals.variable_kind(name)
8056
+ when :ivar
8057
+ return false if foreign_self || CapturedLocals.under_foreign_self_block?(ancestors, in_scope)
8058
+ when :global
8059
+ return false if in_scope.environment.global_for_name(name, builtin: true)
8060
+ end
8061
+ CapturedLocals.rebindable_non_local?(in_scope, name)
8062
+ end
8063
+
5493
8064
  def scope_nesting(ancestors)
5494
8065
  ancestors.count { |ancestor| scope_nesting_node?(ancestor) }
5495
8066
  end
@@ -5510,7 +8081,7 @@ module Rigor
5510
8081
  names = shadows[site]
5511
8082
  return scope if names.nil?
5512
8083
 
5513
- names.reduce(scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
8084
+ names.reduce(scope) { |acc, name| bind_capture_slot(acc, name, Type::Combinator.untyped) }
5514
8085
  end
5515
8086
 
5516
8087
  # `base` binds every fixed name to its join; the moving names are rebound on each pass.
@@ -5522,7 +8093,7 @@ module Rigor
5522
8093
  widen: Type::Combinator.method(:widen_value_pinned),
5523
8094
  evaluate_body: lambda do |assumption|
5524
8095
  pass_entry = kinds.reduce(base) do |acc, (name, kind)|
5525
- acc.with_local(name, content_carrier_under(seeds[name], kind, name, assumption))
8096
+ bind_capture_slot(acc, name, content_carrier_under(seeds[name], kind, name, assumption))
5526
8097
  end
5527
8098
  content_evidence(sites, kinds, pass_entry, shadows)
5528
8099
  end
@@ -5633,15 +8204,23 @@ module Rigor
5633
8204
  type.nil? || type.is_a?(Type::Bot) ? [] : [type]
5634
8205
  end
5635
8206
 
8207
+ # A joined name is a local, an instance variable (#1208), a class variable or a global (#3096), so a store
8208
+ # reading any of them moves.
8209
+ CAPTURE_READ_NODES = Set[
8210
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
8211
+ Prism::GlobalVariableReadNode
8212
+ ].freeze
8213
+ private_constant :CAPTURE_READ_NODES
8214
+
5636
8215
  def store_arguments_read?(node, names)
5637
8216
  arguments = node.arguments
5638
8217
  return false if arguments.nil?
5639
8218
 
5640
- Source::NodeWalker.each(arguments).any? { |n| n.is_a?(Prism::LocalVariableReadNode) && names.include?(n.name) }
8219
+ Source::NodeWalker.each(arguments).any? { |n| CAPTURE_READ_NODES.include?(n.class) && names.include?(n.name) }
5641
8220
  end
5642
8221
 
5643
8222
  # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
5644
- # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured- local content
8223
+ # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured-local content
5645
8224
  # mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
5646
8225
  # memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
5647
8226
  # is not a collection.
@@ -5660,33 +8239,250 @@ module Rigor
5660
8239
  body = block.body
5661
8240
  return call_type if body.nil?
5662
8241
 
8242
+ memo_after_block(call_node, call_type, memo_param, body)
8243
+ end
8244
+
8245
+ # {#each_with_object_return}'s answer once the call is known to bind a memo parameter in a block with a body.
8246
+ def memo_after_block(call_node, call_type, memo_param, body)
8247
+ memo_arg = call_node.arguments.arguments.first
8248
+ memo_type = scope.type_of(memo_arg, tracer: tracer)
8249
+ pre_state = lookup_mutated_seed(body, memo_param, memo_type, MEMO_SEED_MUTATORS) do |depth, nesting|
8250
+ depth == nesting
8251
+ end
8252
+ reach = memo_scan_reach(body, memo_param)
8253
+ return content_floor_for(pre_state) || call_type if reach == :escape
8254
+
5663
8255
  # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
5664
8256
  # captured-local walk.
5665
8257
  shadows = {}.compare_by_identity
5666
8258
  calls = body_content_mutations_on(body, memo_param, shadows)
5667
- return call_type if calls.empty?
8259
+ memo = if calls.empty?
8260
+ # Issue #2411 — a memo the block only reshapes (`m.merge!(s)`, `m.delete(k)`) answers its widened seed.
8261
+ pre_state.equal?(memo_type) ? call_type : pre_state
8262
+ else
8263
+ join_memo_content(call_node, memo_param, calls, pre_state, shadows) || call_type
8264
+ end
8265
+ memo = floor_memo_callee_escapes(body, memo_param, memo)
8266
+ reach == :values ? memo_value_floor(memo) : memo
8267
+ end
8268
+
8269
+ # Issue #2411 — `memo` with its values or elements floored to `Dynamic[top]` and a Hash's keys kept: a reader's
8270
+ # value is an element the block may then mutate where no scan sees it (`h[x] << "s"`, `v = h[:k]; v << "s"`,
8271
+ # `h[:k][:n] = "s"`), so the join's element types are not what the memo holds. A memo whose every value or
8272
+ # element is of a class no method mutates ({MEMO_IMMUTABLE_CLASSES}) keeps its join: `m[k] = m[k] + 1` reads an
8273
+ # Integer, which nothing in the block can change.
8274
+ def memo_value_floor(type)
8275
+ return type if memo_contents_immutable?(type)
5668
8276
 
5669
- pre_state = lookup_mutated_seed(body, memo_param, scope.type_of(memo_arg, tracer: tracer)) do |depth, nesting|
5670
- depth == nesting
8277
+ case type
8278
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| memo_value_floor(member) })
8279
+ when Type::Nominal
8280
+ return content_floor_for(type) || type unless type.class_name == "Hash" && type.type_args.size == 2
8281
+
8282
+ Type::Combinator.nominal_of("Hash", type_args: [type.type_args.first, Type::Combinator.untyped])
8283
+ else content_floor_for(type) || type
8284
+ end
8285
+ end
8286
+
8287
+ # The classes whose instances no method mutates.
8288
+ MEMO_IMMUTABLE_CLASSES = Set[
8289
+ "Integer", "Float", "Symbol", "NilClass", "TrueClass", "FalseClass", "Rational", "Complex"
8290
+ ].freeze
8291
+ private_constant :MEMO_IMMUTABLE_CLASSES
8292
+
8293
+ # Whether every value of a Hash memo, or every element of an Array memo, is of a {MEMO_IMMUTABLE_CLASSES} class.
8294
+ def memo_contents_immutable?(type)
8295
+ case type
8296
+ when Type::Union then type.members.all? { |member| memo_contents_immutable?(member) }
8297
+ when Type::Nominal
8298
+ slot = type.class_name == "Hash" ? 1 : 0
8299
+ %w[Hash Array].include?(type.class_name) && !type.type_args[slot].nil? &&
8300
+ memo_immutable_value?(type.type_args[slot])
8301
+ when Type::Tuple then type.elements.all? { |element| memo_immutable_value?(element) }
8302
+ when Type::HashShape then type.pairs.values.all? { |value| memo_immutable_value?(value) }
8303
+ else false
8304
+ end
8305
+ end
8306
+
8307
+ def memo_immutable_value?(type)
8308
+ case type
8309
+ when Type::Union then type.members.all? { |member| memo_immutable_value?(member) }
8310
+ when Type::Constant then MEMO_IMMUTABLE_CLASSES.include?(type.value.class.name)
8311
+ when Type::Nominal then MEMO_IMMUTABLE_CLASSES.include?(type.class_name)
8312
+ when Type::IntegerRange, Type::FloatRange then true
8313
+ else false
8314
+ end
8315
+ end
8316
+
8317
+ # Issue #2411 — whether the block can change the memo where neither the join nor the callee floor sees it, so
8318
+ # the memo answers its content floor ({#content_floor_for}) instead of the join. Read as an expression, the
8319
+ # join's answer reaches every use of the call, and one that misses a store reports on correct code:
8320
+ # `|x, h| g = h; g["s"] = "v"; h[:a] = 2` joined `Hash[:a, 2]`, and `.fetch("s").upcase` read `2`. Every read
8321
+ # or write of the memo's name in its own scope ({#scope_nesting}) must be one the scan models
8322
+ # ({#memo_read_modelled?}); a rebinding write (`h = {}`) is not, since the stores after it reach another object.
8323
+ # Answers `:escape` for such a read or write, `:values` when the only unmodelled reads are readers whose value
8324
+ # is used ({#memo_value_floor}), and nil when every read is modelled.
8325
+ def memo_scan_reach(body, memo_param)
8326
+ reach = nil
8327
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
8328
+ next unless MEMO_NAME_NODES.any? { |klass| node.is_a?(klass) } && node.name == memo_param
8329
+ next unless node.depth == scope_nesting(ancestors)
8330
+
8331
+ modelled = node.is_a?(Prism::LocalVariableReadNode) && memo_read_modelled?(node, ancestors)
8332
+ return :escape unless modelled
8333
+
8334
+ reach = :values if modelled == :values
8335
+ end
8336
+ reach
8337
+ end
8338
+
8339
+ # The nodes that read or bind a local by name. `LocalVariableTargetNode` covers a multi-assign and a `rescue =>`.
8340
+ MEMO_NAME_NODES = [
8341
+ Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode, Prism::LocalVariableTargetNode,
8342
+ Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
8343
+ ].freeze
8344
+ private_constant :MEMO_NAME_NODES
8345
+
8346
+ # The memo reads that cannot hand the object to anything the scan does not see. A call on the memo is one when
8347
+ # it takes no block (`tap`, `each` and `then` yield it) and the scan knows the method: one whose value never is
8348
+ # the memo ({MEMO_VALUE_READERS}), or a mutator the join or the seed widening reads ({CONTENT_MUTATORS},
8349
+ # {MEMO_SEED_MUTATORS}) whose value, the memo for `h << x` and `h.merge!(s)`, is discarded
8350
+ # ({#memo_value_discarded?}). Any other name is not: `h.send(:store, :t, "t")` stores what no scan sees. An index
8351
+ # compound write on it is one. So is an argument of a self-call that resolves to a user def, whose census the
8352
+ # callee floor reads ({#floor_memo_callee_escapes}), when the call's value is discarded: `keep(h)["s"] = "v"`
8353
+ # stores through the value. Any other read — `g = h`, `[h]`, `Helper.fill(h, x)`, an unresolved self-call — is
8354
+ # not.
8355
+ def memo_read_modelled?(read, ancestors)
8356
+ parent = ancestors.last
8357
+ if parent.respond_to?(:receiver) && parent.receiver.equal?(read)
8358
+ return true if INDEX_WRITE_NODES.any? { |klass| parent.is_a?(klass) }
8359
+ return false unless parent.is_a?(Prism::CallNode) && parent.block.nil?
8360
+
8361
+ return memo_receiver_call_modelled?(parent, ancestors[0...-1])
8362
+ end
8363
+ memo_self_call_argument?(ancestors)
8364
+ end
8365
+
8366
+ # True, false, or `:values` for a reader whose value is used: the value may be an element the block mutates.
8367
+ # A mutator given a splat or `**` argument is not modelled: the join reads each argument by position, and
8368
+ # `h.store(*kv)` stored a pair it never saw.
8369
+ def memo_receiver_call_modelled?(call, ancestors)
8370
+ mutator = CONTENT_MUTATORS.include?(call.name) || MEMO_SEED_MUTATORS.include?(call.name)
8371
+ return false if mutator && memo_splat_arguments?(call)
8372
+ return memo_value_discarded?(call, ancestors) || :values if MEMO_VALUE_READERS.include?(call.name)
8373
+ return false unless mutator
8374
+
8375
+ memo_value_discarded?(call, ancestors)
8376
+ end
8377
+
8378
+ def memo_splat_arguments?(call)
8379
+ (call.arguments&.arguments || []).any? do |argument|
8380
+ argument.is_a?(Prism::SplatNode) ||
8381
+ (argument.is_a?(Prism::KeywordHashNode) && argument.elements.any?(Prism::AssocSplatNode))
8382
+ end
8383
+ end
8384
+
8385
+ # Methods of Hash and Array whose value is never the receiver itself, so using it hands nothing on.
8386
+ MEMO_VALUE_READERS = Set[
8387
+ :[], :[]=, :store, :fetch, :dig, :key?, :has_key?, :include?, :member?, :key, :value?, :has_value?,
8388
+ :size, :length, :count, :empty?, :any?, :none?, :keys, :values, :values_at, :fetch_values, :first, :last,
8389
+ :delete, :pop, :shift, :to_s, :inspect, :hash, :==, :!=, :eql?, :equal?, :frozen?, :nil?, :is_a?,
8390
+ :kind_of?, :instance_of?, :respond_to?, :class, :join, :sum, :min, :max, :dup, :clone
8391
+ ].freeze
8392
+ private_constant :MEMO_VALUE_READERS
8393
+
8394
+ # Iterators whose block's value the call never reads.
8395
+ MEMO_BLOCK_VALUE_DROPPING = Set[
8396
+ :each, :each_with_index, :each_pair, :each_key, :each_value, :each_index, :reverse_each, :each_slice,
8397
+ :each_cons, :each_entry, :each_char, :each_line, :each_byte, :times, :upto, :downto, :step, :loop
8398
+ ].freeze
8399
+ private_constant :MEMO_BLOCK_VALUE_DROPPING
8400
+
8401
+ # Whether `node`'s value is dropped: a statement that is not its sequence's last, or the last one of a sequence
8402
+ # whose own value is dropped, up through `if` / `unless` arms, `begin` / `rescue` bodies and parentheses, to the
8403
+ # `each_with_object` block body, whose value the call never answers, or to the body of a nested block whose
8404
+ # call never reads its value ({MEMO_BLOCK_VALUE_DROPPING}: `xs.each { |y| acc << y }`). Any other block's value
8405
+ # may be the memo handed on (`map { acc << y }`).
8406
+ def memo_value_discarded?(node, ancestors)
8407
+ return true if ancestors.empty?
8408
+
8409
+ parent = ancestors.last
8410
+ up = ancestors[0...-1]
8411
+ case parent
8412
+ when Prism::StatementsNode
8413
+ return true unless parent.body.last.equal?(node)
8414
+
8415
+ block = up.last
8416
+ return memo_value_discarded?(parent, up) unless block.is_a?(Prism::BlockNode)
8417
+
8418
+ up[-2].is_a?(Prism::CallNode) && MEMO_BLOCK_VALUE_DROPPING.include?(up[-2].name)
8419
+ when Prism::IfNode, Prism::UnlessNode
8420
+ !parent.predicate.equal?(node) && memo_value_discarded?(parent, up)
8421
+ when Prism::ElseNode, Prism::BeginNode, Prism::RescueNode, Prism::ParenthesesNode
8422
+ memo_value_discarded?(parent, up)
8423
+ when Prism::EnsureNode then true
8424
+ else false
8425
+ end
8426
+ end
8427
+
8428
+ # Whether the read under `ancestors` is a positional or keyword argument of a self-call resolving to a user def.
8429
+ # Its value must be discarded: the callee may return the memo (`def keep(h) = h`) or wrap it.
8430
+ def memo_self_call_argument?(ancestors)
8431
+ depth = ancestors.size - 1
8432
+ depth -= 2 if ancestors[depth].is_a?(Prism::AssocNode) && ancestors[depth - 1].is_a?(Prism::KeywordHashNode)
8433
+ call = ancestors[depth - 1]
8434
+ return false unless ancestors[depth].is_a?(Prism::ArgumentsNode) && call.is_a?(Prism::CallNode)
8435
+ return false unless self_dispatch_call?(call) && memo_value_discarded?(call, ancestors[0...(depth - 1)])
8436
+
8437
+ !resolve_self_callees(call).empty?
8438
+ end
8439
+
8440
+ # Issue #2411 — `memo` floored as a captured local is when the block hands it to a self-call whose callee
8441
+ # mutates it ({#floor_callee_escaped_args_for_call}, the walk {#floor_block_body_callee_captures} makes): the
8442
+ # join reads only the stores in the block, so `|x, out| record(out, x); out[[x, :e]] = 1` joined
8443
+ # `Hash[[Integer, :e], 1]` while `record` stored other keys, and a memo the block only passed on kept its `{}`.
8444
+ # Every read of the memo's name counts, a nested block's parameter shadowing it included: the floor only forgets.
8445
+ # The walk runs on an evaluator whose scope binds the memo, since the floor reads only locals its scope binds.
8446
+ def floor_memo_callee_escapes(body, memo_param, memo)
8447
+ StatementEvaluator.new(scope: scope.with_local(memo_param, memo), tracer: tracer)
8448
+ .callee_floored_local(body, memo_param)
8449
+ end
8450
+
8451
+ protected
8452
+
8453
+ # `name` as the self-calls in `body` leave it, each flooring the arguments its callee mutates
8454
+ # ({#floor_memo_callee_escapes}).
8455
+ def callee_floored_local(body, name)
8456
+ floored = scope
8457
+ each_node_in_caller_block(body, 0) do |node, _depth|
8458
+ floored = floor_callee_escaped_args_for_call(node, floored)
5671
8459
  end
5672
- joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
5673
- joined || call_type
8460
+ floored.local(name)
5674
8461
  end
5675
8462
 
8463
+ private
8464
+
5676
8465
  # `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
5677
8466
  # never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
5678
8467
  # values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
5679
8468
  # `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
5680
8469
  # block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
5681
- # `seed` describes. A `def` opens a scope of its own, so nothing under one is.
5682
- def lookup_mutated_seed(body, name, seed)
8470
+ # `seed` describes. A `def` opens a scope of its own, so nothing under one is. `mutators` widens the call names
8471
+ # read; the `each_with_object` memo passes {MEMO_SEED_MUTATORS}.
8472
+ def lookup_mutated_seed(body, name, seed, mutators = HashLookupMutation::MUTATORS)
5683
8473
  Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
5684
- next unless node.is_a?(Prism::CallNode) && HashLookupMutation::MUTATORS.include?(node.name)
8474
+ next unless node.is_a?(Prism::CallNode) && mutators.include?(node.name)
5685
8475
  next if ancestors.any?(Prism::DefNode)
5686
8476
 
5687
8477
  receiver = node.receiver
5688
- next unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
5689
- next unless yield(receiver.depth, scope_nesting(ancestors))
8478
+ case receiver
8479
+ when Prism::LocalVariableReadNode
8480
+ next unless receiver.name == name && yield(receiver.depth, scope_nesting(ancestors))
8481
+ when Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
8482
+ next unless receiver.name == name && capturable_non_local?(name, ancestors, scope)
8483
+ else
8484
+ next
8485
+ end
5690
8486
 
5691
8487
  seed = MutationWidening.widen_for_mutator(seed, node.name) || seed
5692
8488
  end
@@ -5699,11 +8495,11 @@ module Rigor
5699
8495
  # write.
5700
8496
  def join_memo_content(call_node, memo_param, calls, pre_state, shadows)
5701
8497
  block = call_node.block
5702
- captured = captured_content_mutations(block, shadows)
5703
- seeds = captured.keys.to_h { |name| [name, scope.local(name)] }
8498
+ captured = captured_content_mutations(block, shadows, call_node)
8499
+ seeds = captured.keys.to_h { |name| [name, CapturedLocals.bound_type(scope, name)] }
5704
8500
  seeds[memo_param] = pre_state
5705
8501
  sites = captured.merge(memo_param => calls)
5706
- shadow_rebound_reads(block, sites, seeds, shadows)
8502
+ shadow_rebound_reads(block, sites, seeds, shadows, call_node)
5707
8503
  join_content_to_fixpoint(sites, seeds, build_block_entry_scope(call_node, block), shadows)[memo_param]
5708
8504
  end
5709
8505
 
@@ -5723,21 +8519,19 @@ module Rigor
5723
8519
  second.respond_to?(:name) ? second.name : nil
5724
8520
  end
5725
8521
 
5726
- # Content-mutator calls on the block-local `var_name` within `body` — directly, or from a nested block that
5727
- # reaches it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a
5728
- # different variable and does not count.
8522
+ # Content mutations of the block-local `var_name` within `body` — directly, or from a nested block that reaches
8523
+ # it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a different
8524
+ # variable and does not count. The sites are the ones {#captured_content_mutations} reads for a captured local
8525
+ # ({#content_mutation_target}), the index compound writes included: scanning calls alone, `h[x] ||= []` was no
8526
+ # store, and the memo kept its `{}`.
5729
8527
  def body_content_mutations_on(body, var_name, shadows)
5730
8528
  calls = []
5731
8529
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
5732
- next unless descendant.is_a?(Prism::CallNode)
5733
- next unless CONTENT_MUTATORS.include?(descendant.name)
5734
-
5735
- receiver = descendant.receiver
5736
- next unless receiver.is_a?(Prism::LocalVariableReadNode)
5737
- next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
8530
+ name, site = content_mutation_target(descendant) { |receiver| receiver.depth == scope_nesting(ancestors) }
8531
+ next unless name == var_name
5738
8532
 
5739
- calls << descendant
5740
- record_shadows(shadows, descendant, ancestors)
8533
+ calls << site
8534
+ record_shadows(shadows, site, ancestors)
5741
8535
  end
5742
8536
  calls
5743
8537
  end
@@ -5877,6 +8671,29 @@ module Rigor
5877
8671
  MutationWidening::HASH_MUTATORS)).freeze
5878
8672
  private_constant :CONTENT_MUTATORS
5879
8673
 
8674
+ # The memo's seed widens on every shape mutator its scan does not read evidence from, as a captured local's
8675
+ # binding does on the block seam ({MutationWidening.widen_after_block}): the {HashLookupMutation} calls, and the
8676
+ # rewriters and removers (`merge!`, `update`, `delete`, `replace`, …) whose added content no join can read. Read
8677
+ # off the memo argument alone, `xs.each_with_object({}) { |s, m| m.merge!(s) }` answered the empty `{}`.
8678
+ MEMO_SEED_MUTATORS = (MutationWidening::SHAPE_MUTATORS - CONTENT_MUTATORS).freeze
8679
+ private_constant :MEMO_SEED_MUTATORS
8680
+
8681
+ # Issue #2296 — the Set adders {CONTENT_MUTATORS} leaves out (`add`, `add?`, `merge`): a scan cannot see the
8682
+ # receiver's class, and `merge` on a Hash returns a new Hash, so the callee census keeps them apart and floors
8683
+ # only a Set argument on them.
8684
+ SET_ONLY_ADDERS = (SetMutation::CONTENT_ADDERS - CONTENT_MUTATORS).freeze
8685
+ private_constant :SET_ONLY_ADDERS
8686
+
8687
+ # Issue #2595 — every name the callee write census ({#callee_write_census}) counts as a write: the shape mutators
8688
+ # the widening reads ({MutationWidening::SHAPE_MUTATORS}) and the content scans' own names. A Set-only adder
8689
+ # stays out: `merge` on a Hash returns a new Hash.
8690
+ CALLEE_WRITE_MUTATORS = (MutationWidening::SHAPE_MUTATORS | CONTENT_MUTATORS).freeze
8691
+ # The tables {#callee_write_census} scans with.
8692
+ CENSUS_TABLES = CalleeWriteCensus::Tables.new(mutators: CALLEE_WRITE_MUTATORS, handled: CONTENT_MUTATORS,
8693
+ fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS,
8694
+ foreign_calls: METHOD_BODY_BLOCK_CALLS)
8695
+ private_constant :CALLEE_WRITE_MUTATORS, :CENSUS_TABLES
8696
+
5880
8697
  # The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
5881
8698
  # method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
5882
8699
  # `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
@@ -6239,7 +9056,8 @@ module Rigor
6239
9056
  # ({#record_closure_escape_if_any}) and a lambda literal ({#eval_lambda}) — so they cannot drift.
6240
9057
  def escaping_closure_captures(closure_node, post_scope, foreign_self: false)
6241
9058
  widen_escaping_content_captures(closure_node,
6242
- drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self))
9059
+ drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self),
9060
+ foreign_self: foreign_self)
6243
9061
  end
6244
9062
 
6245
9063
  # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`
@@ -6249,6 +9067,30 @@ module Rigor
6249
9067
  InterveningIvarReset.foreign_self_block?(call_node, operand_scope)
6250
9068
  end
6251
9069
 
9070
+ # The scope a block body's `self` marks start from: the statement form's opaque `self` (issue #316), or the
9071
+ # walk's unknown-`self` mark alone (issue #1717) for the value-position block {#evaluate_block_of} enters and for
9072
+ # every block entered beneath it. A scope carrying the unknown-`self` mark without the opaque one comes only from
9073
+ # the index walk ({Scope#with_block_self_unknown}), so it is how a nested block knows it sits under one: the
9074
+ # opaque mark, which declines a cross-file top-level `def`, stays off the whole value-position subtree.
9075
+ #
9076
+ # Issue #2861 — a block whose call provably runs it under the caller's `self` ({SelfPreservingBlock}:
9077
+ # `rows.map { … }`) enters with no opaque mark the caller's scope does not already carry
9078
+ # ({Scope#entering_self_preserving_block}).
9079
+ def block_self_entry(block_node, call_node = nil)
9080
+ walked = block_node.equal?(@value_position_block) || scope.walked_block_self_unknown?
9081
+ return scope.with_block_self_unknown(true) if walked
9082
+ return scope.entering_self_preserving_block if self_preserving_block?(call_node)
9083
+
9084
+ scope.entering_opaque_block
9085
+ end
9086
+
9087
+ def self_preserving_block?(call_node)
9088
+ return false if call_node.nil? || call_node.receiver.nil?
9089
+
9090
+ SelfPreservingBlock.call?(call_node, explicit_receiver_type(call_node), scope,
9091
+ classification: classify_closure_escape(call_node))
9092
+ end
9093
+
6252
9094
  # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
6253
9095
  # and adds bindings for every named block parameter on top. Parameter types come from the receiving method's RBS
6254
9096
  # signature when one is available; the rest default to `Dynamic[Top]`.
@@ -6263,7 +9105,8 @@ module Rigor
6263
9105
  # yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
6264
9106
  # Issue #1358 — a body that may run a match reads the match globals an earlier iteration may have rebound
6265
9107
  # ({MatchRebinding.block_entry}).
6266
- entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
9108
+ entry = MatchRebinding.block_entry(block_self_entry(block_node, call_node), block_node, call_node)
9109
+ entry = entry.entering_block_without_gradual_marks(block_node)
6267
9110
  scope_with_params = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
6268
9111
  # ADR-16 Tier A — a plugin `block_as_methods:` entry that matches `(receiver, name)` narrows the
6269
9112
  # body's `self` to the object the DSL `instance_eval`s the block on (`params` on
@@ -6399,7 +9242,8 @@ module Rigor
6399
9242
  # def's contract; it binds its parameters as an undeclared method does.
6400
9243
  class_path: @class_context.last&.refinement ? nil : current_class_path,
6401
9244
  singleton: singleton,
6402
- source_path: scope.source_path
9245
+ source_path: scope.source_path,
9246
+ scope: scope
6403
9247
  )
6404
9248
  bindings = binder.bind(def_node)
6405
9249
  # ADR-67 WD3 — override an undeclared parameter with its call-site inferred type (precision-additive; an
@@ -6484,7 +9328,7 @@ module Rigor
6484
9328
  # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an undeclared parameter — the only
6485
9329
  # bindings ADR-67 WD3 overrides.
6486
9330
  def untyped_binding?(type)
6487
- type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
9331
+ Type::Combinator.untyped?(type)
6488
9332
  end
6489
9333
 
6490
9334
  def seed_instance_ivars(body_scope, singleton:)
@@ -6696,8 +9540,13 @@ module Rigor
6696
9540
  # nested body, so this handler only ever appends a return that genuinely exits the method currently being
6697
9541
  # inferred.
6698
9542
  def eval_return(node)
6699
- sink = Thread.current[RETURN_SINK_KEY]
6700
- sink&.record(node, jump_value_type(node))
9543
+ state = typing_state
9544
+ sink = state[RETURN_SINK]
9545
+ if sink
9546
+ type = jump_value_type(node)
9547
+ state.touch(RETURN_SINK)
9548
+ sink.record(node, type, state[PROVISIONAL_RETURNS])
9549
+ end
6701
9550
  [Type::Combinator.bot, scope]
6702
9551
  end
6703
9552
 
@@ -6711,8 +9560,13 @@ module Rigor
6711
9560
  # dead arm), so the join is flow-sensitive for free: `[1, 2].map { |x| next nil if x.nil?; x.to_s }` keeps its
6712
9561
  # exact per-element answer.
6713
9562
  def eval_next(node)
6714
- sink = Thread.current[NEXT_SINK_KEY]
6715
- sink << [node, jump_value_type(node)] if sink
9563
+ state = typing_state
9564
+ sink = state[NEXT_SINK]
9565
+ if sink
9566
+ type = jump_value_type(node)
9567
+ state.touch(NEXT_SINK)
9568
+ sink << [node, type]
9569
+ end
6716
9570
  @next_scope_sink << [node, jump_scope(node)] if @next_scope_sink
6717
9571
  [Type::Combinator.bot, scope]
6718
9572
  end
@@ -6738,10 +9592,19 @@ module Rigor
6738
9592
  # break-value sink for `ExpressionTyper#call_dispatch_type_for` to union in. Both sinks are optional and
6739
9593
  # independent — a loop body collects scopes while an enclosing call collects values from the same walk.
6740
9594
  def eval_break(node)
6741
- sink = Thread.current[BREAK_SINK_KEY]
6742
- sink << [node, jump_scope(node)] if sink
6743
- value_sink = Thread.current[BREAK_VALUE_SINK_KEY]
6744
- value_sink << [node, jump_value_type(node)] if value_sink
9595
+ state = typing_state
9596
+ sink = state[BREAK_SINK]
9597
+ if sink
9598
+ jump = jump_scope(node)
9599
+ state.touch(BREAK_SINK)
9600
+ sink << [node, jump]
9601
+ end
9602
+ value_sink = state[BREAK_VALUE_SINK]
9603
+ if value_sink
9604
+ type = jump_value_type(node)
9605
+ state.touch(BREAK_VALUE_SINK)
9606
+ value_sink << [node, type]
9607
+ end
6745
9608
  [Type::Combinator.bot, scope]
6746
9609
  end
6747
9610
 
@@ -6847,6 +9710,39 @@ module Rigor
6847
9710
  branch_type.is_a?(Type::Bot)
6848
9711
  end
6849
9712
 
9713
+ # Issue #1230 — a strict, syntactic-only exit test for the then-exits-beside-an-else arm ({#if_join}). The
9714
+ # branch's last statement is a `return` / `next` / `break`; a receiver-less `raise` / `fail` /
9715
+ # `exit` / `exit!` / `abort` / `throw` whose name no project file defines on any class or module, at the top level
9716
+ # or by `define_method` ({Scope#project_method_name?}); or an if / unless / case with an else whose every arm
9717
+ # certainly exits. A `redo` / `retry` does not: it re-runs a loop body or a `begin` that reads the branch's
9718
+ # writes. Unlike {#branch_terminates?} it reads no type, so a `bot`-typed helper call does not qualify:
9719
+ # the typer types a call to an overridable or Kernel-shadowing project method `bot` (issue #3110).
9720
+ def branch_certainly_exits?(node)
9721
+ case node
9722
+ when Prism::ReturnNode, Prism::NextNode, Prism::BreakNode then true
9723
+ when Prism::CallNode then certain_exit_call?(node)
9724
+ when Prism::StatementsNode then branch_certainly_exits?(node.body.last)
9725
+ when Prism::ParenthesesNode then branch_certainly_exits?(node.body)
9726
+ when Prism::ElseNode then branch_certainly_exits?(node.statements)
9727
+ when Prism::IfNode, Prism::UnlessNode
9728
+ branch_certainly_exits?(node.statements) && branch_certainly_exits?(node_else_branch(node))
9729
+ when Prism::CaseNode, Prism::CaseMatchNode then case_certainly_exits?(node)
9730
+ else false
9731
+ end
9732
+ end
9733
+
9734
+ def certain_exit_call?(node)
9735
+ node.receiver.nil? && CERTAIN_EXIT_NAMES.include?(node.name) && !scope.project_method_name?(node.name)
9736
+ end
9737
+
9738
+ def case_certainly_exits?(node)
9739
+ !node.else_clause.nil? && branch_certainly_exits?(node.else_clause) &&
9740
+ node.conditions.all? { |arm| branch_certainly_exits?(arm.statements) }
9741
+ end
9742
+
9743
+ CERTAIN_EXIT_NAMES = %i[raise fail exit exit! abort throw].freeze
9744
+ private_constant :CERTAIN_EXIT_NAMES
9745
+
6850
9746
  def eval_branch_or_nil(branch_node, branch_scope, on_enter: @on_enter)
6851
9747
  return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
6852
9748
 
@@ -6899,6 +9795,8 @@ module Rigor
6899
9795
  scope.with_ivar(ref.name, exception_type).without_ivar_guard(ref.name)
6900
9796
  when Prism::GlobalVariableTargetNode
6901
9797
  scope.with_global(ref.name, exception_type)
9798
+ when Prism::ClassVariableTargetNode
9799
+ scope.with_cvar(ref.name, exception_type)
6902
9800
  when Prism::IndexTargetNode
6903
9801
  widen_index_target(ref, exception_type, scope, type_scope: scope)
6904
9802
  else
@@ -6906,20 +9804,11 @@ module Rigor
6906
9804
  end
6907
9805
  end
6908
9806
 
6909
- # Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
6910
- # type is `StandardError`. When one or more exception classes are named the types are unioned. A class that
6911
- # cannot be resolved to a `Singleton` type contributes `Dynamic[top]`.
9807
+ # Derives the exception instance type for a `RescueNode` ({ErrorInfo.rescue_exception_type}): `StandardError` for
9808
+ # a bare `rescue`, else the union of the named classes' instances, with `Dynamic[top]` for one that does not
9809
+ # resolve to a `Singleton`.
6912
9810
  def rescue_exception_type(rescue_node, scope)
6913
- exceptions = rescue_node.exceptions
6914
- if exceptions.empty?
6915
- Type::Combinator.nominal_of("StandardError")
6916
- else
6917
- types = exceptions.map do |exc_node|
6918
- singleton_type, = sub_eval(exc_node, scope)
6919
- singleton_to_nominal(singleton_type)
6920
- end
6921
- Type::Combinator.union(*types)
6922
- end
9811
+ ErrorInfo.rescue_exception_type(rescue_node) { |exception_node| sub_eval(exception_node, scope).first }
6923
9812
  end
6924
9813
 
6925
9814
  # --------------------------------------------------------------- `case/in` pattern variable binding helpers
@@ -6931,7 +9820,27 @@ module Rigor
6931
9820
  # `deconstruct` / `deconstruct_keys` dispatch reads for its freshness gate.
6932
9821
  def apply_in_pattern_bindings(subject_type, subject_node, pattern, scope)
6933
9822
  bindings = collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
6934
- bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
9823
+ bound = bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
9824
+ marked = subject_node.is_a?(Prism::LocalVariableReadNode) && scope.gradual_narrowing?(subject_node.name)
9825
+ mark_gradual_class_capture(subject_type, pattern, bound, marked: marked)
9826
+ end
9827
+
9828
+ # Issue #2656 — `in C => y` over a `Dynamic` subject binds `y` to `C`'s `Nominal`, as a class guard's edge binds
9829
+ # the subject, and marks it as that edge marks a local ({Narrowing.mark_gradual_local}). Only a capture of
9830
+ # the whole subject by a class or an alternation of classes is marked.
9831
+ def mark_gradual_class_capture(subject_type, pattern, scope, marked: false)
9832
+ return scope unless pattern.is_a?(Prism::CapturePatternNode) && class_capture_value?(pattern.value)
9833
+
9834
+ name = pattern.target.name
9835
+ Narrowing.mark_gradual_local(scope, name, subject_type, scope.local(name), marked: marked)
9836
+ end
9837
+
9838
+ def class_capture_value?(value)
9839
+ case value
9840
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then true
9841
+ when Prism::AlternationPatternNode then class_capture_value?(value.left) && class_capture_value?(value.right)
9842
+ else false
9843
+ end
6935
9844
  end
6936
9845
 
6937
9846
  # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`, typed against the
@@ -7311,7 +10220,8 @@ module Rigor
7311
10220
  floor = Type::Combinator.untyped
7312
10221
  walks.first.map do |name, _type|
7313
10222
  types = walks.map { |walk| walk.assoc(name)&.last }
7314
- [name, types.any? { |type| type.nil? || type == floor } ? floor : Type::Combinator.union(*types)]
10223
+ floored = types.any? { |type| type.nil? || Type::Combinator.untyped?(type) }
10224
+ [name, floored ? floor : Type::Combinator.union(*types)]
7315
10225
  end
7316
10226
  end
7317
10227