rigortype 0.4.5 → 0.4.7

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 (452) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/gem_overlay/activesupport/core_ext.rbs +16 -13
  4. data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
  5. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
  6. data/docs/handbook/01-getting-started.md +5 -2
  7. data/docs/handbook/11-sig-gen.md +61 -5
  8. data/docs/handbook/appendix-phpstan.md +4 -4
  9. data/docs/manual/02-cli-reference.md +192 -27
  10. data/docs/manual/03-configuration.md +38 -4
  11. data/docs/manual/04-diagnostics.md +1 -1
  12. data/docs/manual/06-baseline.md +6 -4
  13. data/docs/manual/09-editor-integration.md +13 -2
  14. data/docs/manual/10-mcp-server.md +47 -3
  15. data/docs/manual/12-caching.md +9 -1
  16. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +49 -11
  18. data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
  19. data/docs/manual/plugins/rigor-devise.md +15 -0
  20. data/docs/manual/plugins/rigor-rspec.md +2 -1
  21. data/lib/rigor/analysis/baseline.rb +24 -27
  22. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  24. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  25. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +4 -4
  26. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +105 -12
  28. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +143 -15
  29. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -1
  30. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  31. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +1 -1
  32. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  33. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  34. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  35. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  36. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  37. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
  38. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  39. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  40. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  41. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +53 -2
  42. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  43. data/lib/rigor/analysis/check_rules.rb +217 -52
  44. data/lib/rigor/analysis/crash_signature.rb +2 -2
  45. data/lib/rigor/analysis/dependency_recorder.rb +15 -4
  46. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  47. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  48. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  49. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  50. data/lib/rigor/analysis/edit_replay.rb +154 -0
  51. data/lib/rigor/analysis/fact_store.rb +3 -3
  52. data/lib/rigor/analysis/incremental.rb +37 -0
  53. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -2
  54. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  55. data/lib/rigor/analysis/incremental_session.rb +202 -43
  56. data/lib/rigor/analysis/path_expansion.rb +41 -0
  57. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  58. data/lib/rigor/analysis/rule_catalog.rb +7 -3
  59. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  60. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  61. data/lib/rigor/analysis/run_stats.rb +2 -2
  62. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +8 -4
  63. data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
  64. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +204 -96
  65. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +157 -320
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +48 -6
  68. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  70. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  71. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  72. data/lib/rigor/analysis/runner.rb +233 -120
  73. data/lib/rigor/analysis/self_call_resolution_recorder.rb +1 -1
  74. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  75. data/lib/rigor/analysis/template_units.rb +9 -1
  76. data/lib/rigor/analysis/worker_session.rb +38 -33
  77. data/lib/rigor/bleeding_edge.rb +3 -6
  78. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  79. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  80. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  81. data/lib/rigor/cache/descriptor.rb +6 -5
  82. data/lib/rigor/cache/incremental_snapshot.rb +60 -18
  83. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  84. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  85. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  86. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  87. data/lib/rigor/cache/rbs_descriptor.rb +54 -3
  88. data/lib/rigor/cache/rbs_environment.rb +4 -1
  89. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  90. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  91. data/lib/rigor/cache/store.rb +11 -10
  92. data/lib/rigor/ci_detector.rb +1 -1
  93. data/lib/rigor/cli/annotate_command.rb +24 -19
  94. data/lib/rigor/cli/baseline_command.rb +145 -55
  95. data/lib/rigor/cli/catalogue.rb +256 -0
  96. data/lib/rigor/cli/check_command.rb +188 -93
  97. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  98. data/lib/rigor/cli/command.rb +15 -1
  99. data/lib/rigor/cli/coverage_command.rb +21 -8
  100. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  101. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  102. data/lib/rigor/cli/coverage_scan.rb +3 -5
  103. data/lib/rigor/cli/diff_command.rb +27 -21
  104. data/lib/rigor/cli/docs_command.rb +1 -2
  105. data/lib/rigor/cli/doctor_command.rb +12 -12
  106. data/lib/rigor/cli/effects_command.rb +50 -40
  107. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  108. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  109. data/lib/rigor/cli/effects_renderer.rb +2 -1
  110. data/lib/rigor/cli/effects_snapshot_command.rb +18 -17
  111. data/lib/rigor/cli/explain_command.rb +18 -8
  112. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  113. data/lib/rigor/cli/json_document.rb +41 -0
  114. data/lib/rigor/cli/lens_command.rb +17 -13
  115. data/lib/rigor/cli/lsp_command.rb +250 -23
  116. data/lib/rigor/cli/mcp_command.rb +14 -8
  117. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  118. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  119. data/lib/rigor/cli/options.rb +60 -4
  120. data/lib/rigor/cli/plugin_command.rb +93 -46
  121. data/lib/rigor/cli/plugins_command.rb +22 -18
  122. data/lib/rigor/cli/plugins_renderer.rb +34 -8
  123. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  124. data/lib/rigor/cli/protection_renderer.rb +2 -1
  125. data/lib/rigor/cli/show_bleedingedge_command.rb +23 -16
  126. data/lib/rigor/cli/sig_gen_command.rb +130 -34
  127. data/lib/rigor/cli/skill_command.rb +32 -3
  128. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  129. data/lib/rigor/cli/skill_describe.rb +1 -1
  130. data/lib/rigor/cli/trace_command.rb +14 -9
  131. data/lib/rigor/cli/triage_command.rb +10 -11
  132. data/lib/rigor/cli/triage_renderer.rb +2 -1
  133. data/lib/rigor/cli/type_of_command.rb +18 -12
  134. data/lib/rigor/cli/type_of_project_scope.rb +24 -0
  135. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  136. data/lib/rigor/cli/type_scan_command.rb +14 -9
  137. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  138. data/lib/rigor/cli/unused_command.rb +27 -17
  139. data/lib/rigor/cli/upgrade_command.rb +5 -0
  140. data/lib/rigor/cli.rb +209 -54
  141. data/lib/rigor/config_audit.rb +17 -6
  142. data/lib/rigor/configuration/severity_profile.rb +5 -5
  143. data/lib/rigor/configuration.rb +86 -34
  144. data/lib/rigor/effects/definition_context.rb +0 -2
  145. data/lib/rigor/effects/envelope.rb +1 -1
  146. data/lib/rigor/effects/framework_units.rb +1 -1
  147. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  148. data/lib/rigor/effects/propagator.rb +1 -1
  149. data/lib/rigor/effects/registry.rb +2 -2
  150. data/lib/rigor/effects/snapshot.rb +1 -1
  151. data/lib/rigor/effects/taint_cause.rb +0 -2
  152. data/lib/rigor/effects/unit_scan.rb +9 -13
  153. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -9
  154. data/lib/rigor/environment/class_registry.rb +3 -3
  155. data/lib/rigor/environment/core_root.rb +77 -0
  156. data/lib/rigor/environment/default_libraries.rb +1 -1
  157. data/lib/rigor/environment/deferred_signature_plan.rb +283 -0
  158. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  159. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  160. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  161. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  162. data/lib/rigor/environment/member_consistency.rb +56 -7
  163. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  164. data/lib/rigor/environment/project_path_gems.rb +124 -0
  165. data/lib/rigor/environment/rbs_hierarchy.rb +6 -15
  166. data/lib/rigor/environment/rbs_loader.rb +592 -70
  167. data/lib/rigor/environment/reflection.rb +7 -7
  168. data/lib/rigor/environment/required_features.rb +136 -32
  169. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  170. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  171. data/lib/rigor/environment.rb +44 -11
  172. data/lib/rigor/flow_contribution/merger.rb +4 -4
  173. data/lib/rigor/inference/acceptance.rb +93 -32
  174. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  175. data/lib/rigor/inference/block_run_count.rb +178 -0
  176. data/lib/rigor/inference/budget_trace.rb +8 -2
  177. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  178. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  179. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  180. data/lib/rigor/inference/callee_mutation_chain.rb +126 -0
  181. data/lib/rigor/inference/callee_write_census.rb +815 -0
  182. data/lib/rigor/inference/construction_escape.rb +217 -0
  183. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  184. data/lib/rigor/inference/deep_erasure.rb +170 -0
  185. data/lib/rigor/inference/def_handle.rb +1 -1
  186. data/lib/rigor/inference/def_node_resolver.rb +1 -1
  187. data/lib/rigor/inference/def_return_typer.rb +204 -12
  188. data/lib/rigor/inference/definer_resolution.rb +21 -5
  189. data/lib/rigor/inference/definers_index.rb +106 -0
  190. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  191. data/lib/rigor/inference/element_read_widening.rb +49 -3
  192. data/lib/rigor/inference/error_info.rb +17 -3
  193. data/lib/rigor/inference/expression_typer.rb +173 -57
  194. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  195. data/lib/rigor/inference/global_write_census.rb +110 -1
  196. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  197. data/lib/rigor/inference/hkt_body.rb +1 -2
  198. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  199. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  200. data/lib/rigor/inference/hkt_registry.rb +3 -5
  201. data/lib/rigor/inference/indexed_narrowing.rb +4 -7
  202. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  203. data/lib/rigor/inference/ivar_writer_census.rb +246 -0
  204. data/lib/rigor/inference/key_presence_guard.rb +2 -1
  205. data/lib/rigor/inference/loop_value_record.rb +57 -0
  206. data/lib/rigor/inference/match_rebinding/frame.rb +65 -0
  207. data/lib/rigor/inference/match_rebinding/yielding_match.rb +45 -0
  208. data/lib/rigor/inference/match_rebinding.rb +11 -1
  209. data/lib/rigor/inference/memo_audit.rb +55 -0
  210. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  211. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  212. data/lib/rigor/inference/method_dispatcher/block_folding.rb +1 -1
  213. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +113 -12
  214. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  215. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  216. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -3
  217. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  218. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +39 -27
  219. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  220. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +203 -17
  221. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  222. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  223. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +2 -2
  224. data/lib/rigor/inference/method_dispatcher/set_folding.rb +59 -24
  225. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +83 -20
  226. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  227. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  228. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  229. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  230. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  231. data/lib/rigor/inference/method_dispatcher.rb +109 -29
  232. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  233. data/lib/rigor/inference/module_function_state.rb +23 -1
  234. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  235. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  236. data/lib/rigor/inference/mutation_widening.rb +162 -29
  237. data/lib/rigor/inference/narrowing.rb +113 -38
  238. data/lib/rigor/inference/object_mixins.rb +13 -1
  239. data/lib/rigor/inference/optimistic_origin.rb +1 -1
  240. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  241. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  242. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  243. data/lib/rigor/inference/project_method_ownership.rb +6 -3
  244. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  245. data/lib/rigor/inference/project_yielder.rb +122 -0
  246. data/lib/rigor/inference/receiver_alias.rb +20 -0
  247. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +3 -1
  248. data/lib/rigor/inference/refined_dispatch/own_method.rb +2 -1
  249. data/lib/rigor/inference/remover_holder_scan.rb +233 -0
  250. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  251. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  252. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -1
  253. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  254. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  255. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +205 -0
  256. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  257. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  258. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +135 -0
  259. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  260. data/lib/rigor/inference/scope_indexer.rb +614 -146
  261. data/lib/rigor/inference/self_call_ivar_writes.rb +101 -17
  262. data/lib/rigor/inference/set_mutation.rb +189 -0
  263. data/lib/rigor/inference/singleton_hook_decline.rb +1 -2
  264. data/lib/rigor/inference/statement_evaluator.rb +3036 -278
  265. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  266. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  267. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  268. data/lib/rigor/inference/synthetic_method_scanner.rb +73 -14
  269. data/lib/rigor/inference/toplevel_def_census.rb +59 -0
  270. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  271. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  272. data/lib/rigor/inference/void_tail_summary.rb +2 -1
  273. data/lib/rigor/language_server/buffer_table.rb +9 -3
  274. data/lib/rigor/language_server/completion_provider.rb +26 -20
  275. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  276. data/lib/rigor/language_server/diagnostic_publisher.rb +28 -9
  277. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  278. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  279. data/lib/rigor/language_server/hover_provider.rb +1 -7
  280. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  281. data/lib/rigor/language_server/project_context.rb +98 -4
  282. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  283. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  284. data/lib/rigor/language_server/server.rb +12 -6
  285. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  286. data/lib/rigor/lens/plugins.rb +1 -1
  287. data/lib/rigor/mcp/server.rb +82 -3
  288. data/lib/rigor/mcp.rb +1 -1
  289. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  290. data/lib/rigor/plugin/base.rb +26 -11
  291. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  292. data/lib/rigor/plugin/io_boundary.rb +1 -1
  293. data/lib/rigor/plugin/loader.rb +25 -8
  294. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  295. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  296. data/lib/rigor/plugin/manifest.rb +38 -16
  297. data/lib/rigor/plugin/registry.rb +41 -3
  298. data/lib/rigor/plugin/services.rb +2 -3
  299. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  300. data/lib/rigor/plugin/trust_policy.rb +2 -2
  301. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  302. data/lib/rigor/plugin.rb +6 -8
  303. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  304. data/lib/rigor/project_environment.rb +16 -2
  305. data/lib/rigor/protection/analysis_guard.rb +1 -1
  306. data/lib/rigor/protection/discovery_seed.rb +96 -2
  307. data/lib/rigor/protection/mutator.rb +1 -1
  308. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  309. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  310. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  311. data/lib/rigor/rbs_extended.rb +2 -3
  312. data/lib/rigor/reflection/constant_ancestors.rb +5 -3
  313. data/lib/rigor/rooted_glob.rb +25 -2
  314. data/lib/rigor/run_scoped_slots.rb +38 -0
  315. data/lib/rigor/runtime/jit.rb +1 -1
  316. data/lib/rigor/scope/discovery_index.rb +19 -6
  317. data/lib/rigor/scope/resolution_chain.rb +3 -1
  318. data/lib/rigor/scope.rb +379 -36
  319. data/lib/rigor/sig_gen/classification.rb +4 -0
  320. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  321. data/lib/rigor/sig_gen/generator.rb +668 -84
  322. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  323. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  324. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  325. data/lib/rigor/sig_gen/method_candidate.rb +23 -4
  326. data/lib/rigor/sig_gen/observation_collector.rb +7 -6
  327. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  328. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  329. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  330. data/lib/rigor/sig_gen/renderer.rb +21 -12
  331. data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
  332. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  333. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  334. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  335. data/lib/rigor/sig_gen/write_result.rb +23 -12
  336. data/lib/rigor/sig_gen/writer.rb +217 -37
  337. data/lib/rigor/type/acceptance_router.rb +2 -2
  338. data/lib/rigor/type/accepts_result.rb +1 -1
  339. data/lib/rigor/type/app.rb +4 -6
  340. data/lib/rigor/type/combinator.rb +13 -16
  341. data/lib/rigor/type/constant.rb +48 -7
  342. data/lib/rigor/type/hash_shape.rb +26 -40
  343. data/lib/rigor/type/struct_instance.rb +3 -2
  344. data/lib/rigor/type/union.rb +1 -1
  345. data/lib/rigor/type_node/identifier.rb +1 -2
  346. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  347. data/lib/rigor/type_node/string_literal.rb +1 -2
  348. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  349. data/lib/rigor/type_node/union.rb +3 -6
  350. data/lib/rigor/value_semantics.rb +1 -10
  351. data/lib/rigor/version.rb +1 -1
  352. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  353. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  354. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  355. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +89 -70
  356. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  357. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +25 -4
  358. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  359. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  360. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  361. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  362. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  363. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +79 -19
  364. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  365. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +6 -3
  366. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  367. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  368. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  369. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  370. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +2 -3
  371. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +0 -1
  372. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +5 -4
  373. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  374. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  375. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +7 -12
  376. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  377. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  378. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  379. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +19 -6
  380. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +26 -13
  381. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  382. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  383. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  384. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  385. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  386. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  387. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  388. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  389. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  390. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  391. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  392. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  393. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  394. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -5
  395. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  396. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  397. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  398. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  399. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -2
  400. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  401. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  402. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  403. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  404. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  405. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -3
  406. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  407. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  408. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  409. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  410. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  411. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  412. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  413. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  414. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +5 -2
  416. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  417. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +1 -1
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +17 -21
  421. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  422. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +3 -2
  423. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  424. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  425. data/sig/rigor/analysis/baseline.rbs +0 -2
  426. data/sig/rigor/analysis/fact_store.rbs +1 -0
  427. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  428. data/sig/rigor/cli/diff_command.rbs +1 -1
  429. data/sig/rigor/cli/explain_command.rbs +1 -1
  430. data/sig/rigor/effects/file_collection.rbs +5 -6
  431. data/sig/rigor/effects/registry.rbs +8 -6
  432. data/sig/rigor/effects/summary.rbs +6 -5
  433. data/sig/rigor/environment.rbs +5 -3
  434. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  435. data/sig/rigor/inference.rbs +10 -1
  436. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  437. data/sig/rigor/plugin/base.rbs +1 -0
  438. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  439. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  440. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  441. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  442. data/sig/rigor/plugin/io_boundary.rbs +6 -5
  443. data/sig/rigor/plugin/manifest.rbs +16 -18
  444. data/sig/rigor/plugin/protocol_contract.rbs +6 -9
  445. data/sig/rigor/plugin/registry.rbs +1 -1
  446. data/sig/rigor/scope.rbs +51 -50
  447. data/sig/rigor/type.rbs +0 -6
  448. data/skills/rigor-plugin-author/SKILL.md +4 -4
  449. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  450. data/skills/rigor-plugin-review/SKILL.md +3 -3
  451. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +12 -1
  452. metadata +55 -3
@@ -14,6 +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"
18
+ require_relative "callee_mutation_chain"
19
+ require_relative "toplevel_def_census"
20
+ require_relative "callee_write_census"
17
21
  require_relative "captured_locals"
18
22
  require_relative "dynamic_origin"
19
23
  require_relative "error_info"
@@ -25,10 +29,13 @@ require_relative "../analysis/check_rules/published_constant_guard"
25
29
  require_relative "struct_fold_safety"
26
30
  require_relative "closure_escape_analyzer"
27
31
  require_relative "content_join"
32
+ require_relative "construction_escape"
28
33
  require_relative "define_method_block_self"
29
34
  require_relative "macro_block_self_type"
35
+ require_relative "global_writer_reach"
30
36
  require_relative "guard_rebinding"
31
37
  require_relative "self_call_ivar_writes"
38
+ require_relative "direct_ivar_writes"
32
39
  require_relative "intervening_ivar_reset"
33
40
  require_relative "match_rebinding"
34
41
  require_relative "clause_widening"
@@ -41,16 +48,20 @@ require_relative "method_dispatcher"
41
48
  require_relative "method_parameter_binder"
42
49
  require_relative "multi_target_binder"
43
50
  require_relative "mutation_widening"
51
+ require_relative "out_buffer_mutation"
44
52
  require_relative "narrowing"
45
53
  require_relative "operand_effects"
46
54
  require_relative "operand_walk"
47
55
  require_relative "optimistic_origin"
56
+ require_relative "loop_value_record"
57
+ require_relative "project_yielder"
48
58
  require_relative "return_barrier"
49
59
  require_relative "repeated_or_writes"
50
60
  require_relative "rewrite_mutation"
51
61
  require_relative "unknown_store_widening"
52
62
  require_relative "untyped_rejoin"
53
63
  require_relative "version_guard"
64
+ require_relative "../run_scoped_slots"
54
65
 
55
66
  module Rigor
56
67
  module Inference
@@ -117,8 +128,8 @@ module Rigor
117
128
  # the default expression pair, so routing the or-writes here adds the body entry and nothing else.
118
129
  Prism::ConstantOrWriteNode => :eval_constant_write,
119
130
  Prism::ConstantPathOrWriteNode => :eval_constant_write,
120
- Prism::IfNode => :eval_if,
121
- Prism::UnlessNode => :eval_unless,
131
+ Prism::IfNode => :eval_recorded_if,
132
+ Prism::UnlessNode => :eval_recorded_unless,
122
133
  Prism::ElseNode => :eval_else,
123
134
  Prism::CaseNode => :eval_case,
124
135
  Prism::CaseMatchNode => :eval_case,
@@ -127,9 +138,9 @@ module Rigor
127
138
  Prism::BeginNode => :eval_begin_node,
128
139
  Prism::RescueNode => :eval_rescue,
129
140
  Prism::EnsureNode => :eval_ensure,
130
- Prism::WhileNode => :eval_loop,
131
- Prism::UntilNode => :eval_loop,
132
- Prism::ForNode => :eval_repeating_for,
141
+ Prism::WhileNode => :eval_recorded_loop,
142
+ Prism::UntilNode => :eval_recorded_loop,
143
+ Prism::ForNode => :eval_recorded_for,
133
144
  Prism::AndNode => :eval_and_or,
134
145
  Prism::OrNode => :eval_and_or,
135
146
  Prism::ParenthesesNode => :eval_parentheses,
@@ -186,6 +197,40 @@ module Rigor
186
197
  RETURN_SINK_KEY = :rigor_return_sink
187
198
  private_constant :RETURN_SINK_KEY
188
199
 
200
+ # Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
201
+ # than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
202
+ PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
203
+ private_constant :PROVISIONAL_RETURNS_KEY
204
+
205
+ # The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
206
+ # with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
207
+ # binding of as `Dynamic[top]`. A provisional record of a `ReturnNode` therefore yields to the walk's record of
208
+ # the SAME node, and stands only where the walk never reaches that node: a block in expression position (a call
209
+ # argument, an array element, a chain receiver, a `return` value, an endless-def body), whose tail the side pass
210
+ # is the only walk to type.
211
+ class ReturnSink
212
+ def initialize
213
+ @types = []
214
+ @recorded = Set.new.compare_by_identity
215
+ @provisional = []
216
+ end
217
+
218
+ def record(node, type)
219
+ if Thread.current[PROVISIONAL_RETURNS_KEY]
220
+ @provisional << [node, type]
221
+ else
222
+ @types << type
223
+ @recorded << node
224
+ end
225
+ end
226
+
227
+ # The walk's records, then each provisional record whose node the walk did not record.
228
+ def types
229
+ @types + @provisional.filter_map { |node, type| type unless @recorded.include?(node) }
230
+ end
231
+ end
232
+ private_constant :ReturnSink
233
+
189
234
  # Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
190
235
  # leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
191
236
  # block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
@@ -313,14 +358,31 @@ module Rigor
313
358
  # method-return type.
314
359
  def self.with_return_sink
315
360
  previous = Thread.current[RETURN_SINK_KEY]
316
- sink = []
361
+ previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
362
+ sink = ReturnSink.new
317
363
  Thread.current[RETURN_SINK_KEY] = sink
364
+ Thread.current[PROVISIONAL_RETURNS_KEY] = nil
318
365
  begin
319
366
  result = yield
320
367
  ensure
321
368
  Thread.current[RETURN_SINK_KEY] = previous
369
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
370
+ end
371
+ [result, sink.types]
372
+ end
373
+
374
+ # Runs `block` with the `return`s it reaches filed as provisional ({ReturnSink}), restoring the previous state
375
+ # on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
376
+ # `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
377
+ # although the statement walk of the same block recorded `r` with its real binding.
378
+ def self.provisional_returns
379
+ previous = Thread.current[PROVISIONAL_RETURNS_KEY]
380
+ Thread.current[PROVISIONAL_RETURNS_KEY] = true
381
+ begin
382
+ yield
383
+ ensure
384
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous
322
385
  end
323
- [result, sink]
324
386
  end
325
387
 
326
388
  # Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
@@ -452,38 +514,730 @@ module Rigor
452
514
  [type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
453
515
  end
454
516
 
455
- # ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
517
+ # ADR-89 WD2 — the sorted slots of the parameters (positional, then keyword) whose CONTENT `def_node` mutates
456
518
  # (`callee_content_mutated_parameters`, the ADR-56 arg-flooring surface a caller consumes). A per-def
457
519
  # static property of the AST — this is a pure exposure of the existing private computation (it never
458
520
  # reads the scope), so the incremental session can compute it on any `StatementEvaluator` (an empty
459
521
  # scope suffices), persist it in a return summary, and re-check a callee's symbol dependents when it
460
522
  # moves (a caller's arg flooring changes even if the callee's return does not).
523
+ #
524
+ # Issue #2104 — a mutated keyword parameter reports its keyword symbol, after the positions. A parameter the
525
+ # def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
526
+ # callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
527
+ # the effects equal and the callers' floors stale.
528
+ #
529
+ # Issue #2296 — the parameters the def mutates inside a block, or through a Set-only adder, count too: they
530
+ # floor the caller's argument through {#floor_callee_escaped_args_for_call}'s element-keeping channels.
461
531
  def content_mutated_parameter_positions(def_node)
462
- callee_content_mutated_parameters(def_node).values.uniq.sort
532
+ slots = forwarding_slots(def_node)
533
+ census = callee_content_mutated_parameters(def_node).values +
534
+ own_block_content_parameters(def_node).values + own_set_content_parameters(def_node).values
535
+ each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
536
+ positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
537
+ positions.sort + keywords.sort
463
538
  end
464
539
 
465
- # The local-variable reads among `call_node`'s positional arguments whose matching parameter the callee
466
- # content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's scope
467
- # (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
540
+ # The local-variable reads among `call_node`'s arguments (positional, or a keyword's value) whose parameter the
541
+ # callee content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's
542
+ # scope (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
468
543
  # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
469
544
  # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
470
545
  def content_mutated_arguments(call_node)
471
- return NO_ARGUMENT_READS unless self_dispatch_call?(call_node)
546
+ callees = argument_mutating_callees(call_node)
547
+ return NO_ARGUMENT_READS if callees.empty?
548
+
549
+ reads = callees.flat_map do |def_node, offset, owner|
550
+ arguments_for_slots(call_node, census_of(owner, :callee_content_mutated_parameters, def_node), offset)
551
+ end
552
+ callees.size == 1 ? reads : reads.uniq
553
+ end
554
+
555
+ # The `[def, offset]` pairs a self-dispatch `call_node` that passes a local resolves to
556
+ # ({#resolve_self_callees}) — the defs whose census the callee floor reads; empty for any other call.
557
+ #
558
+ # Issue #2336 — a call no def answers that runs code the floor cannot see against its arguments (`pr.call(acc)`,
559
+ # `obj.method(:poke).call(acc)`, `send(name, acc)`; {#opaque_sink_call?}) answers {OPAQUE_CALLEES}: every
560
+ # argument it passes reads as written.
561
+ def argument_mutating_callees(call_node)
562
+ unless self_dispatch_call?(call_node)
563
+ found = receiver_callees(call_node)
564
+ return found unless found.empty? && opaque_sink_call?(call_node)
565
+
566
+ return call_passes_local_argument?(call_node) ? OPAQUE_CALLEES : NO_CALLEES
567
+ end
472
568
  # Fast path — only a local passed as an argument can be reported, so a call with none skips the def
473
569
  # resolution and the body scan entirely (the overwhelming common case).
474
- return NO_ARGUMENT_READS unless call_passes_local_argument?(call_node)
570
+ return NO_CALLEES unless call_passes_local_argument?(call_node)
571
+
572
+ found = resolve_self_callees(call_node)
573
+ found.empty? && opaque_sink_call?(call_node) ? OPAQUE_CALLEES : found
574
+ end
575
+
576
+ # True for a call whose arguments go to code no def shows: `call` / `instance_exec` / `class_exec` /
577
+ # `module_exec` on an explicit receiver (a Proc, a Method, a lambda), or a `send` whose target is no literal
578
+ # ({CalleeWriteCensus}, which reads the same calls inside a callee).
579
+ def opaque_sink_call?(call_node)
580
+ name = call_node.name
581
+ return !CalleeWriteCensus.literal_send?(call_node) if SEND_HAND_OFFS.include?(name)
582
+ return false unless OPAQUE_SINKS.include?(name)
583
+
584
+ # A receiverless `call` is a self-call to a def of that name; `instance_exec` and the rest run their block on
585
+ # any receiver.
586
+ name != :call || !self_dispatch_call?(call_node)
587
+ end
588
+
589
+ OPAQUE_SINKS = %i[call instance_exec class_exec module_exec].to_set.freeze
590
+ private_constant :OPAQUE_SINKS
591
+
592
+ # Issue #2510, #2515 — the `[def, 0, owner]` triple an explicit-receiver `recv.name(local)` call resolves to when
593
+ # `recv` types as a project class object (`Const`, a bare constant or any constant path, or a local holding
594
+ # one), whose singleton chain has `name` (`def self.name`, `class << self`, `module_function`, an `extend`ed
595
+ # module, the project superclass's), or as an instance of a project class (`Opts.new.add_format(...)`,
596
+ # `h = Opts.new; h.add_format(...)`), whose instance chain has `name`: the same census a self-call reads then
597
+ # floors the passed local, so `Opts.add_format(opts, options)` handing `options` to a block that writes it
598
+ # leaves the literal behind. `owner` is a {CalleeOwner}: the receiver type the callee's own hand-offs
599
+ # (`really(parser, options)`) resolve self-calls in, not the caller's class. `resolved_census` keys the shared
600
+ # memo by the receiver's class and the def node, whose recorded nesting its constants resolve in. A def the
601
+ # receiver inherits (a superclass's, an included module's) resolves to {FLOOR_ALL} ({#walked_owner}). A receiver
602
+ # of any other type resolves to nothing, as does `Const.new` of a class that defines its own `self.new`; `send`
603
+ # hand-offs are not followed through an explicit receiver. The hand-offs inside a callee, whose arguments are
604
+ # parameters, take {#hand_off_receiver_callees}.
605
+ def receiver_callees(call_node)
606
+ return NO_CALLEES if call_node.receiver.nil?
607
+ return NO_CALLEES unless call_passes_local_argument?(call_node)
608
+
609
+ receiver_callees_in(scope, call_node)
610
+ end
611
+
612
+ # {#receiver_callees} for one scope `call_node`'s receiver (or `receiver`, the constant form a callee's local
613
+ # receiver was assigned) is typed in.
614
+ def receiver_callees_in(typing_scope, call_node, receiver = call_node.receiver)
615
+ return NO_CALLEES if overridden_constructor?(receiver, typing_scope)
616
+
617
+ receiver_type = typing_scope.type_of(receiver)
618
+ own = callees_for_receiver_type(receiver_type, call_node.name)
619
+ return own unless own.empty? && send_hand_off?(call_node)
620
+
621
+ shifted_callees(callees_for_receiver_type(receiver_type, send_target(call_node)))
622
+ end
623
+
624
+ # Issue #2336 — the method a `send` hand-off names (`recv.send(:poke, h)`, `send("poke", h)`).
625
+ def send_target(call_node) = call_node.arguments.arguments.first.unescaped.to_sym
626
+
627
+ # `callees` with each offset one further: the callees of a `send` hand-off's target, whose first argument is the
628
+ # name. A capped answer stays as it is.
629
+ def shifted_callees(callees)
630
+ return callees if callees.empty? || callees.equal?(CAPPED_HAND_OFF)
631
+
632
+ shifted = callees.map { |def_node, offset, owner| [def_node, offset + 1, owner] }
633
+ callees.is_a?(UnionCallees) ? UnionCallees.new(shifted) : shifted
634
+ end
635
+
636
+ # `[def, 0, owner]` for `name` called on a receiver of `receiver_type`, or none.
637
+ def callees_for_receiver_type(receiver_type, name)
638
+ if receiver_type.is_a?(Type::Union)
639
+ return receiver_type.members.flat_map { |member| callees_for_receiver_type(member, name) }
640
+ .uniq { |callee, _, owner| [callee.object_id, owner] }
641
+ end
642
+
643
+ singleton = receiver_type.is_a?(Type::Singleton)
644
+ return NO_CALLEES unless singleton || receiver_type.is_a?(Type::Nominal)
645
+
646
+ class_name = receiver_type.class_name
647
+ found, defining =
648
+ if singleton then scope.singleton_def_through_ancestors(class_name, name)
649
+ else scope.user_def_through_ancestors(class_name, name)
650
+ end
651
+ side = singleton ? :singleton : :instance
652
+ return unresolved_receiver_callees(receiver_type, side, name) if found.nil?
653
+
654
+ [[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
655
+ end
656
+
657
+ # `owner`, the owner a def found on `defining` by a lookup on `class_name` is walked under, or {FLOOR_ALL} for a
658
+ # def the class inherits (`defining` is not `class_name`). An inherited def is not walked (#2521): walking it
659
+ # exposed the own-def census's blind spots, which #2595 closes ({#callee_write_census}), and the hand-offs #2336
660
+ # lists, which stay open and which the floor masks.
661
+ def walked_owner(defining, class_name, owner)
662
+ defining == class_name ? owner : FLOOR_ALL
663
+ end
664
+
665
+ # Issue #2336 — what a call of `name` on a receiver of `receiver_type` reaches when its `side` chain has no def of
666
+ # the name: a body an ownerless definition row may give it ({#receiver_ownerless_body?}), floored outright; on
667
+ # the singleton side, a same-named instance def
668
+ # written as a `module_function def x` argument ({Scope#module_function_row_def?}), which the singleton def table
669
+ # does not hold, walked as a class method; else the chain's `method_missing`.
670
+ def unresolved_receiver_callees(receiver_type, side, name)
671
+ class_name = receiver_type.class_name
672
+ return OPAQUE_CALLEES if receiver_ownerless_body?(name, class_name)
673
+
674
+ if side == :singleton
675
+ found, defining = scope.user_def_through_ancestors(class_name, name)
676
+ if found && scope.module_function_row_def?(found, defining)
677
+ return [[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
678
+ end
679
+ end
680
+ method_missing_callees(class_name, side, name, 0)
681
+ end
682
+
683
+ # Issue #2336 — true when an ownerless definition row may give `name` a body on `class_name`, the receiver of an
684
+ # explicit-receiver call ({Scope#ownerless_definition?}). A `define_method` / `define_singleton_method` /
685
+ # `alias_method` call or a def in a block claims no owner, so it may be the receiver's own and always counts. A
686
+ # top-level def lives on `Object`, so a method RBS declares on the receiver's class answers ahead of it
687
+ # (`Bag < Hash` and a top-level `def store`): the top-level rows count only when RBS declares none.
688
+ def receiver_ownerless_body?(name, class_name)
689
+ return true if scope.ownerless_definition?(name, top_level: false)
690
+ return false if rbs_declares?(class_name, name)
691
+
692
+ scope.ownerless_definition?(name)
693
+ end
694
+
695
+ # Issue #2336 — true when a body no def table holds may answer the receiverless call of `name` in `class_name`'s
696
+ # `side` chain, which no def, `method_missing` or top-level def answers: a definer the chain records
697
+ # (`define_method(:format)` in the class body; {Scope#discovered_method_through_ancestors?}), or, for a name RBS
698
+ # does not declare on `Object` (`Class` on the singleton side), any ownerless definition row. A name `Object`
699
+ # declares (`warn`, `format`) is answered by that method once the chain has no definer of it, so a `def warn` in a
700
+ # `refine Kernel` block or a `define_method` on another class does not count.
701
+ def self_ownerless_body?(name, class_name, side)
702
+ return true if scope.discovered_method_through_ancestors?(class_name, name, side)
703
+ return false if Reflection.instance_method_definition(side == :singleton ? "Class" : "Object", name,
704
+ scope: scope)
705
+
706
+ scope.ownerless_definition?(name)
707
+ end
708
+
709
+ # Issue #2521 (#2570 review) — the `[def, offset, FLOOR_ALL]` a call of `name` that resolves to no def on
710
+ # `class_name`'s `side` chain reaches when the chain defines `method_missing` and `name` is not a method every
711
+ # object (`Object`, or `Class` on the singleton side) answers: Ruby calls `method_missing(name, *args)`, so its
712
+ # parameters sit `offset` places ahead of the call's arguments (-1; 0 for a `send(:name, ...)` hand-off).
713
+ def method_missing_callees(class_name, side, name, offset)
714
+ found =
715
+ if side == :singleton then scope.singleton_def_through_ancestors(class_name, :method_missing).first
716
+ else scope.user_def_through_ancestors(class_name, :method_missing).first
717
+ end
718
+ return NO_CALLEES if found.nil?
719
+
720
+ base = side == :singleton ? "Class" : "Object"
721
+ return NO_CALLEES if Reflection.instance_method_definition(base, name, scope: scope)
722
+
723
+ [[found, offset - 1, FLOOR_ALL]]
724
+ end
725
+
726
+ # What a cross-class hand-off carries instead of an evaluator: the receiver type, which is `self` in the callee.
727
+ # The def's lexical nesting is not carried: it is a property of the def node ({#recorded_def_nesting}), which
728
+ # keys the census memo and the chain's nodes beside it. Holds no scope, so the run-scoped census memo that keeps
729
+ # an {CalleeMutationChain::Edge} alive keeps no caller state alive.
730
+ CalleeOwner = Data.define(:self_type)
731
+
732
+ # The owner of an inherited def ({#walked_owner}): its census and hand-offs are not walked. Every content
733
+ # parameter reads as mutated, so the argument is floored, and a {BudgetTrace::CALLEE_CHAIN_CAP} hit is recorded.
734
+ FLOOR_ALL = :floor_all
735
+
736
+ # Issue #2515 — the owner of a top-level def that may not be the method the call runs (several defs of the name,
737
+ # an alias, a class chain that may answer it first; {ToplevelDefCensus}, {#top_level_callees}): the def is not
738
+ # read, and EVERY argument the call passes reads as written, whatever the def's own parameters are, since another
739
+ # def of the name may take more of them. A hand-off to it floors the handed parameter outright.
740
+ FLOOR_EVERY = :floor_every
741
+
742
+ # Issue #2336 — the answer for a call no def resolves whose arguments the floor reads as written: an opaque sink
743
+ # ({#opaque_sink_call?}), or a name a definer other than a `def` may give a body ({#receiver_ownerless_body?},
744
+ # {#self_ownerless_body?}). No def is
745
+ # read; every argument floors as under {FLOOR_EVERY}.
746
+ OPAQUE_CALLEES = [[nil, 0, FLOOR_EVERY].freeze].freeze
747
+
748
+ # The owner of a top-level def reached from inside a class: its hand-offs resolve at the top level, where `self`
749
+ # is no instance of the caller's class.
750
+ TOPLEVEL_OWNER = CalleeOwner.new(nil)
751
+ OVER_APPROXIMATING = [FLOOR_ALL, FLOOR_EVERY].freeze
752
+ private_constant :CalleeOwner, :FLOOR_ALL, :FLOOR_EVERY, :OPAQUE_CALLEES, :TOPLEVEL_OWNER, :OVER_APPROXIMATING
753
+
754
+ # True for `Const.new` where the class defines its own `self.new`, whose result is not an instance of `Const`.
755
+ def overridden_constructor?(receiver, typing_scope)
756
+ return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
757
+
758
+ klass = typing_scope.type_of(receiver.receiver) if constant_node?(receiver.receiver)
759
+ klass.is_a?(Type::Singleton) &&
760
+ !typing_scope.singleton_def_through_ancestors(klass.class_name, :new).first.nil?
761
+ end
762
+
763
+ # Issue #2522 — the callees a hand-off inside `def_node`'s body (`Help.c(p, o)`, `Helper.new.c(p, o)`) resolves
764
+ # to: the receiver typed once under the `Module.nesting` the declaration walk recorded for `def_node`, so a bare
765
+ # constant follows Ruby's lookup (the lexical chain, the innermost cref's ancestors, the top level) for the
766
+ # spelling the def is actually written under (`class B::Opts` reads `::Helper`, `module B; class Opts` reads
767
+ # `B::Helper`). The nesting is a function of the def node, never of the caller, so the answer cannot depend on
768
+ # which caller reached the memoised edges first. A def with no recorded nesting takes
769
+ # {#unrecorded_hand_off_callees}.
770
+ def hand_off_receiver_callees(call_node, def_node, receiver = call_node.receiver)
771
+ nesting = exact_def_nesting(def_node)
772
+ return unrecorded_hand_off_callees(call_node, receiver) if nesting.nil?
773
+
774
+ receiver_callees_in(scope.with_lexical_nesting(nesting), call_node, receiver)
775
+ end
776
+
777
+ # The def's recorded `Module.nesting` ({Scope::DiscoveryIndex#recorded_def_nesting}), nil when the declaration
778
+ # walk recorded none.
779
+ def recorded_def_nesting(def_node) = scope.discovery.recorded_def_nesting(def_node)
780
+
781
+ # The nesting the hand-offs of `def_node`, run with `self_type`, resolve their constants in exactly, or nil. A
782
+ # def in a `class << self` body answers nil: there Ruby's cref is the singleton class, whose ancestors are not
783
+ # the class's (#2570 review), and the recorded nesting does not name it. Such a def is receiverless, runs on the
784
+ # class object, and is not in its cref's instance def table, which a `module_function` / `extend self` def or an
785
+ # `extend`ed module's def (whose cref is the module itself) is.
786
+ def exact_def_nesting(def_node, self_type = scope.self_type)
787
+ nesting = recorded_def_nesting(def_node)
788
+ return nesting unless self_type.is_a?(Type::Singleton) && def_node.receiver.nil?
789
+ return nil if nesting.nil? || nesting.empty?
790
+
791
+ scope.user_def_for(nesting.first, def_node.name).equal?(def_node) ? nesting : nil
792
+ end
793
+
794
+ # A hand-off in a def whose nesting was not recorded (a def under a header that renders no name, a bundle-served
795
+ # def outside a rehydrating run): the spelling of the defining class is unknown (`module A; class B::C` has the
796
+ # nesting `["A::B::C", "A"]`, `class A::B::C` has `["A::B::C"]`), so the union of two resolutions that try
797
+ # every spelling it could have had, each of which only adds callees:
798
+ #
799
+ # - By name: a bare constant `K` as each prefix of the class name followed by `K`, and as the top-level `K`; a
800
+ # rooted one (`::K`) as `K` alone.
801
+ # - Typed: the receiver typed under each truncation of the nested-spelling nesting (`["A::B::C", "A::B", "A"]`
802
+ # gives `["A::B::C"]`, `["A::B::C", "A::B"]` and the whole chain), which sees an alias, an inherited or
803
+ # included constant and a constant holding an instance.
804
+ def unrecorded_hand_off_callees(call_node, receiver = call_node.receiver)
805
+ constructed = receiver.is_a?(Prism::CallNode) && receiver.name == :new
806
+ written = constant_name(constructed ? receiver.receiver : receiver)
807
+ return NO_CALLEES if written.nil?
808
+ return compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed) if hand_off_memo_bypassed?
809
+
810
+ memo = unrecorded_hand_off_memo(written)
811
+ key = [constructed, call_node.name]
812
+ unless memo.key?(key)
813
+ return memo[key] = compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
814
+ end
815
+
816
+ MemoAudit.check("unrecorded hand-off", memo[key], written, key) do
817
+ compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
818
+ end
819
+ end
820
+
821
+ def compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
822
+ found = hand_off_class_names(written).flat_map do |class_name|
823
+ hand_off_callee_in(class_name, constructed, call_node.name)
824
+ end
825
+ hand_off_typing_scopes(written).each do |typing_scope|
826
+ found.concat(receiver_callees_in(typing_scope, call_node, receiver))
827
+ end
828
+ found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
829
+ end
830
+
831
+ # The run's per-discovery memo of {#unrecorded_hand_off_callees}: the answer depends on the receiver's spelling,
832
+ # the class `self` is and its side (a constant's ancestor lookup differs between the class object and an
833
+ # instance), and the called name, not on the def it sits in.
834
+ def unrecorded_hand_off_memo(written)
835
+ table = (Thread.current[UNRECORDED_HAND_OFF_KEY] ||= ObjectSpace::WeakKeyMap.new)
836
+ self_type = scope.self_type
837
+ by_side = ((table[scope.discovery] ||= {})[self_type.is_a?(Type::Singleton)] ||= {})
838
+ (by_side[enclosing_class_name_for(self_type)] ||= {})[written] ||= {}
839
+ end
840
+
841
+ UNRECORDED_HAND_OFF_KEY = RunScopedSlots.register(:rigor_callee_unrecorded_hand_offs)
842
+ private_constant :UNRECORDED_HAND_OFF_KEY
843
+
844
+ # The scopes {#unrecorded_hand_off_callees} types a receiver written as `written` in. A rooted constant and a
845
+ # top-level def are typed once.
846
+ def hand_off_typing_scopes(written)
847
+ name = enclosing_class_name_for(scope.self_type)
848
+ return [scope.with_lexical_nesting([])] if name.nil?
849
+ return [scope] if written.start_with?("::")
850
+
851
+ parts = name.to_s.split("::")
852
+ chain = parts.each_index.map { |i| parts[0..(parts.size - 1 - i)].join("::") }
853
+ chain.each_index.map { |k| scope.with_lexical_nesting(chain[0..k]) }
854
+ end
855
+
856
+ # `[def, 0, owner]` for `name` called on `class_name` (or on its `new`), or none.
857
+ def hand_off_callee_in(class_name, constructed, name)
858
+ if constructed
859
+ return NO_CALLEES unless scope.singleton_def_through_ancestors(class_name, :new).first.nil?
860
+
861
+ found = scope.user_def_for(class_name, name)
862
+ return found.nil? ? NO_CALLEES : [[found, 0, CalleeOwner.new(Type::Combinator.nominal_of(class_name))]]
863
+ end
864
+
865
+ found, defining = scope.singleton_def_through_ancestors(class_name, name)
866
+ return NO_CALLEES if found.nil?
867
+
868
+ owner = CalleeOwner.new(Type::Combinator.singleton_of(class_name))
869
+ [[found, 0, walked_owner(defining, class_name, owner)]]
870
+ end
871
+
872
+ # The class names a constant written as `written` (`K`, `A::K`, `::K`) can name from the def's class name.
873
+ def hand_off_class_names(written)
874
+ return [written.delete_prefix("::")] if written.start_with?("::")
875
+
876
+ name = enclosing_class_name_for(scope.self_type)
877
+ return [written] if name.nil?
878
+
879
+ parts = name.to_s.split("::")
880
+ parts.each_index.map { |i| "#{parts[0..i].join('::')}::#{written}" } << written
881
+ end
882
+
883
+ # True when a census memo must not be read or written: a dependency recorder is active (it must see each
884
+ # constant read), or this scope carries guard narrowings of constants (`if INST.is_a?(Sub)`), which survive
885
+ # `with_self_type` and change what the constant types as, so the answer is no longer the def's alone. An audit
886
+ # recomputation ({MemoAudit}) bypasses them too, so it reads no stored entry.
887
+ def hand_off_memo_bypassed?
888
+ Analysis::DependencyRecorder.active? || !scope.constant_narrowings.empty? || MemoAudit.suspended?
889
+ end
890
+
891
+ # The name a constant node is written with, or nil when part of it is not a constant (`foo::Bar`).
892
+ def constant_name(node)
893
+ node.full_name if constant_node?(node)
894
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError, Prism::ConstantPathNode::MissingNodesInConstantPathError
895
+ nil
896
+ end
897
+
898
+ # The evaluator a hand-off's {CalleeOwner} stands for, built from this one's scope and kept on it.
899
+ def owner_evaluator(owner)
900
+ (@owner_evaluators ||= {})[owner] ||=
901
+ self.class.new(scope: scope.with_self_type(owner.self_type))
902
+ end
903
+
904
+ # The `[def, offset, owner]` triples a call inside `def_node`'s body hands its parameter to: a self-call
905
+ # ({#resolve_self_callees}, owner nil) or a call on a constant / `Const.new` receiver ({#hand_off_receiver?},
906
+ # {#hand_off_receiver_callees}).
907
+ #
908
+ # Issue #2336 — a call on `itself` is a self-call, and one on `self.class` resolves on the class object `self`
909
+ # types as. A call on any other receiver the callee's body cannot type (a call's result: `new.store(h)`,
910
+ # `instance.helper(h)`; a class variable or a global) resolves by name, as an ivar receiver does. A literal
911
+ # `send` hand-off on any receiver resolves as its target.
912
+ def hand_off_callees(call_node, def_node, depth = 0)
913
+ receiver = call_node.receiver
914
+ return self_hand_off_callees(call_node) if self_dispatch_call?(call_node) || itself_receiver?(receiver)
915
+
916
+ if receiver.is_a?(Prism::LocalVariableReadNode) || receiver.is_a?(Prism::ItLocalVariableReadNode)
917
+ return local_receiver_callees(call_node, def_node, depth)
918
+ end
919
+ return receiver_callees_in(scope, call_node) if class_of_self?(receiver)
920
+ return hand_off_receiver_callees(call_node, def_node) if constant_receiver_form?(receiver)
921
+
922
+ named_hand_off_callees(call_node)
923
+ end
924
+
925
+ # {#named_instance_callees} for `call_node`'s name, or a literal `send` hand-off's target.
926
+ def named_hand_off_callees(call_node)
927
+ return named_instance_callees(call_node.name) unless send_hand_off?(call_node)
928
+
929
+ shifted_callees(named_instance_callees(send_target(call_node)))
930
+ end
931
+
932
+ # `itself` / `self.itself`, which is `self`.
933
+ def itself_receiver?(receiver)
934
+ receiver.is_a?(Prism::CallNode) && receiver.name == :itself && receiver.arguments.nil? &&
935
+ receiver.block.nil? && self_dispatch_call?(receiver)
936
+ end
937
+
938
+ # `self.class`, the class object of `self`.
939
+ def class_of_self?(receiver)
940
+ receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode) &&
941
+ receiver.arguments.nil? && receiver.block.nil?
942
+ end
943
+
944
+ # {#resolve_self_callees} for a self-call that is a hand-off inside a def. In a def the evaluator is the top
945
+ # level's (`self` nil), the def a top-level one, and a top-level def is a private method on `Object`: it may be
946
+ # called from any object, so a same-named instance def of the caller's class answers the hand-off before the
947
+ # top-level one. Issue #2515 — the project's same-named instance defs are unioned in as a receiver the callee
948
+ # cannot type is ({#named_instance_callees}): they floor, never narrow.
949
+ def self_hand_off_callees(call_node)
950
+ found = resolve_self_callees(call_node)
951
+ return found unless enclosing_class_name_for(scope.self_type).nil?
952
+
953
+ send_hand_off = send_hand_off?(call_node)
954
+ target = send_hand_off ? call_node.arguments.arguments.first.unescaped.to_sym : call_node.name
955
+ named = named_instance_callees(target)
956
+ return named if named.equal?(CAPPED_HAND_OFF)
957
+ return found if named.empty?
958
+
959
+ named = named.map { |def_node, _offset, owner| [def_node, 1, owner] } if send_hand_off
960
+ UnionCallees.new(found + named)
961
+ end
962
+
963
+ # True for a call on an explicit receiver: one the callee's body can type without its locals (a constant or
964
+ # `Const.new`), a local of the callee (#2515: {#local_receiver_callees}), or (issue #2595 for an ivar, #2336 for
965
+ # any other) one whose type the def's AST does not show, so it resolves by name ({#named_hand_off_callees}).
966
+ def hand_off_receiver?(node) = node.is_a?(Prism::CallNode) && !node.receiver.nil?
967
+
968
+ def constant_receiver_form?(receiver)
969
+ case receiver
970
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then true
971
+ when Prism::CallNode then receiver.name == :new && constant_node?(receiver.receiver)
972
+ else false
973
+ end
974
+ end
975
+
976
+ # Issue #2515 — the callees of `local.name(p, o)` inside `def_node`, where `local` is a parameter or a local of
977
+ # the def. A local every assignment of which in the def is a constant receiver form (`Const`, `Const.new`) is
978
+ # typed inside the callee, as the constant receiver is ({#hand_off_receiver_callees}). Any other receiver (a
979
+ # parameter, a local assigned anything else, a block-local) has a type that depends on the caller, which is
980
+ # not a function of the graph node, so the call resolves by name instead ({#named_instance_callees}).
981
+ def local_receiver_callees(call_node, def_node, depth)
982
+ receiver = call_node.receiver
983
+ typed = receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
984
+ forms = typed_local_forms(def_node, receiver.name) if typed
985
+ forms = nil if forms && local_object_opened?(def_node, call_node)
986
+ return named_hand_off_callees(call_node) if forms.nil?
987
+
988
+ found = forms.flat_map { |form| hand_off_receiver_callees(call_node, def_node, form) }
989
+ found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
990
+ end
991
+
992
+ # The constant receiver forms every assignment of the local `name` in `def_node` holds, or nil when `name` is a
993
+ # parameter, is never assigned, or may hold anything else. The whole body is read, including the scopes the
994
+ # hand-off walk leaves out (a lambda, an `instance_exec` / `class_exec` / `define_method` block, a block that
995
+ # shadows the name), so a write there counts. The local is untyped when any write is not a constant form (an
996
+ # op-write, a multiple assignment, `for` or `rescue =>` target, a pattern capture, any other value), when it is
997
+ # the receiver of `send` / `public_send` / `__send__` / `singleton_class` or is assigned to another local
998
+ # (`g = h`), and, for every local, when the body calls `binding`, `eval`, `instance_eval` or
999
+ # `local_variable_set` on any receiver, which can write a local no node names. A local whose object the syntax
1000
+ # gives methods ({#local_object_opened?}) is untyped at that call. A per-def AST fact, memoised as {#def_census}.
1001
+ def typed_local_forms(def_node, name)
1002
+ def_census(:local_forms, def_node) { local_forms_of(def_node) }[name]
1003
+ end
1004
+
1005
+ # True when the file's syntax gives the receiver local's object methods the declaration tables do not show
1006
+ # (`def h.x`, `h.extend(M)`, `h.define_singleton_method`, `h.singleton_class...`, `class << h`, an
1007
+ # `h.instance_eval` that defines): {Analysis::CheckRules::LexicalMethodSites}, asked once per def for every
1008
+ # call on a local.
1009
+ def local_object_opened?(def_node, call_node)
1010
+ def_census(:opened_local_calls, def_node) { opened_local_call_offsets(def_node) }
1011
+ .include?(call_node.location.start_offset)
1012
+ end
475
1013
 
476
- def_node = resolve_self_callee_def(call_node)
477
- return NO_ARGUMENT_READS if def_node.nil?
1014
+ def opened_local_call_offsets(def_node)
1015
+ require_relative "../analysis/check_rules/lexical_method_sites"
1016
+ sites = Analysis::CheckRules::LexicalMethodSites.new(def_node)
1017
+ offsets = Set.new
1018
+ Source::NodeWalker.each(def_node.body) do |node|
1019
+ next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode)
478
1020
 
479
- mutated = callee_content_mutated_parameters(def_node)
1021
+ offsets << node.location.start_offset if sites.singleton_local_def?(node)
1022
+ end
1023
+ offsets.freeze
1024
+ end
1025
+
1026
+ DYNAMIC_LOCAL_CALLS = Set[:binding, :eval, :instance_eval, :local_variable_set].freeze
1027
+ DISPATCHING_CALLS = Set[:send, :public_send, :__send__, :singleton_class].freeze
1028
+ private_constant :DYNAMIC_LOCAL_CALLS, :DISPATCHING_CALLS
1029
+
1030
+ def local_forms_of(def_node)
1031
+ forms = {}
1032
+ tainted = Set.new
1033
+ return NO_FORMS if collect_local_forms(def_node.body, forms, tainted)
1034
+
1035
+ params = def_node.parameters
1036
+ forms.each_value(&:uniq!)
1037
+ forms.delete_if do |name, values|
1038
+ tainted.include?(name) || !values.all? { |value| constant_receiver_form?(value) } ||
1039
+ parameter_named?(params, name)
1040
+ end
1041
+ forms.empty? ? NO_FORMS : forms
1042
+ end
1043
+
1044
+ # Fills `forms` (`{ name => written values }`) and `tainted` from `node` and everything under it, a nested `def`
1045
+ # body excepted (its locals are its own). True when the body may write locals the nodes do not show.
1046
+ def collect_local_forms(node, forms, tainted)
1047
+ return false if node.nil?
1048
+
1049
+ return false if node.is_a?(Prism::DefNode)
1050
+ return true if node.is_a?(Prism::CallNode) && dynamic_local_call?(node, tainted)
1051
+
1052
+ note_local_write(node, forms, tainted)
1053
+ dynamic = false
1054
+ node.rigor_each_child { |child| dynamic = true if collect_local_forms(child, forms, tainted) }
1055
+ dynamic
1056
+ end
1057
+
1058
+ def note_local_write(node, forms, tainted)
1059
+ case node
1060
+ when Prism::LocalVariableWriteNode
1061
+ (forms[node.name] ||= []) << node.value
1062
+ tainted << node.value.name if node.value.is_a?(Prism::LocalVariableReadNode)
1063
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
1064
+ Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
1065
+ (forms[node.name] ||= []) << node
1066
+ end
1067
+ end
1068
+
1069
+ # True for a `binding` / `eval` / ... call on any receiver (`Kernel.binding` included), which can write a local no
1070
+ # node names. A local receiving a dispatching call (`send`, `singleton_class`, ...) is tainted as a side effect.
1071
+ def dynamic_local_call?(node, tainted)
1072
+ return true if DYNAMIC_LOCAL_CALLS.include?(node.name)
1073
+
1074
+ tainted << node.receiver.name if DISPATCHING_CALLS.include?(node.name) &&
1075
+ node.receiver.is_a?(Prism::LocalVariableReadNode)
1076
+ false
1077
+ end
1078
+
1079
+ NO_FORMS = {}.freeze
1080
+ private_constant :NO_FORMS
1081
+
1082
+ def parameter_named?(params, name)
1083
+ return false if params.nil?
1084
+
1085
+ all = [params.requireds, params.optionals, params.posts, params.keywords,
1086
+ [params.rest, params.keyword_rest, params.block]]
1087
+ all.flatten.compact.any? { |param| param.respond_to?(:name) && param.name == name }
1088
+ end
1089
+
1090
+ # Issue #2515 — the `[def, 0, owner]` triples of every user instance def named `name`, for a hand-off on a
1091
+ # receiver whose type the callee cannot know. It over-approximates (it favours flooring, ADR-5), as
1092
+ # {#unrecorded_hand_off_callees} unions spellings. A def on a class nothing subclasses is walked under that
1093
+ # class. A def on a module, or on a class a project class names as its superclass, is {FLOOR_ALL}: the real
1094
+ # receiver may be an includer or a subclass whose overrides the walk would not see (ADR-127 WD4, as an inherited
1095
+ # def, {#walked_owner}). A name no user def has (a core or gem method) contributes nothing; a name with more
1096
+ # than {NAMED_HAND_OFF_CAP} defs answers {CAPPED_HAND_OFF}, which floors the handed parameter outright.
1097
+ def named_instance_callees(name)
1098
+ owners = instance_def_owners(name)
1099
+ return NO_CALLEES if owners.empty?
1100
+ return CAPPED_HAND_OFF if owners.size > NAMED_HAND_OFF_CAP
1101
+
1102
+ opaque = subclassing_opaque?
1103
+ UnionCallees.new(owners.filter_map do |class_name, open|
1104
+ found = scope.user_def_for(class_name, name)
1105
+ next if found.nil?
1106
+
1107
+ [found, 0, open || opaque ? FLOOR_ALL : CalleeOwner.new(Type::Combinator.nominal_of(class_name))]
1108
+ end)
1109
+ end
1110
+
1111
+ # A resolution by name, whose candidates are guesses at the receiver: every edge it yields is an
1112
+ # over-approximation ({CalleeMutationChain::Edge#approx}), as is any hand-off with more than one candidate.
1113
+ class UnionCallees < Array; end
1114
+ private_constant :UnionCallees
1115
+
1116
+ # The most defs one name may have for {#named_instance_callees} to walk them; more floors the parameter. A cap
1117
+ # 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
1118
+ # not apply: the answer stays a function of the graph node.
1119
+ NAMED_HAND_OFF_CAP = 8
1120
+
1121
+ # The answer for a name above the cap; {#compute_forwarding_edges} turns it into a floor of each handed
1122
+ # parameter.
1123
+ CAPPED_HAND_OFF = [[nil, 0, nil].freeze].freeze
1124
+ private_constant :NAMED_HAND_OFF_CAP, :CAPPED_HAND_OFF
1125
+
1126
+ # The `[class, open]` owners of `name` ({Scope#user_instance_def_owners}). A function of the discovery and the
1127
+ # name alone, so its run-scoped memo is keyed by both; the answer reads no constant and no narrowing, so
1128
+ # {#hand_off_memo_bypassed?} does not bypass it. A hit does not reach the scope, so a dependency recorder is told
1129
+ # of the reads here. Built per name, not as a whole index: a run holds a discovery per file, and a whole-index
1130
+ # build per file cost more than the walks it served.
1131
+ def instance_def_owners(name)
1132
+ return scope.user_instance_def_owners(name) if MemoAudit.suspended?
1133
+
1134
+ table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
1135
+ by_name = (table[scope.discovery] ||= {})
1136
+ return by_name[name] = scope.user_instance_def_owners(name) unless by_name.key?(name)
1137
+
1138
+ record_instance_def_owner_reads(name, by_name[name]) if Analysis::DependencyRecorder.active?
1139
+ MemoAudit.check("instance def owners", by_name[name], name) { scope.user_instance_def_owners(name) }
1140
+ end
1141
+
1142
+ # {Scope#subclassing_opacity}'s flag, memoised beside the owners per discovery; a recorder is told its keys on
1143
+ # every call, hit or not.
1144
+ def subclassing_opaque?
1145
+ return scope.subclassing_opacity.first if MemoAudit.suspended?
1146
+
1147
+ table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
1148
+ by_name = (table[scope.discovery] ||= {})
1149
+ entry = by_name[OPACITY_ENTRY] ||= scope.subclassing_opacity
1150
+ Analysis::DependencyRecorder.read_keys(entry.last) if Analysis::DependencyRecorder.active?
1151
+ MemoAudit.check("subclassing opacity", entry, OPACITY_ENTRY) { scope.subclassing_opacity }.first
1152
+ end
1153
+
1154
+ OPACITY_ENTRY = :"<subclassing-opacity>"
1155
+ private_constant :OPACITY_ENTRY
1156
+
1157
+ def record_instance_def_owner_reads(name, owners)
1158
+ Analysis::DependencyRecorder.read_name(:defines, name)
1159
+ owners.map(&:first).each { |owner| Analysis::DependencyRecorder.read_last_segment(:subclass_of, owner) }
1160
+ end
1161
+
1162
+ INSTANCE_DEF_INDEX_KEY = RunScopedSlots.register(:rigor_callee_instance_def_index)
1163
+ private_constant :INSTANCE_DEF_INDEX_KEY
1164
+
1165
+ def constant_node?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
1166
+
1167
+ # `census` (a `callee_*_mutated_parameters` name) of `def_node`, read through `owner`, the {CalleeOwner} the
1168
+ # callee's hand-offs resolve in, or this evaluator when the callee is a self-call's.
1169
+ def census_of(owner, census, def_node)
1170
+ return floor_all_census(census, def_node) if owner == FLOOR_ALL
1171
+ return floor_every_census(census) if owner == FLOOR_EVERY
1172
+
1173
+ (owner.nil? ? self : owner_evaluator(owner)).__send__(census, def_node)
1174
+ end
1175
+
1176
+ # The {FLOOR_EVERY} answer: every positional and keyword argument mutated, no hand-offs.
1177
+ def floor_every_census(census)
1178
+ BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
1179
+ census == :forwarding_edges ? NO_FORWARDS : EVERY_ARGUMENT
1180
+ end
1181
+
1182
+ # The {FLOOR_ALL} answer: every content parameter mutated, and (issue #2595) a named rest or keyword rest at
1183
+ # {ANY_SLOT} / {ANY_KEYWORD} ({#forwarding_slots}), no hand-offs.
1184
+ def floor_all_census(census, def_node)
1185
+ BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
1186
+ census == :forwarding_edges ? NO_FORWARDS : forwarding_slots(def_node)
1187
+ end
1188
+
1189
+ # The local-variable reads `call_node` passes to the parameters of `mutated`, a `{ name => slot }` census, the
1190
+ # first `offset` arguments not being the callee's (the `:name` of a `send` hand-off, {#resolve_self_callees}).
1191
+ def arguments_for_slots(call_node, mutated, offset)
480
1192
  return NO_ARGUMENT_READS if mutated.empty?
481
1193
 
482
1194
  argument_nodes = call_node.arguments.arguments
483
- mutated.values.uniq.filter_map do |index|
484
- argument = argument_nodes[index]
1195
+ values = mutated.values.uniq
1196
+ reads = values.filter_map do |slot|
1197
+ next if slot == ANY_SLOT || slot == ANY_KEYWORD || (slot.is_a?(Integer) && (slot + offset).negative?)
1198
+
1199
+ argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot + offset]
1200
+ argument = argument.value if argument.is_a?(Prism::ImplicitNode)
485
1201
  argument if argument.is_a?(Prism::LocalVariableReadNode)
486
1202
  end
1203
+ reads += rest_arguments(argument_nodes, offset) if values.include?(ANY_SLOT)
1204
+ reads += keyword_rest_arguments(argument_nodes) if values.include?(ANY_KEYWORD)
1205
+ reads.uniq
1206
+ end
1207
+
1208
+ # Every local read passed positionally from `offset` on: what a callee that hands its rest parameter to `super`
1209
+ # may mutate ({#super_forwarded_parameters}).
1210
+ def rest_arguments(argument_nodes, offset)
1211
+ argument_nodes.drop([offset, 0].max).grep(Prism::LocalVariableReadNode)
1212
+ end
1213
+
1214
+ # Every local read passed as a keyword value: what a callee that hands its keyword rest parameter to `super` may
1215
+ # mutate ({#super_forwarded_parameters}).
1216
+ def keyword_rest_arguments(argument_nodes)
1217
+ hash = argument_nodes.last
1218
+ return NO_ARGUMENT_READS unless hash.is_a?(Prism::KeywordHashNode)
1219
+
1220
+ hash.elements.filter_map do |pair|
1221
+ value = pair.value if pair.is_a?(Prism::AssocNode)
1222
+ value = value.value if value.is_a?(Prism::ImplicitNode)
1223
+ value if value.is_a?(Prism::LocalVariableReadNode)
1224
+ end
1225
+ end
1226
+
1227
+ # The slot {#super_forwarded_parameters} gives a keyword rest parameter: every keyword value of the call.
1228
+ ANY_KEYWORD = :__any_keyword__
1229
+ private_constant :ANY_KEYWORD
1230
+
1231
+ # The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
1232
+ def keyword_argument(argument_nodes, name)
1233
+ hash = argument_nodes.last
1234
+ return unless hash.is_a?(Prism::KeywordHashNode)
1235
+
1236
+ pair = hash.elements.find do |element|
1237
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
1238
+ element.key.unescaped.to_sym == name
1239
+ end
1240
+ pair&.value
487
1241
  end
488
1242
 
489
1243
  NO_ARGUMENT_READS = [].freeze
@@ -509,16 +1263,37 @@ module Rigor
509
1263
  # evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
510
1264
  # body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
511
1265
  # absence of evidence.
1266
+ #
1267
+ # On a receiver that is itself untracked (a `Dynamic` / `Top` constituent, the #544 test the narrowing
1268
+ # record takes: a parameter, an unbound ivar), the slot may hold what an earlier call stored, so the reading
1269
+ # keeps the rvalue only as far as its class is known ({#untracked_memo_value}, #2292): read as the fresh
1270
+ # literal, `(h[k] ||= {})[:name].upcase` reported a call on `nil` and `(h[k] ||= 1).upcase` one on `1`. A
1271
+ # tracked receiver keeps the literal, so a block fold over a captured `pool = {}`, whose in-place widening
1272
+ # reads `Hash[Dynamic[top], Dynamic[top]]`, still answers `find { |s| (pool[s] ||= s) == "b" }` as `"b"`.
512
1273
  def index_compound_write_value(node)
513
1274
  return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
514
1275
 
515
1276
  current = index_read_type(node, scope)
516
1277
  rhs = scope.type_of(node.value, tracer: tracer)
517
- return rhs if memoizing_index_read?(node, current, rhs)
1278
+ if memoizing_index_read?(node, current, rhs)
1279
+ return rhs if tracked_memo_receiver?(node.receiver)
1280
+
1281
+ memo = untracked_memo_value(rhs)
1282
+ return memo if memo
1283
+ end
518
1284
 
519
1285
  index_write_stored_type(node, scope, current: current, rhs: rhs)
520
1286
  end
521
1287
 
1288
+ # The value of `recv.each_with_object(memo) { |x, acc| … }` given `call_type`, the call's dispatched type
1289
+ # (issue #2411): the memo as the block's content mutations of `acc` leave it ({#each_with_object_return}).
1290
+ # `ExpressionTyper` reads it for every call, so a value position — a method's tail, which
1291
+ # {DefReturnTyper} types through `Scope#type_of` — answers what a statement does; read off the dispatch
1292
+ # alone, `def m = xs.each_with_object({}) { |x, h| h[x] = 1 }` returned the memo argument's `{}`.
1293
+ def each_with_object_value(call_node, call_type)
1294
+ each_with_object_return(call_node, call_type)
1295
+ end
1296
+
522
1297
  private
523
1298
 
524
1299
  attr_reader :scope, :tracer
@@ -550,11 +1325,11 @@ module Rigor
550
1325
  end
551
1326
 
552
1327
  # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
553
- # propagate left-to-right) and binds `name` to the result type. Compound assignment forms (`+=` etc.) are deferred
554
- # to a follow-up; for now they degrade to "type the rhs, do not rebind" via the default branch in {#evaluate}.
1328
+ # propagate left-to-right) and binds `name` to the result type.
555
1329
  def eval_local_write(node)
556
1330
  rhs_type, post_rhs = sub_eval(node.value, scope)
557
1331
  bound = bind_local_write(node, rhs_type, post_rhs)
1332
+ bound = bound.with_construction_fact(node.name, :synthesised) if synthesised_write?(node, rhs_type)
558
1333
  # Issue #667 — `m = AppConfig::MODE` makes `m` a copy of a constant the reader's file does not
559
1334
  # declare, so `m == :production` folding to `true` is the project's configuration and not a logic
560
1335
  # error the author can see. Stamped LAST, across every binding path above: an RHS can be both an
@@ -565,6 +1340,53 @@ module Rigor
565
1340
  [rhs_type, bound.with_published_constant_mark(:local, node.name)]
566
1341
  end
567
1342
 
1343
+ # Issue #2555 L3 — whether the object a local write binds is one the engine built itself, so the binding
1344
+ # carries the `:synthesised` construction fact ({Scope#construction_fact?}): a collection literal (`[...]`,
1345
+ # `{...}`), or a constructor call on the core class (`Array.new(n, 0)`, `Hash.new(0)`, `Set.new(xs)`,
1346
+ # `Set[1]`) whose value is a carrier of that class. Nothing else is stamped: not a copy of another binding
1347
+ # (`b = a`), a call whose return a signature declares, a parameter, or an instance variable.
1348
+ # The fact is withheld from a local that may escape anywhere in the body ({ConstructionEscape}): a store through
1349
+ # another holder of the object is one no seam sees, and the fact would leave the carrier without the floor that
1350
+ # covered it.
1351
+ def synthesised_write?(node, rhs_type)
1352
+ synthesised_construction?(node.value, rhs_type) &&
1353
+ !ConstructionEscape.escaped?(scope.match_frame&.body, node.name, constructed_carrier_class(rhs_type), scope)
1354
+ end
1355
+
1356
+ def synthesised_construction?(value_node, rhs_type)
1357
+ case value_node
1358
+ when Prism::ArrayNode, Prism::HashNode then true
1359
+ when Prism::CallNode then core_constructor_call?(value_node, rhs_type)
1360
+ else false
1361
+ end
1362
+ end
1363
+
1364
+ CORE_CONSTRUCTOR_NAMES = %i[new []].to_set.freeze
1365
+ private_constant :CORE_CONSTRUCTOR_NAMES
1366
+
1367
+ # The constant receiver must resolve to the core class itself (a project's own `Admin::Set` does not), and the
1368
+ # value must be a carrier of that class.
1369
+ def core_constructor_call?(call, rhs_type)
1370
+ receiver = call.receiver
1371
+ return false unless CORE_CONSTRUCTOR_NAMES.include?(call.name)
1372
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
1373
+
1374
+ class_name = constructed_carrier_class(rhs_type)
1375
+ return false if class_name.nil?
1376
+
1377
+ receiver_type = scope.type_of(receiver, tracer: tracer)
1378
+ receiver_type.is_a?(Type::Singleton) && receiver_type.class_name == class_name
1379
+ end
1380
+
1381
+ def constructed_carrier_class(type)
1382
+ case type
1383
+ when Type::Tuple then "Array"
1384
+ when Type::HashShape then "Hash"
1385
+ when Type::Nominal then type.class_name if %w[Array Hash Set].include?(type.class_name)
1386
+ when Type::Constant then "Set" if type.value.is_a?(::Set)
1387
+ end
1388
+ end
1389
+
568
1390
  # The binding half of {#eval_local_write}: the three mutually exclusive provenance paths a local write
569
1391
  # can take, unchanged from when they were inline.
570
1392
  def bind_local_write(node, rhs_type, post_rhs)
@@ -664,8 +1486,7 @@ module Rigor
664
1486
  # Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
665
1487
  # and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
666
1488
  # type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
667
- # `build_method_entry_scope`, so writes do not leak across method boundaries until cross-method ivar/cvar tracking
668
- # lands.
1489
+ # `build_method_entry_scope`; cross-method ivar and cvar state enters it through the class-level seeds.
669
1490
  def eval_ivar_write(node)
670
1491
  rhs_type, post_rhs = sub_eval(node.value, scope)
671
1492
  # Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
@@ -678,7 +1499,19 @@ module Rigor
678
1499
  # stamped by {#seed_instance_ivars}.
679
1500
  bound = bound.with_published_constant_mark(:ivar, node.name) if
680
1501
  published_constant_copy?(node.value, rhs_type, post_rhs)
681
- [rhs_type, bound]
1502
+ [rhs_type, mark_inferred_param_ivar(bound, node)]
1503
+ end
1504
+
1505
+ # Issue #2189 — the ivar twin of {#bind_local_write}'s ADR-67 WD6b stamp: `@x = param; @x.upcase` reads the
1506
+ # parameter's open-call-site lower bound back, so the ivar carries the `:inferred_param` mark under its
1507
+ # `@`-prefixed name and {Analysis::CheckRules::InferredParamGuard} declines on the read. A write from any
1508
+ # other RHS clears it. Method-local only: the class-ivar census seeding a sibling method types the RHS
1509
+ # without the inference seed, so no lower bound crosses the method boundary to guard.
1510
+ def mark_inferred_param_ivar(bound, node)
1511
+ return bound.with_inferred_param_mark(node.name) if
1512
+ Analysis::CheckRules::InferredParamGuard.rooted?(node.value, scope)
1513
+
1514
+ bound.without_inferred_param_mark(node.name)
682
1515
  end
683
1516
 
684
1517
  def eval_cvar_write(node)
@@ -832,11 +1665,24 @@ module Rigor
832
1665
 
833
1666
  def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
834
1667
  current_type = current_type_for(kind, node.name)
835
- rhs_type, post_rhs = sub_eval(node.value, scope)
1668
+ rhs_type, post_rhs = sub_eval(node.value, compound_value_scope(kind, node.name, op))
836
1669
  result_type = compound_result_type(current_type, rhs_type, op)
837
1670
  [result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
838
1671
  end
839
1672
 
1673
+ # Issue #2214 — the scope a compound write's value operand runs in. `x &&= v` runs `v` only when `x` is truthy,
1674
+ # as `x && (x = v)` does, so `v` reads `x` narrowed to its truthy part: `c &&= c + 1` on an `Integer?` `c` reads
1675
+ # `Integer`, not the `nil` that drew `call.possible-nil-receiver`. `x ||= v` and an operator write run `v` from
1676
+ # the scope they start in; a variable the scope does not bind is left alone.
1677
+ def compound_value_scope(kind, name, operator)
1678
+ return scope unless operator == :and
1679
+
1680
+ current = scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
1681
+ return scope if current.nil?
1682
+
1683
+ scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, Narrowing.narrow_truthy(current))
1684
+ end
1685
+
840
1686
  VAR_KIND_GETTERS = {
841
1687
  local: :local, ivar: :ivar, cvar: :cvar, global: :global
842
1688
  }.freeze
@@ -879,8 +1725,7 @@ module Rigor
879
1725
  # narrowing keyed on `(receiver_kind, receiver_name,
880
1726
  # literal_key)` when both receiver and key are stable
881
1727
  # (see {Inference::IndexedNarrowing}). Unstable shapes
882
- # fall through to "no scope effect", matching the old
883
- # `Prism::IndexOrWriteNode` default-branch behaviour.
1728
+ # record no narrowing.
884
1729
  #
885
1730
  # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
886
1731
  # post-`||=` value, not the rvalue alone.
@@ -906,7 +1751,63 @@ module Rigor
906
1751
  arg_types: index_write_arg_types(node, result_type))
907
1752
  post = post.with_indexed_narrowing(*address, result_type) if address
908
1753
 
909
- [result_type, forget_rebound_specials(post, node)]
1754
+ # The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
1755
+ # fresh container or construction rvalue, with a container's literal precision erased
1756
+ # ({#memo_bound_value}).
1757
+ [memo_bound_value(node, result_type), forget_rebound_specials(post, node)]
1758
+ end
1759
+
1760
+ # What `x = (tbl[k] ||= v)` binds `x` to. Where {#index_compound_write_value} reads the memoizing idiom, the
1761
+ # slot may hold ANY earlier value of the table, so `x` is the rvalue only as far as its CLASS is known: a
1762
+ # `{}` / `[]` rvalue (a shape or tuple) erases to `Hash[untyped, untyped]` / `Array[untyped]`, because the
1763
+ # stored table is not empty after the first store (`entry[:name].upcase` is no call on nil), and a
1764
+ # construction (`new(...)`, a nominal) is kept. Every other rvalue (a Constant, a union) keeps the stored
1765
+ # `Dynamic | v`, since a literal would read the slot as freshly created at every later read.
1766
+ def memo_bound_value(node, stored)
1767
+ return stored unless node.is_a?(Prism::IndexOrWriteNode)
1768
+
1769
+ value = index_compound_write_value(node)
1770
+ return stored if value == stored
1771
+
1772
+ erased = erase_memo_container(value)
1773
+ erased || stored
1774
+ end
1775
+
1776
+ # The memoizing value on an untracked receiver, or nil when it keeps the stored `Dynamic[top] | v`. A `{}` /
1777
+ # `[]` rvalue (a shape, a tuple, a bare `Hash` / `Array`) erases to `Hash[untyped, untyped]` /
1778
+ # `Array[untyped]`, since the table is not empty after the first store. A construction (a nominal,
1779
+ # parameterised or not, or a `Data` / `Struct` instance, possibly with a `nil` / `false` arm) is kept as the
1780
+ # #1155 reading has it. Anything else carries literal precision (`1`, `:a`, an integer range) and yields nil.
1781
+ def untracked_memo_value(rhs)
1782
+ erased = erase_memo_literal(rhs)
1783
+ return erased if erased
1784
+
1785
+ truthy = Narrowing.narrow_truthy(rhs)
1786
+ members = truthy.is_a?(Type::Union) ? truthy.members : [truthy]
1787
+ rhs if members.all? { |member| MEMO_CONSTRUCTIONS.any? { |klass| member.is_a?(klass) } }
1788
+ end
1789
+
1790
+ MEMO_CONSTRUCTIONS = [Type::Nominal, Type::DataInstance, Type::StructInstance, Type::Dynamic, Type::Top].freeze
1791
+ private_constant :MEMO_CONSTRUCTIONS
1792
+
1793
+ def erase_memo_literal(type)
1794
+ return erase_memo_container(type) unless type.is_a?(Type::Nominal)
1795
+
1796
+ erase_memo_container(type) if type.type_args.empty? && %w[Hash Array].include?(type.class_name)
1797
+ end
1798
+
1799
+ def erase_memo_container(type)
1800
+ any = Type::Combinator.untyped
1801
+ case type
1802
+ when Type::HashShape then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1803
+ when Type::Tuple then Type::Combinator.nominal_of("Array", type_args: [any])
1804
+ when Type::Nominal
1805
+ case type.class_name
1806
+ when "Hash" then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1807
+ when "Array" then Type::Combinator.nominal_of("Array", type_args: [any])
1808
+ else type
1809
+ end
1810
+ end
910
1811
  end
911
1812
 
912
1813
  # `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
@@ -979,6 +1880,28 @@ module Rigor
979
1880
  end
980
1881
  end
981
1882
 
1883
+ # Whether a memoizing `||=` on `receiver` reads as the fresh rvalue ({#index_compound_write_value}): the
1884
+ # receiver's type passes the #544 test, and the receiver is a local the current body binds, captured into a
1885
+ # block or not, and no parameter of the enclosing method names ({MatchRebinding::Frame#body_local?}), or
1886
+ # an object fresh at every run (`Hash.new`, {RepeatedOrWrites.fresh_object?}) (issue
1887
+ # #2354). An ivar, cvar or global read, a parameter, or any other expression (a call result, a
1888
+ # parenthesised `||=`) is a table whose gradual slot another call, or an earlier run of this one, may have
1889
+ # filled — an ivar seeded `{}` in `initialize`, a parameter declared `Hash[Symbol, untyped]` — so it widens
1890
+ # through {#untracked_memo_value} like an untracked receiver. Without a frame (no method, class or file
1891
+ # body stamped one) a local receiver is treated as body-bound, as before #2354.
1892
+ def tracked_memo_receiver?(receiver)
1893
+ return false unless body_bound_memo_receiver?(receiver)
1894
+
1895
+ IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(receiver, tracer: tracer))
1896
+ end
1897
+
1898
+ def body_bound_memo_receiver?(receiver)
1899
+ return RepeatedOrWrites.fresh_object?(receiver) unless receiver.is_a?(Prism::LocalVariableReadNode)
1900
+
1901
+ frame = scope.match_frame
1902
+ frame.nil? || frame.body_local?(receiver.name)
1903
+ end
1904
+
982
1905
  # True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
983
1906
  # reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
984
1907
  def memoizing_index_read?(node, current, rhs)
@@ -1026,17 +1949,46 @@ module Rigor
1026
1949
  # Both mutator-receiver widenings, against one typing of the call's arguments: the bindings the
1027
1950
  # receiver NAMES ({MutationWidening.widen_after_call}) and, when it names none because the
1028
1951
  # receiver is an element read into a local, that element's pin inside its container
1029
- # ({ElementReadWidening.widen_element_read}, issue #643).
1952
+ # ({ElementReadWidening.widen_element_read}, issue #643) — and, issue #2499, the String a stdlib
1953
+ # out-buffer call fills ({OutBufferMutation}), which names no receiver of the call at all.
1030
1954
  def widen_mutated_receivers(call_node, current_scope)
1031
1955
  arg_types = mutator_arg_types(call_node, current_scope)
1032
1956
  widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
1033
- arg_types: arg_types)
1034
- ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened, arg_types: arg_types)
1957
+ arg_types: arg_types,
1958
+ rewritten_element: rewritten_element(call_node, current_scope))
1959
+ widened = ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened,
1960
+ arg_types: arg_types)
1961
+ # Issue #2499 — a stdlib call that fills a String argument (`io.read(n, buf)`, `StringIO.new(s)`).
1962
+ OutBufferMutation.floor(call_node, widened)
1963
+ end
1964
+
1965
+ # Issue #2427 — the element an element rewriter (`map!`, `collect!`, `flatten!`) leaves in its receiver, read
1966
+ # off what the non-bang counterpart answers for the same receiver, block and arguments
1967
+ # ({RewriteMutation::TYPED_COUNTERPARTS}): `a.map!(&:to_i)` stores what `a.map(&:to_i)` returns. `nil` when the
1968
+ # call is no such rewriter, or the counterpart has no single element; the widening then takes
1969
+ # {RewriteMutation}'s gradual arm.
1970
+ #
1971
+ # `nil` too when the receiver may name more than one binding (`(flag ? a : b).map!(&:to_i)`): Ruby rewrites
1972
+ # only the one the branch selects, and typing every candidate gave the other one an element it never holds.
1973
+ #
1974
+ # The block and receiver gates only save typing a counterpart nothing consumes: with no block `map` answers an
1975
+ # Enumerator, which has no element to read, and {RewriteMutation.typed} answers nothing for a receiver the
1976
+ # widening does not rewrite.
1977
+ def rewritten_element(call_node, current_scope)
1978
+ counterpart = RewriteMutation::TYPED_COUNTERPARTS[call_node.name]
1979
+ return nil if counterpart.nil?
1980
+ return nil if counterpart != :flatten && call_node.block.nil? # cost guard
1981
+ return nil unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) # cost guard
1982
+ return nil unless ReceiverAlias.mutated_reads(call_node.receiver).size == 1
1983
+
1984
+ RewriteMutation.counterpart_element(operand_scope.type_of(call_node.copy(name: counterpart)))
1035
1985
  end
1036
1986
 
1037
1987
  # `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
1038
1988
  def mutator_arg_types(call_node, current_scope)
1039
- unless ContentJoin::CONTENT_ADDERS.include?(call_node.name) || StringMutation::TRANSLATORS.include?(call_node.name)
1989
+ name = call_node.name
1990
+ unless ContentJoin::CONTENT_ADDERS.include?(name) || StringMutation::TRANSLATORS.include?(name) ||
1991
+ SetMutation::CONTENT_ADDERS.include?(name)
1040
1992
  return MutationWidening::NO_ARG_TYPES
1041
1993
  end
1042
1994
  unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
@@ -1306,7 +2258,7 @@ module Rigor
1306
2258
  sub_eval(node.statements, scope)
1307
2259
  end
1308
2260
 
1309
- # `case pred; when ...; when ...; else; end` and the pattern- matching variant. The predicate's post-scope is
2261
+ # `case pred; when ...; when ...; else; end` and the pattern-matching variant. The predicate's post-scope is
1310
2262
  # shared with every branch (including the else); branches are evaluated independently and merged with
1311
2263
  # nil-injection so half-bound names degrade to `T | nil`.
1312
2264
  def eval_case(node)
@@ -1446,7 +2398,10 @@ module Rigor
1446
2398
  after
1447
2399
  end
1448
2400
  body_entry = reduce_scopes_with_nil_injection(test_exits)
1449
- return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true] if rebound
2401
+ if rebound
2402
+ body_entry, after = [body_entry, after].map { |s| forget_called_global_subject(subject, s) }
2403
+ return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true]
2404
+ end
1450
2405
 
1451
2406
  [Narrowing.case_when_scopes(subject, conditions, body_entry).first,
1452
2407
  Narrowing.case_when_scopes(subject, conditions, after).last, false]
@@ -1493,26 +2448,56 @@ module Rigor
1493
2448
  # reads: its binding moved, the test writes it, or — for an instance variable — a call in the test may write
1494
2449
  # it ({InterveningIvarReset.may_write?}), or — for a global or constant — the test may run code that rebinds
1495
2450
  # one ({GuardRebinding.may_rebind?}).
2451
+ #
2452
+ # A subject that writes a local or instance variable (`case (@x = v)`) is read as that variable, as
2453
+ # {Narrowing.case_when_scopes} narrows it (issue #2211). For such an instance variable the binding test above
2454
+ # answers first (the test's call widens it in {#run_when_test}), so its `may_write?` arm is a guard.
1496
2455
  def when_test_rebinds_subject?(subject, condition, before, after)
2456
+ subject = case_subject_variable(subject)
1497
2457
  name = case_subject_name(subject)
1498
2458
  return false if name.nil?
1499
2459
  return true unless case_subject_binding(subject, before) == case_subject_binding(subject, after)
1500
2460
  return true if OperandEffects.written_variables(condition).include?(name)
1501
2461
 
1502
2462
  case subject
1503
- when Prism::InstanceVariableReadNode
2463
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
1504
2464
  InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1505
- when Prism::LocalVariableReadNode
2465
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
1506
2466
  false
2467
+ when Prism::GlobalVariableReadNode
2468
+ # Issue #2314 — the question the statement walk asks past a call ({#forget_reached_globals}).
2469
+ body = when_lambda_body(condition)
2470
+ GlobalWriterReach.may_write?(condition, name, before) ||
2471
+ (!body.nil? && GlobalWriterReach.may_write?(body, name, before))
1507
2472
  else
1508
2473
  body = when_lambda_body(condition)
1509
2474
  GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
1510
2475
  end
1511
2476
  end
1512
2477
 
1513
- def case_subject_name(subject)
2478
+ # Issue #2211 — a global subject from a `when` test that may rebind it on ({#when_test_rebinds_subject?}): a
2479
+ # method may assign a global, and the statement walk keeps a global's binding past a call, so the clause
2480
+ # bodies and the later clauses read it as `Dynamic[top]`, as the scope index's walk of a value-position `case`
2481
+ # reads it
2482
+ # (`ScopeIndexer#forget_called_subject`). `$g = nil; case $g when String then 1 when reset! then 2 else $g + 1
2483
+ # end`, with `reset!` assigning `$g`, is `6` in Ruby. An instance variable a test's call may write is widened by
2484
+ # {#run_when_test} instead; any other subject is left as is.
2485
+ def forget_called_global_subject(subject, scope)
2486
+ return scope unless subject.is_a?(Prism::GlobalVariableReadNode)
2487
+
2488
+ scope.with_global(subject.name, Type::Combinator.untyped)
2489
+ end
2490
+
2491
+ # The subject node the rebinding test reads: a parenthesised one unwrapped, and a local or instance variable
2492
+ # write kept as the variable it writes.
2493
+ def case_subject_variable(subject)
2494
+ Narrowing.unwrap_case_subject(subject)
2495
+ end
2496
+
2497
+ def case_subject_name(subject)
1514
2498
  case subject
1515
- when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode
2499
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
2500
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
1516
2501
  subject.name
1517
2502
  when Prism::ConstantReadNode, Prism::ConstantPathNode
1518
2503
  Narrowing.constant_key(subject)
@@ -1521,8 +2506,8 @@ module Rigor
1521
2506
 
1522
2507
  def case_subject_binding(subject, scope)
1523
2508
  case subject
1524
- when Prism::LocalVariableReadNode then scope.local(subject.name)
1525
- when Prism::InstanceVariableReadNode then scope.ivar(subject.name)
2509
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.local(subject.name)
2510
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.ivar(subject.name)
1526
2511
  when Prism::GlobalVariableReadNode then scope.global(subject.name)
1527
2512
  else scope.constant_narrowing(Narrowing.constant_key(subject))
1528
2513
  end
@@ -2222,16 +3207,37 @@ module Rigor
2222
3207
  # ({#loop_body_ivar_writes}).
2223
3208
  def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2224
3209
 
3210
+ # A loop's evaluation, with its value recorded for `ExpressionTyper#type_of_loop` ({LoopValueRecord}).
3211
+ # A conditional's evaluation, recorded when it evaluated a loop ({LoopValueRecord.conditional}): the typer
3212
+ # types a conditional that holds a loop again from the scope it was evaluated from, and the loop in its branch
3213
+ # is evaluated from a branch scope no record matches.
3214
+ def eval_recorded_if(node) = record_conditional_value(node) { eval_if(node) }
3215
+
3216
+ def eval_recorded_unless(node) = record_conditional_value(node) { eval_unless(node) }
3217
+
3218
+ # The "evaluated a loop" gate is a memory-only guard: recording every conditional would give the same answers, at
3219
+ # the cost of a retained scope per `if` node, so no behavioural example pins it.
3220
+ def record_conditional_value(node)
3221
+ before = LoopValueRecord.loops_evaluated
3222
+ result = yield
3223
+ LoopValueRecord.conditional(node, scope, result.first) unless LoopValueRecord.loops_evaluated == before
3224
+ result
3225
+ end
3226
+
3227
+ def eval_recorded_loop(node) = record_loop_value(node, eval_loop(node))
3228
+
3229
+ def eval_recorded_for(node) = record_loop_value(node, eval_repeating_for(node))
3230
+
3231
+ def record_loop_value(node, result)
3232
+ LoopValueRecord.record(node, scope, result.first)
3233
+ result
3234
+ end
3235
+
2225
3236
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
2226
- # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
2227
- # 1), reflecting the common case where no `break VALUE` is observed.
3237
+ # degrade to `T | nil` in the post-loop scope. The loop expression is `nil` joined with the value of every `break`
3238
+ # that leaves this loop ({#loop_value}, issue #2031).
2228
3239
  def eval_loop(node)
2229
- # Issue #1930 — the predicate runs before every iteration but is walked once, so a literal-name
2230
- # `instance_variable_set` in it joins its value and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}):
2231
- # the rebind fixpoint tracks the body's writes only.
2232
- entry = loop_entry_scope(node)
2233
- _pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
2234
- post_pred = widen_predicate_pins(node, post_pred)
3240
+ post_pred = loop_predicate_scope(node)
2235
3241
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
2236
3242
 
2237
3243
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
@@ -2248,7 +3254,7 @@ module Rigor
2248
3254
  # The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
2249
3255
  # superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
2250
3256
  jumps = loop_jumps(node.statements)
2251
- body_scope, first_breaks = single_pass(node, post_pred, jumps)
3257
+ body_scope, first_breaks, first_values = single_pass(node, post_pred, jumps)
2252
3258
  base_scope = join_with_nil_injection(post_pred, body_scope)
2253
3259
 
2254
3260
  names, body_first = loop_rebound_names(node.statements, post_pred)
@@ -2258,17 +3264,50 @@ module Rigor
2258
3264
  # the single-pass join before returning.
2259
3265
  if names.empty?
2260
3266
  fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
2261
- return [Type::Combinator.constant_of(nil), loop_exit(node, post_pred, fast)]
3267
+ return [loop_value(node, first_values), loop_exit(node, post_pred, fast)]
2262
3268
  end
2263
3269
 
2264
- post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
3270
+ post_loop, values = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2265
3271
  # A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
2266
3272
  # entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
2267
3273
  # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
2268
3274
  # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
2269
3275
  post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
2270
3276
  post_loop = loop_exit(node, post_pred, post_loop)
2271
- [Type::Combinator.constant_of(nil), post_loop]
3277
+ [loop_value(node, first_values + values), post_loop]
3278
+ end
3279
+
3280
+ # The scope a loop's predicate leaves, which the body and the exit edge start from. Issue #1930 — the predicate
3281
+ # runs before every iteration but is walked once, so a literal-name `instance_variable_set` in it joins its value
3282
+ # and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}): the rebind fixpoint tracks the body's writes only.
3283
+ def loop_predicate_scope(node)
3284
+ entry = loop_entry_scope(node)
3285
+ _pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
3286
+ widen_predicate_pins(node, post_pred)
3287
+ end
3288
+
3289
+ # Issue #2031 — a `while` / `until` expression is `nil` when its predicate ends it and the value of the `break`
3290
+ # that ends it otherwise: `x = while i < k; break @r if i == 4; end` is `"four"` or `nil`. `break_values` holds
3291
+ # what each `break` targeting this loop carried, from the single body pass (the first iteration) and the pass
3292
+ # from the converged bindings ({#loop_break_arms}), which together cover the entry of every iteration. A literal
3293
+ # `while true` / `until false` / `until nil` is never ended by its predicate, so only its `break`s give it a
3294
+ # value; any other predicate is assumed to end it, since its first evaluation says nothing about later ones. A
3295
+ # loop with no reachable `break` keeps its `nil`.
3296
+ def loop_value(node, break_values)
3297
+ nil_type = Type::Combinator.constant_of(nil)
3298
+ return nil_type if break_values.empty?
3299
+ return Type::Combinator.union(*break_values) if endless_loop_predicate?(node)
3300
+
3301
+ Type::Combinator.union(nil_type, *break_values)
3302
+ end
3303
+
3304
+ def endless_loop_predicate?(node)
3305
+ predicate = node.predicate
3306
+ if node.is_a?(Prism::UntilNode)
3307
+ predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
3308
+ else
3309
+ predicate.is_a?(Prism::TrueNode)
3310
+ end
2272
3311
  end
2273
3312
 
2274
3313
  # Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
@@ -2330,7 +3369,8 @@ module Rigor
2330
3369
 
2331
3370
  # The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
2332
3371
  # `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
2333
- # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
3372
+ # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`). Returns `[scope, values]`,
3373
+ # `values` being what the converged pass's `break`s carry ({#loop_value}).
2334
3374
  def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2335
3375
  # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
2336
3376
  # single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
@@ -2338,7 +3378,7 @@ module Rigor
2338
3378
  # fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
2339
3379
  # post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
2340
3380
  # degrades it to `T | nil`, matching the nil-injection treatment.
2341
- break_pass = jumps.breaks && { entry: nil, arms: [] }
3381
+ break_pass = { entry: nil, arms: [], values: [] } if jumps.breaks || later_pass_returns?(node.statements)
2342
3382
  result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2343
3383
  # Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
2344
3384
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
@@ -2350,8 +3390,8 @@ module Rigor
2350
3390
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
2351
3391
  # from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
2352
3392
  post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
2353
- arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2354
- join_break_scopes(post_loop, arms, names)
3393
+ arms, values = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
3394
+ [join_break_scopes(post_loop, arms, names), values]
2355
3395
  end
2356
3396
 
2357
3397
  # A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
@@ -2444,8 +3484,11 @@ module Rigor
2444
3484
  [sink, result]
2445
3485
  end
2446
3486
 
2447
- # One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with, and the
2448
- # scopes at the `break`s that target the loop ({LoopJumps}). Every reader of a loop body goes through here — the
3487
+ # One evaluation of a loop body from `entry`. Returns `[exit, breaks, values]`: the scope the iteration ends with,
3488
+ # the scopes at the `break`s that target the loop ({LoopJumps}), and the values those `break`s carry, read from a
3489
+ # `break`-value sink of the loop's own ({#loop_value}); filtering it by the loop's `break`s is defensive, since a
3490
+ # nested loop installs its own sink and a nested block's call evaluates its body under another, so no example
3491
+ # tells the filter apart today. Every reader of a loop body goes through here — the
2449
3492
  # single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
2450
3493
  # pass.
2451
3494
  #
@@ -2463,18 +3506,21 @@ module Rigor
2463
3506
  recording = recorded ? {} : UNRECORDED
2464
3507
  evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
2465
3508
  if jumps.breaks
2466
- break_sink, fall_through = collect_break_scopes(&evaluate)
3509
+ break_sink, (fall_through, value_sink) = collect_break_scopes do
3510
+ StatementEvaluator.with_break_value_sink(&evaluate)
3511
+ end
2467
3512
  breaks = targeted_scopes(break_sink, jumps.breaks)
3513
+ values = targeted_scopes(value_sink, jumps.breaks)
2468
3514
  else
2469
3515
  fall_through = evaluate.call
2470
- breaks = NO_BREAK_ARMS
3516
+ breaks = values = NO_BREAK_ARMS
2471
3517
  end
2472
- return [fall_through, breaks] if next_sink.nil?
3518
+ return [fall_through, breaks, values] if next_sink.nil?
2473
3519
 
2474
3520
  exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
2475
3521
  join_with_nil_injection(acc, next_scope)
2476
3522
  end
2477
- [exit_scope, breaks]
3523
+ [exit_scope, breaks, values]
2478
3524
  end
2479
3525
 
2480
3526
  # The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
@@ -2494,12 +3540,24 @@ module Rigor
2494
3540
  # The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
2495
3541
  # inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
2496
3542
  # fixpoint's own passes never evaluated.
3543
+ #
3544
+ # Returns `[arms, values]`, `values` being what the same pass's `break`s carry ({#loop_value}).
2497
3545
  def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
2498
- return NO_BREAK_ARMS if break_pass.nil?
2499
- return break_pass[:arms] if break_pass[:entry] == converged.except(*body_first)
3546
+ return [NO_BREAK_ARMS, NO_BREAK_ARMS] if break_pass.nil?
3547
+ return break_pass.values_at(:arms, :values) if break_pass[:entry] == converged.except(*body_first)
2500
3548
 
2501
3549
  entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
2502
- loop_iteration(node.statements, entry, jumps, recorded: false).last
3550
+ loop_iteration(node.statements, entry, jumps, recorded: false).drop(1)
3551
+ end
3552
+
3553
+ # Issue #2128 — whether a pass from the converged bindings must run for the `return`s in a loop body: a method's
3554
+ # return sink is active and the body holds a `return` that exits the method ({ReturnBarrier.exits_method?}). A
3555
+ # `return` whose condition reads a loop-carried local (`return false if seen > 3` after `seen += 1`) is dead in
3556
+ # every capped fixpoint pass, which runs from a value-pinned assumption (`seen` at most `3`), and only the pass
3557
+ # from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
3558
+ # caller's test on it folded always-truthy on correct code.
3559
+ def later_pass_returns?(statements)
3560
+ !Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
2503
3561
  end
2504
3562
 
2505
3563
  # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
@@ -2674,10 +3732,11 @@ module Rigor
2674
3732
  # it.
2675
3733
  def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
2676
3734
  entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2677
- exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
3735
+ exit_scope, breaks, values = loop_iteration(node.statements, entry, jumps)
2678
3736
  if break_pass
2679
3737
  break_pass[:entry] = bindings.except(*body_first)
2680
3738
  break_pass[:arms] = breaks
3739
+ break_pass[:values] = values
2681
3740
  end
2682
3741
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
2683
3742
  end
@@ -2736,29 +3795,159 @@ module Rigor
2736
3795
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
2737
3796
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
2738
3797
  # `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
2739
- # expression itself types as `Constant[nil]`, the policy `eval_loop` uses for `while` / `until` — a known gap for
2740
- # `for`, whose value in Ruby is the collection it iterated (issue #1216).
3798
+ # expression is the collection's `each` value joined with every `break` value ({#for_value}, issue #1216).
2741
3799
  def eval_for(node)
2742
3800
  coll_type, post_coll = sub_eval(node.collection, scope)
2743
- element_type = for_iteration_element_type(coll_type)
2744
- # Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
2745
- body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
2746
- body_entry = forget_guard_for_body(node, post_coll, body_entry)
2747
- body_entry = loop_content_entry(node.statements, post_coll, body_entry)
3801
+ body_entry = for_body_entry(node, for_iteration_element_type(coll_type), post_coll)
2748
3802
 
2749
3803
  if node.statements.nil?
2750
- return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
3804
+ return [for_value(node, coll_type, post_coll, NO_BREAK_ARMS),
3805
+ join_with_nil_injection(post_coll, body_entry)]
2751
3806
  end
2752
3807
 
2753
3808
  # The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
2754
3809
  # the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
2755
3810
  # the only continuation and the only source of `break` arms).
2756
3811
  jumps = loop_jumps(node.statements)
2757
- body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
3812
+ body_scope, breaks, values = loop_iteration(node.statements, body_entry, jumps)
3813
+ values += later_for_pass_values(node.statements, body_entry, body_scope, jumps)
2758
3814
  continuation = join_with_nil_injection(post_coll, body_scope)
2759
3815
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
2760
- continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
2761
- [Type::Combinator.constant_of(nil), for_exit(node, post_coll, continuation)]
3816
+ continuation = for_exit(node, post_coll, join_break_scopes(continuation, breaks, pre_existing + body_first))
3817
+ continuation = widen_for_elements(node, continuation)
3818
+ [for_value(node, coll_type, continuation, values), continuation]
3819
+ end
3820
+
3821
+ # Issue #2262 — the variables the collection reads, past a body that mutates an element in place: `for x in a;
3822
+ # x << 2; end` leaves `a[0]` holding what the literal's shape did not.
3823
+ def widen_for_elements(node, scope)
3824
+ DeepErasure.widen_element_aliases(
3825
+ node.collection, node.statements, DeepErasure.parameter_names(node.index), scope
3826
+ )
3827
+ end
3828
+
3829
+ # Issue #2128 — a `for` body has no rebind fixpoint, so its only pass enters with every loop-carried local at its
3830
+ # pre-loop binding, and a `return` or `break` whose condition needs a later iteration (`n = 0; for x in xs;
3831
+ # return :f if (n += 1) > 2; end`) was never reached. When the body holds such a jump (a `break`, or a `return`
3832
+ # that exits the method while a return sink is active, {#later_pass_returns?}), one more unrecorded pass runs
3833
+ # with each local and instance variable the body rebinds at its value-pinned widening
3834
+ # (`Type::Combinator.widen_value_pinned`) of its entry binding joined with the first pass's exit, a binding that
3835
+ # covers what any iteration enters with, as the `while` fixpoint's widened binding does. The `return`s it
3836
+ # reaches join the method's return type as any pass's do, and the `break` values it collects are returned for
3837
+ # the loop's value ({#for_value}). The pass records no scope, and the continuation keeps the first pass's
3838
+ # answer.
3839
+ def later_for_pass_values(statements, body_entry, body_scope, jumps)
3840
+ return NO_BREAK_ARMS unless jumps.breaks || later_pass_returns?(statements)
3841
+
3842
+ names, body_first = loop_rebound_names(statements, body_entry)
3843
+ bindings = (names - body_first).filter_map do |name|
3844
+ joined = [body_entry, body_scope].filter_map { |s| CapturedLocals.bound_type(s, name) }
3845
+ [name, Type::Combinator.widen_value_pinned(Type::Combinator.union(*joined))] unless joined.empty?
3846
+ end
3847
+ return NO_BREAK_ARMS if bindings.empty?
3848
+
3849
+ entry = bindings.reduce(body_entry) { |acc, (name, type)| bind_capture(acc, name, type) }
3850
+ loop_iteration(statements, entry, jumps, recorded: false)[2]
3851
+ end
3852
+
3853
+ # The core collections whose `each` with a block answers its receiver.
3854
+ FOR_SELF_EACH_CLASSES = Set["Array", "Hash", "Range"].freeze
3855
+ private_constant :FOR_SELF_EACH_CLASSES
3856
+
3857
+ # The nodes through which a `for` body or index may change the iterated collection's contents: a call (an
3858
+ # in-place mutator on the collection or on an alias of it, or a method that reaches it), `yield` / `super`, an
3859
+ # index store, and an operator write, which calls its operator on the variable's value (`b <<= 9` appends to
3860
+ # the array `b` holds).
3861
+ FOR_MUTATING_NODES = Set[
3862
+ Prism::CallNode, Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::IndexTargetNode,
3863
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
3864
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
3865
+ Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
3866
+ Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode
3867
+ ].freeze
3868
+ FOR_VARIABLE_READS = Set[Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode].freeze
3869
+ private_constant :FOR_MUTATING_NODES, :FOR_VARIABLE_READS
3870
+
3871
+ # Issue #1216 — `for x in coll` is `coll.each { |x| … }`, so the loop's value is what that `each` answers, or
3872
+ # the value of the `break` that ends it. An Array, a Hash and a Range answer themselves, including when
3873
+ # they are empty and the body never runs, so the value is the collection ({#for_collection_after}). Any other
3874
+ # collection (`Dynamic`, a project class, an `Enumerator`, a union with `nil`) answers `Dynamic[top]`: its
3875
+ # `each` is not known to answer its receiver, and a guessed type would be a claim the analysis cannot back.
3876
+ def for_value(node, coll_type, continuation, break_values)
3877
+ each_value = each_answers_self?(coll_type) ? for_collection_after(node, coll_type, continuation) : Type::Combinator.untyped
3878
+ break_values.empty? ? each_value : Type::Combinator.union(each_value, *break_values)
3879
+ end
3880
+
3881
+ # The collection as the loop leaves it. `each` answers the object it iterated, after the body ran, so a body that
3882
+ # may change its contents ({FOR_MUTATING_NODES}: `a.pop`, `h.delete(k)`, `b << 3` on an alias `b`) leaves
3883
+ # neither its shape nor its size: `a = [1, 2]; for i in a; a.pop; end` is `[1]`. The value is then the
3884
+ # collection's type with every shape and refinement dropped to its nominal ({#shape_erased_collection}), joined,
3885
+ # when the collection is a local or instance variable the loop does not rebind, with that variable's binding
3886
+ # after the loop, which holds the contents the body's own mutator calls add. A body that runs no such code
3887
+ # leaves the collection as it was.
3888
+ def for_collection_after(node, coll_type, continuation)
3889
+ return coll_type unless [node.index, node.statements].any? { |part| for_may_mutate?(part) }
3890
+
3891
+ types = [coll_type]
3892
+ name = for_collection_variable(node)
3893
+ types << CapturedLocals.bound_type(continuation, name) if name
3894
+ deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index)) do
3895
+ DeepErasure.holds_set?(coll_type)
3896
+ end
3897
+ Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
3898
+ end
3899
+
3900
+ def for_may_mutate?(part)
3901
+ return false if part.nil?
3902
+
3903
+ found = false
3904
+ Source::NodeWalker.each(part) { |descendant| found ||= FOR_MUTATING_NODES.include?(descendant.class) }
3905
+ found || FOR_MUTATING_NODES.include?(part.class)
3906
+ end
3907
+
3908
+ # The local or instance variable `for` iterates, when neither its index nor its body rebinds it.
3909
+ def for_collection_variable(node)
3910
+ collection = node.collection
3911
+ return nil unless FOR_VARIABLE_READS.include?(collection.class)
3912
+
3913
+ written = OperandEffects.written_variables(node.index) + OperandEffects.written_variables(node.statements)
3914
+ written.include?(collection.name) ? nil : collection.name
3915
+ end
3916
+
3917
+ # `type` with each `Tuple` / `HashShape` / literal Array or Hash dropped to its nominal, each refinement to its
3918
+ # base. A Range is immutable and keeps its value.
3919
+ def shape_erased_collection(type, deep: false)
3920
+ # A body that mutates an element in place changes the shapes one level down too, which `each` answers
3921
+ # as the elements' own objects ({DeepErasure.widen}).
3922
+ return DeepErasure.widen(type) if deep && !(type.is_a?(Type::Constant) && type.value.is_a?(Range))
3923
+
3924
+ case type
3925
+ when Type::Tuple then MutationWidening.widen_tuple(type)
3926
+ when Type::HashShape then MutationWidening.widen_hash_shape(type)
3927
+ when Type::Constant then type.value.is_a?(Range) ? type : Type::Combinator.widen_value_pinned(type)
3928
+ when Type::Refined, Type::Difference then shape_erased_collection(type.base)
3929
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| shape_erased_collection(member) })
3930
+ else type
3931
+ end
3932
+ end
3933
+
3934
+ def each_answers_self?(type)
3935
+ case type
3936
+ when Type::Tuple, Type::HashShape then true
3937
+ when Type::Nominal then FOR_SELF_EACH_CLASSES.include?(type.class_name)
3938
+ when Type::Constant then type.value.is_a?(Array) || type.value.is_a?(Hash) || type.value.is_a?(Range)
3939
+ when Type::Refined, Type::Difference then each_answers_self?(type.base)
3940
+ when Type::Union then type.members.all? { |member| each_answers_self?(member) }
3941
+ else false
3942
+ end
3943
+ end
3944
+
3945
+ # The scope a `for` body enters with: the index bound to the element. Issue #1359 — a body that may set `$_` runs
3946
+ # again after it ran, as a `while` body does ({#eval_loop}).
3947
+ def for_body_entry(node, element_type, post_coll)
3948
+ body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
3949
+ body_entry = forget_guard_for_body(node, post_coll, body_entry)
3950
+ loop_content_entry(node.statements, post_coll, body_entry)
2762
3951
  end
2763
3952
 
2764
3953
  # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
@@ -2769,11 +3958,11 @@ module Rigor
2769
3958
  forget_guard_if_rebinds(body_entry, node.statements)
2770
3959
  end
2771
3960
 
2772
- # `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
2773
- # block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
2774
- # structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
3961
+ # `for x in coll` is semantically `coll.each { |x| ... }`. The structural `collection_element_type` answers first;
3962
+ # only when it is untyped do we ask the method dispatcher for `coll.each`'s expected block parameter types, which
3963
+ # consult RBS and the iterator dispatch table (they know, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
2775
3964
  # the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
2776
- # signature, unknown receiver) we fall back to the structural extractor.
3965
+ # signature, unknown receiver) the structural answer stands.
2777
3966
  def for_iteration_element_type(coll_type)
2778
3967
  structural = collection_element_type(coll_type)
2779
3968
  return structural unless structural.equal?(Type::Combinator.untyped)
@@ -2835,7 +4024,7 @@ module Rigor
2835
4024
 
2836
4025
  # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
2837
4026
  # elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
2838
- # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
4027
+ # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; a `Range` constant reads the
2839
4028
  # literal range's element class. Anything else falls back to `untyped`.
2840
4029
  def collection_element_type(type)
2841
4030
  case type
@@ -3136,8 +4325,9 @@ module Rigor
3136
4325
 
3137
4326
  # `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
3138
4327
  # class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
3139
- # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
3140
- # the existing context and accept that nested defs degrade to the `Dynamic[Top]` default.
4328
+ # rather than `instance_method`. For an expression that is neither `self` nor a constant we cannot statically
4329
+ # resolve the receiver, so we keep the existing context and accept that nested defs degrade to the `Dynamic[Top]`
4330
+ # default.
3141
4331
  # Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
3142
4332
  # `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
3143
4333
  # `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
@@ -3203,11 +4393,9 @@ module Rigor
3203
4393
  invoked = call_operand_scope(node, walk, scope)
3204
4394
  invoked = forget_operand_specials(node, invoked)
3205
4395
  operand_types = walk.types(tracer)
4396
+ # The `each_with_object` memo widening (ADR-56 slice C, B3) is part of the call's type, applied by
4397
+ # `ExpressionTyper#call_type_for` ({#each_with_object_value}).
3206
4398
  call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
3207
- # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
3208
- # types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
3209
- # block-param and adopt it as the call's return type.
3210
- call_type = each_with_object_return(node, call_type)
3211
4399
  [call_type, invoke_from(node, invoked, call_type, operand_types)]
3212
4400
  end
3213
4401
 
@@ -3396,8 +4584,7 @@ module Rigor
3396
4584
  # B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
3397
4585
  # of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
3398
4586
  # binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
3399
- # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3400
- # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
4587
+ # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
3401
4588
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
3402
4589
  # Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
3403
4590
  # above, which would join a literal-name write back into the class seed.
@@ -3431,9 +4618,32 @@ module Rigor
3431
4618
  post_scope.forget_guard_narrowings
3432
4619
  end
3433
4620
  end
4621
+ post_scope = forget_reached_globals(node, post_scope)
3434
4622
  forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
3435
4623
  end
3436
4624
 
4625
+ def guard_narrowed_global?(scope, name)
4626
+ scope.guard_narrowed? && scope.guard_records.key?([:global, name])
4627
+ end
4628
+
4629
+ # Issue #2314 — `post_scope` with each global a project method may write rebound to `Dynamic[top]` when the
4630
+ # statement call `node` may reach that method ({GlobalWriterReach.may_write?}): `$g = nil; reset!; $g + 1`,
4631
+ # with `reset!` assigning `$g`, is `2` in Ruby. A global no reachable method writes keeps its binding. A
4632
+ # `case` whose `when` test may rebind a global subject reads it the same way ({#when_test_rebinds_subject?}).
4633
+ # A global a guard narrowed where the operands started keeps the union issue #1429's restore reads instead
4634
+ # (`return unless $sep; puts(reset_sep); $sep.length` reads `$sep` as the union of its seed and `","`).
4635
+ def forget_reached_globals(node, post_scope)
4636
+ names = GlobalWriterReach.candidates(post_scope)
4637
+ return post_scope if names.empty?
4638
+
4639
+ names.reduce(post_scope) do |acc, name|
4640
+ next acc if guard_narrowed_global?(operand_scope, name)
4641
+ next acc unless GlobalWriterReach.may_write?(node, name, operand_scope)
4642
+
4643
+ acc.with_global(name, Type::Combinator.untyped)
4644
+ end
4645
+ end
4646
+
3437
4647
  # True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
3438
4648
  # its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
3439
4649
  # so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
@@ -3553,7 +4763,8 @@ module Rigor
3553
4763
 
3554
4764
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
3555
4765
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3556
- # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
4766
+ # External-receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged, apart from
4767
+ # the literal-block case below.
3557
4768
  # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3558
4769
  #
3559
4770
  # Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
@@ -3561,11 +4772,19 @@ module Rigor
3561
4772
  # calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
3562
4773
  # code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
3563
4774
  # (`@items << yield`, `puts(yield)`) widens every narrowed ivar ({InterveningIvarReset.after_operands}).
4775
+ #
4776
+ # Issue #2039 — an implicit-self or `self.` call marks on the same rule only when its write set is unproven
4777
+ # and its callee's body shows no possibly-nil write of the ivar (`mark: :unproven`, {DirectIvarWrites}):
4778
+ # `v2?`, an `attr_reader` call or `respond_to?(:x)` forgets the narrowing without making the ivar newly
4779
+ # nil-reportable, while a callee that assigns it where the program shows (`clear_tag`) widens unmarked.
3564
4780
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3565
4781
  widened =
3566
4782
  if intervening_call_candidate?(call_node)
3567
- mark = !InterveningIvarReset.self_call?(call_node)
3568
- InterveningIvarReset.widen(current_scope, mark: mark) { self_call_ivar_writes(call_node) }
4783
+ mark = InterveningIvarReset.self_call?(call_node) ? :unproven : true
4784
+ direct_writes = -> { DirectIvarWrites.of(call_node, operand_scope) }
4785
+ InterveningIvarReset.widen(current_scope, mark: mark, direct_writes: direct_writes) do
4786
+ self_call_ivar_writes(call_node)
4787
+ end
3569
4788
  else
3570
4789
  InterveningIvarReset.after_block(call_node, current_scope)
3571
4790
  end
@@ -3755,7 +4974,7 @@ module Rigor
3755
4974
  # `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
3756
4975
  # method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
3757
4976
  # cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
3758
- # plugin-recognised calls) lives behind a follow-up slice that introduces `:local` / `:argument_at` target kinds.
4977
+ # plugin-recognised calls) lives behind a follow-up slice that introduces an `:argument_at` target kind.
3759
4978
  def apply_plugin_assertions(call_node, current_scope)
3760
4979
  registry = current_scope.environment&.plugin_registry
3761
4980
  return current_scope if registry.nil? || registry.empty?
@@ -3910,7 +5129,7 @@ module Rigor
3910
5129
  def lookup_post_return_arg(call_node, method_def, target_name)
3911
5130
  # Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
3912
5131
  # directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
3913
- # mapping (`:local` / `:argument_at`) is a follow-up slice.
5132
+ # mapping (`:argument_at`) is a follow-up slice.
3914
5133
  return nil if method_def.nil?
3915
5134
 
3916
5135
  arguments = call_node.arguments&.arguments || []
@@ -3981,176 +5200,1314 @@ module Rigor
3981
5200
  target = ScopeIndexer.refine_target(node)
3982
5201
  return nil if target.nil?
3983
5202
 
3984
- return nil if InEffectRefinements.class_body_refine?(scope, node)
5203
+ return nil if InEffectRefinements.class_body_refine?(scope, node)
5204
+
5205
+ name = ScopeIndexer.refined_class_name(target, scope)
5206
+ return nil if name.nil?
5207
+
5208
+ [ClassFrame.new(name: name, singleton: false, refinement: true)]
5209
+ end
5210
+
5211
+ # The block calls whose body `return` leaves only the block ({ReturnBarrier.block_call?}). Like a `->` body
5212
+ # ({#eval_lambda}), each runs with the enclosing method's return sink suspended.
5213
+ def return_barrier_block?(node)
5214
+ ReturnBarrier.block_call?(node)
5215
+ end
5216
+
5217
+ # Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
5218
+ def without_return_sink
5219
+ outer_sink = Thread.current[RETURN_SINK_KEY]
5220
+ Thread.current[RETURN_SINK_KEY] = nil
5221
+ begin
5222
+ yield
5223
+ ensure
5224
+ Thread.current[RETURN_SINK_KEY] = outer_sink
5225
+ end
5226
+ end
5227
+
5228
+ # Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
5229
+ # the block with `self` bound to the receiving instance. Without this the block inherits the class body's
5230
+ # `Singleton[C]`, and #618's own-method veto asks the singleton side of a name the instance side answers.
5231
+ # {DefineMethodBlockSelf} owns the match; a non-match leaves the entry scope exactly as it was.
5232
+ #
5233
+ # The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
5234
+ # method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
5235
+ # still carries the singleton frame although its `self` is the class object, and it is an instance method
5236
+ # the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
5237
+ # same exclusion, which has no frame stack of its own.
5238
+ def narrow_define_method_block_self(call_node, block_entry)
5239
+ narrowed = DefineMethodBlockSelf.narrow_self_type_for(
5240
+ scope: scope, call_node: call_node
5241
+ )
5242
+ return block_entry unless narrowed
5243
+
5244
+ block_entry.with_block_self_type(narrowed, keeps_unknown: scope.block_self_narrowing_unknown?(call_node))
5245
+ end
5246
+
5247
+ # Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
5248
+ # singleton, so a `def` inside binds an instance method on it through the ordinary
5249
+ # {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
5250
+ # positions such a block is reached from — a statement-level call ({#evaluate_block_if_present}) and a
5251
+ # constant-write rvalue ({#eval_constant_write}) — so the two cannot drift on what a class body's entry is.
5252
+ #
5253
+ # Issue #652 — the body keeps this evaluator's `Module.nesting`. A BLOCK never pushes a cref, however
5254
+ # much `class_eval` semantics make it behave like a class body in every other respect, so
5255
+ # `K = Class.new do … end` written inside `module Outer` reads `Outer::LABEL` at runtime and MUST NOT
5256
+ # record a `Outer::K` entry: that is a rung Ruby's constant lookup does not have. The `class_context`
5257
+ # frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
5258
+ # divergence that makes the chain a separate record rather than a view of the frame stack.
5259
+ #
5260
+ # Issue #1759 — a `refine X do … end` block runs on the refinement module, not on X, which Rigor does not
5261
+ # model: the body keeps X's frame and singleton so its `def`s and implicit-self calls resolve as before, but
5262
+ # carries the unknown-`self` mark ({Scope#block_self_unknown?}), so an explicit `self.import_methods(M)` is
5263
+ # not checked against `singleton(X)`. The indexer leaves a value-position refine block unknown the same way.
5264
+ def enter_meta_class_body(block, block_entry, class_context)
5265
+ # Issue #1717 — the body runs on the new class whatever encloses it, so its `self` is known.
5266
+ entry = block_entry.with_block_self_type(self_type_for_class_body(class_context),
5267
+ keeps_unknown: class_context.last.refinement &&
5268
+ Scope::REFINEMENT_BLOCK_SELF)
5269
+ .with_singleton_class_body(false)
5270
+ sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
5271
+ end
5272
+
5273
+ # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
5274
+ # non-escaping:
5275
+ #
5276
+ # - 3b: attach a `dynamic_origin` `closure_escape` fact to the
5277
+ # post-call scope so consumers can see that the closure may
5278
+ # have been retained past the call.
5279
+ # - 3c: drop the narrowed type of every captured outer local
5280
+ # that the block body can rebind, replacing it with
5281
+ # `Dynamic[Top]` through `Scope#with_local` (which also
5282
+ # invalidates the local's `local_binding` facts). Locals
5283
+ # shadowed by a block parameter or a `;`-prefixed
5284
+ # block-local declaration are untouched. Locals the block
5285
+ # only reads (without writing) are also untouched: read-only
5286
+ # captures cannot rebind the outer variable.
5287
+ #
5288
+ # A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
5289
+ def record_closure_escape_if_any(node)
5290
+ # ADR-57 slice 3 work-item 1: an escaping block can also be attached to a RECEIVER call in a chain rather than
5291
+ # to `node` itself — the canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end .parse!(argv)` idiom,
5292
+ # where the content-mutating block hangs off `OptionParser.new` but the statement-level call node is the chained
5293
+ # `.parse!`. A receiver call is evaluated as an expression, never as a statement, so its block never reaches
5294
+ # this escape handler on its own. Fold each escaping receiver-chain block's content widening into the
5295
+ # continuation here so the captured collection is floored regardless of how deep in the receiver chain its
5296
+ # mutating block lives.
5297
+ post_scope = widen_escaping_receiver_chain_captures(node, scope)
5298
+
5299
+ return post_scope unless node.block.is_a?(Prism::BlockNode)
5300
+
5301
+ classification = classify_closure_escape(node)
5302
+ return post_scope if classification == :non_escaping
5303
+
5304
+ post_scope = escaping_closure_captures(node.block, post_scope, foreign_self: foreign_self_block?(node))
5305
+ post_scope.with_fact(
5306
+ Analysis::FactStore::Fact.new(
5307
+ bucket: :dynamic_origin,
5308
+ target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
5309
+ predicate: :closure_escape,
5310
+ payload: { method_name: node.name.to_sym, classification: classification },
5311
+ stability: :unstable
5312
+ )
5313
+ )
5314
+ end
5315
+
5316
+ # Floor each caller-argument local whose matching parameter the resolved callee escape-mutates (see the call-site
5317
+ # comment). Only self-dispatch calls resolving to a discovered user def are considered; the per-def "which
5318
+ # parameters escape-mutate" set is memoised on the def node.
5319
+ def widen_callee_escaped_argument_captures(node, base_scope)
5320
+ # Apply to the statement call AND every call in its receiver chain: the
5321
+ # `build_option_parser(options).parse!(argv)` idiom puts the escape- mutating helper call in the RECEIVER
5322
+ # position, where its argument is never the statement node's own argument.
5323
+ acc = floor_callee_escaped_args_for_call(node, base_scope)
5324
+ receiver = node.receiver
5325
+ while receiver.is_a?(Prism::CallNode)
5326
+ acc = floor_callee_escaped_args_for_call(receiver, acc)
5327
+ receiver = receiver.receiver
5328
+ end
5329
+ # Issue #2397 — and every self-call in the call's own non-escaping block.
5330
+ floor_block_callee_captures(node, acc)
5331
+ end
5332
+
5333
+ # Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
5334
+ # before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
5335
+ #
5336
+ # Issue #2296 — two more channels follow the bare-carrier floor, each keeping the argument's element type
5337
+ # ({UnknownStoreWidening.callee_block_floor} / {UnknownStoreWidening.callee_set_floor}): a parameter the callee
5338
+ # mutates inside a block (`2.times { |i| acc[i] = v }`), and a Set argument the callee grows through a Set-only
5339
+ # adder (`s.add(x)`, `s.merge(xs)`), which the bare census leaves out because `Hash#merge` does not mutate.
5340
+ #
5341
+ # Issue #2297 — a call may resolve to more than one def ({#resolve_self_callees}); each channel floors through
5342
+ # every one of them before the next channel runs, so a bare-census mutation in any of them still goes first.
5343
+ #
5344
+ # `climbing`, when given, keeps only the argument reads deeper than it: those that climb out of a block that
5345
+ # many levels in ({#floor_block_callee_captures}).
5346
+ def floor_callee_escaped_args_for_call(node, base_scope, climbing: nil)
5347
+ callees = argument_mutating_callees(node)
5348
+ return base_scope if callees.empty?
5349
+
5350
+ acc = floor_callee_channel(node, callees, :callee_content_mutated_parameters, base_scope, climbing) do |type|
5351
+ content_floor_for(type)
5352
+ end
5353
+ acc = floor_splat_arguments(node, callees, acc)
5354
+ # Each closure below is only worth computing when an argument holds something its floor could widen.
5355
+ if floorable_local_argument?(node, acc) { |type| UnknownStoreWidening.callee_floorable?(type) }
5356
+ acc = floor_callee_channel(node, callees, :callee_block_mutated_parameters, acc, climbing) do |type|
5357
+ UnknownStoreWidening.callee_block_floor(type)
5358
+ end
5359
+ end
5360
+ return acc unless floorable_local_argument?(node, acc) { |type| SetMutation.carrier_in?(type) }
5361
+
5362
+ callees.each do |def_node, offset, owner|
5363
+ acc = floor_arguments(node, census_of(owner, :callee_set_mutated_parameters, def_node), acc, offset,
5364
+ climbing) do |type|
5365
+ UnknownStoreWidening.callee_set_floor(type)
5366
+ end
5367
+ end
5368
+ acc
5369
+ end
5370
+
5371
+ # Every callee's `census` channel floors the arguments it names with the block's floor, made gradual when the
5372
+ # floor is an over-approximation ({#callee_gradual_floor}). Only a String result is split; the Set channel and
5373
+ # the container elements a floor widens (#2647) are not.
5374
+ def floor_callee_channel(node, callees, census, base_scope, climbing)
5375
+ several = callees.size > 1
5376
+ acc = base_scope
5377
+ callees.each do |def_node, offset, owner|
5378
+ acc = floor_arguments(node, census_of(owner, census, def_node), acc, offset, climbing) do |type|
5379
+ callee_gradual_floor(yield(type), def_node, owner, several)
5380
+ end
5381
+ end
5382
+ acc
5383
+ end
5384
+
5385
+ # Issue #2397 — the continuation of a `:non_escaping` block whose body hands a captured outer local to a
5386
+ # self-call whose callee mutates it: `acc = [7, 9]; [1].each { poke(acc) }`. The body's own statement floor
5387
+ # rebinds `acc` only inside the block, and neither write-back reads it there (the rebind write-back follows
5388
+ # local writes, the content join follows mutator calls), so the continuation kept the literal and
5389
+ # `acc[0].upcase` reported on correct code.
5390
+ #
5391
+ # Every call the statement `node` makes before it returns is visited: `node` itself and the calls in its
5392
+ # receiver chain and arguments (`[1].each { poke(acc) }.size`, `foo([1].each { poke(acc) })`), never inside a
5393
+ # block or lambda of them. For each one with a `:non_escaping` block not run on another object
5394
+ # ({#foreign_self_block?}), each self-call in the block floors as at a statement, by position or keyword, its
5395
+ # arguments limited to reads that climb out of the block: a block parameter or a nested block's parameter
5396
+ # shadowing the outer name is not the outer local. The body walk ({#each_node_in_method_scope}) leaves out a
5397
+ # lambda and the block of a nested `instance_eval` / `define_method` / …, whose self-calls are not ours.
5398
+ #
5399
+ # Two readings are conservative, since the floor only forgets: a block that may run zero times floors, as the
5400
+ # escaping path ({#floor_block_body_callee_escaped_args}) does, and the floor applies after the block's
5401
+ # write-back, so a rebind later in the body (`poke(acc); acc = [1, 2]`) is floored too.
5402
+ def floor_block_callee_captures(node, post_scope)
5403
+ acc = post_scope
5404
+ each_node_outside_nested_scopes(node) do |call|
5405
+ next unless call.is_a?(Prism::CallNode) && call.block.is_a?(Prism::BlockNode) && call.block.body
5406
+
5407
+ acc = floor_block_body_callee_captures(call, acc)
5408
+ end
5409
+ acc
5410
+ end
5411
+
5412
+ def floor_block_body_callee_captures(call_node, post_scope)
5413
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
5414
+ return post_scope if foreign_self_block?(call_node)
5415
+
5416
+ acc = post_scope
5417
+ each_node_in_caller_block(call_node.block.body, 0) do |node, depth|
5418
+ acc = floor_callee_escaped_args_for_call(node, acc, climbing: depth)
5419
+ end
5420
+ acc
5421
+ end
5422
+
5423
+ # Yields every call under `node` with the number of blocks and lambdas it sits in, for
5424
+ # {#floor_block_callee_captures}. A lambda is entered, as the escaping path floors one: it may be called in the
5425
+ # block (`l.call`, `-> { ... }.call`). A nested def is not. Nor is the block of a nested call whose self-calls
5426
+ # are provably not the caller's: one run on another object ({#foreign_block_call?}), a `define_method` /
5427
+ # `define_singleton_method` method body, or a `Class.new` / `Module.new` / `Struct.new` / `Data.define` block.
5428
+ # An `instance_eval` / `class_exec` / … on any other receiver (none, `self`, an instance) is entered.
5429
+ def each_node_in_caller_block(node, depth, &)
5430
+ return if node.nil?
5431
+
5432
+ yield node, depth if node.is_a?(Prism::CallNode)
5433
+ skip_block = node.is_a?(Prism::CallNode) && foreign_block_call?(node)
5434
+ node.rigor_each_child do |child|
5435
+ next if child.is_a?(Prism::DefNode)
5436
+
5437
+ if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::LambdaNode)
5438
+ each_node_in_caller_block(child, depth + 1, &) unless skip_block && child.is_a?(Prism::BlockNode)
5439
+ else
5440
+ each_node_in_caller_block(child, depth, &)
5441
+ end
5442
+ end
5443
+ end
5444
+
5445
+ def foreign_block_call?(call_node)
5446
+ return false unless self_rebinding_block_call?(call_node)
5447
+ return true if %i[define_method define_singleton_method].include?(call_node.name)
5448
+ return true unless AnonymousMetaClass.block_form_receiver(call_node).nil?
5449
+
5450
+ foreign_self_block?(call_node)
5451
+ end
5452
+
5453
+ # Issue #2515 — the floor for a `*x` argument whose `x` is a local holding an array (`args = [parser, options];
5454
+ # Opts.add_format(*args)`). The hash travels inside the array, so the caller's `options` is the array's element,
5455
+ # not an argument, and the per-parameter floor never sees it. An element the callee writes is floored at its place
5456
+ # in the array's type and in every local bound to an equal type (the element's source: only the type is known, so
5457
+ # an equal literal in another local is floored too).
5458
+ #
5459
+ # A known tuple whose splat sits at a fixed position (only plain positional arguments before it) binds element
5460
+ # `j` to the callee's slot `index - offset + j` and floors it when that slot is in the channel's census. Any other
5461
+ # case floors every element when the callee writes any positional parameter: an array that is no longer a tuple
5462
+ # (it was mutated), a splat after another splat, an `offset` that swallows the splat. An element that reaches the
5463
+ # callee's rest or post parameter floors when the callee may write it ({#rest_parameter_written?}). A splat of
5464
+ # anything but a local (a call result, an ivar) is not followed.
5465
+ def floor_splat_arguments(node, callees, base_scope)
5466
+ arguments = node.arguments.arguments
5467
+ return base_scope unless arguments.any?(Prism::SplatNode)
5468
+
5469
+ fixed = true
5470
+ arguments.each_with_index.reduce(base_scope) do |acc, (argument, index)|
5471
+ next acc unless argument.is_a?(Prism::SplatNode)
5472
+
5473
+ read = argument.expression
5474
+ at = fixed ? index : nil
5475
+ fixed = false
5476
+ next acc unless read.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(read.name)
5477
+
5478
+ floor_splat_local(read.name, at, callees, acc)
5479
+ end
5480
+ end
5481
+
5482
+ # Every callee's channels in turn. The callees are a call's own ({#argument_mutating_callees}), never the nil
5483
+ # sentinel {CAPPED_HAND_OFF}, which only a hand-off inside a def answers ({#rest_hand_off_writes?} reads it).
5484
+ def floor_splat_local(name, at, callees, base_scope)
5485
+ channels = splat_channels
5486
+ origin = base_scope.local(name)
5487
+ callees.reduce(base_scope) do |acc, (def_node, offset, owner)|
5488
+ rest = rest_parameter_written?(def_node, owner)
5489
+ channels.reduce(acc) do |inner, (census, floor)|
5490
+ mutated = census_of(owner, census, def_node)
5491
+ writer = ->(count, position) { splat_written_elements(count, position, offset, def_node, mutated, rest) }
5492
+ floor_splat_arrays(name, at, writer, floor, inner, origin)
5493
+ end
5494
+ end
5495
+ end
5496
+
5497
+ # `base_scope` with the elements of the array local `name` the callee writes floored. The local may be a union
5498
+ # (`cond ? [p, o] : [p, q]`, or an array rebound in a block) and may include nil (`args = [...] if cond`): each
5499
+ # array member floors on its own, and a union of arrays, whose position is unknown, takes the any-slot rule. A
5500
+ # local bound to an equal array type (`args2 = args`) is rebuilt too.
5501
+ def floor_splat_arrays(name, at, writer, floor, base_scope, origin)
5502
+ current = base_scope.local(name)
5503
+ members = current.is_a?(Type::Union) ? current.members : [current]
5504
+ arrays = members.count { |member| splat_elements(member) }
5505
+ return base_scope if arrays.zero?
5506
+
5507
+ origins = splat_origin_members(origin, members)
5508
+ acc = base_scope
5509
+ floored = members.each_with_index.map do |member, index|
5510
+ elements = splat_elements(member)
5511
+ next member if elements.nil?
5512
+
5513
+ position = member.is_a?(Type::Tuple) && arrays == 1 ? at : nil
5514
+ written = writer.call(elements.size, position)
5515
+ next member if written.empty?
5516
+
5517
+ source = splat_elements(origins[index]) || elements
5518
+ rebuilt, acc = floor_array_elements(member, elements, [written, source], floor, acc, name)
5519
+ rebuilt
5520
+ end
5521
+ return acc if floored == members
5522
+
5523
+ rebind_array_aliases(name, current, Type::Combinator.union(*floored), acc)
5524
+ end
5525
+
5526
+ # The members of the array local as it was before the channels ran, aligned with `members`, or `members` itself
5527
+ # when a channel merged or dropped some.
5528
+ def splat_origin_members(origin, members)
5529
+ origin_members = splat_parts(origin)
5530
+ origin_members.size == members.size ? origin_members : members
5531
+ end
5532
+
5533
+ # `scope_in` with `name`, and every local bound to the same array type `before`, rebound to `after`.
5534
+ def rebind_array_aliases(name, before, after, scope_in)
5535
+ aliased = scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
5536
+ other != name && bound == before ? acc.with_mutated_local(other, after) : acc
5537
+ end
5538
+ aliased.with_mutated_local(name, after)
5539
+ end
5540
+
5541
+ def splat_channels
5542
+ [[:callee_content_mutated_parameters, method(:content_floor_for)],
5543
+ [:callee_block_mutated_parameters, UnknownStoreWidening.method(:callee_block_floor)],
5544
+ [:callee_set_mutated_parameters, UnknownStoreWidening.method(:callee_set_floor)]]
5545
+ end
5546
+
5547
+ # The element types of `type` when it is an array a splat spreads, or nil: a tuple's, or a nominal `Array[T]`'s.
5548
+ def splat_elements(type)
5549
+ if type.is_a?(Type::Tuple) then type.elements
5550
+ elsif type.is_a?(Type::Nominal) && type.class_name == "Array" && type.type_args.size == 1 then type.type_args
5551
+ end
5552
+ end
5553
+
5554
+ # The indexes of the `count` array elements the callee writes, for a splat at the call's argument `at` (nil: a
5555
+ # position the call does not show, or an array of unknown length). `rest` is true when the callee may write its
5556
+ # rest parameter ({#rest_parameter_written?}).
5557
+ def splat_written_elements(count, at, offset, def_node, mutated, rest)
5558
+ slots = mutated.values
5559
+ return NO_ELEMENTS unless rest || slots.any? { |slot| slot.is_a?(Integer) || slot == ANY_SLOT }
5560
+ return (0...count).to_a if at.nil? || at < offset || slots.include?(ANY_SLOT)
5561
+
5562
+ named = content_parameter_slots(def_node).each_value.grep(Integer).size
5563
+ (0...count).select do |j|
5564
+ slot = at - offset + j
5565
+ slot >= named ? rest : slots.include?(slot)
5566
+ end
5567
+ end
5568
+
5569
+ NO_ELEMENTS = [].freeze
5570
+ private_constant :NO_ELEMENTS
5571
+
5572
+ # `[array, scope]`: the array type `member` with its `written` elements floored by `floor`, and `scope_in` with
5573
+ # every caller local (other than `name`) the floored elements could be floored too. A union element floors member
5574
+ # by member; every element of the array holding a floored type is rebuilt (`[options, options]`, one slot
5575
+ # written); a local is floored (gradually, {#floor_locals_accepted_by}) when a floored part accepts its type.
5576
+ # Locals are matched against the `source` elements, the array as it was before an earlier channel floored it,
5577
+ # so a floored `Hash[untyped, untyped]` element does not match every hash.
5578
+ def floor_array_elements(member, elements, written_and_source, floor, scope_in, name)
5579
+ written, source = written_and_source
5580
+ acc = scope_in
5581
+ pairs = []
5582
+ written.each do |j|
5583
+ splat_parts(source[j]).each { |part| acc = floor_locals_accepted_by(part, name, floor, acc) }
5584
+ splat_parts(elements[j]).each do |part|
5585
+ replacement = floor.call(part)
5586
+ pairs << [part, replacement] unless replacement.nil? || replacement == part
5587
+ end
5588
+ end
5589
+ return [member, acc] if pairs.empty?
5590
+
5591
+ floored = elements.map { |element| replace_splat_parts(element, pairs) }
5592
+ rebuilt = if member.is_a?(Type::Tuple)
5593
+ Type::Combinator.tuple_of(*floored)
5594
+ else
5595
+ Type::Combinator.nominal_of("Array", type_args: floored)
5596
+ end
5597
+ [rebuilt, acc]
5598
+ end
5599
+
5600
+ def splat_parts(type) = type.is_a?(Type::Union) ? type.members : [type]
5601
+
5602
+ # `element` with the floored parts replaced, gradually ({#gradual_floor}): an element is floored by
5603
+ # over-approximation as often as by a precise slot (a repeated type, an unknown position, a rest read as written),
5604
+ # and one the callee never writes must not start failing against a literal parameter type.
5605
+ def replace_splat_parts(element, pairs)
5606
+ parts = splat_parts(element)
5607
+ replaced = parts.map do |part|
5608
+ replacement = pairs.find { |from, _| from == part }&.last
5609
+ replacement ? gradual_floor(replacement) : part
5610
+ end
5611
+ replaced == parts ? element : Type::Combinator.union(*replaced)
5612
+ end
5613
+
5614
+ # `scope_in` with each carrier local other than `except` whose type `part` accepts (it is a subtype of, or equal
5615
+ # to, the element: `args << options` widens the pushed `{ format: "text" }` to `Hash[Symbol, String]`) floored
5616
+ # with `floor`. Such a local is only a guess at the element's source (the type is all the array records), so its
5617
+ # floor is gradual ({#gradual_floor}): it may forget a fact, never add a finding. A `Dynamic` part matches no
5618
+ # local: it stands for any value and would floor every carrier in scope.
5619
+ def floor_locals_accepted_by(part, except, floor, scope_in)
5620
+ return scope_in if part.is_a?(Type::Dynamic)
5621
+
5622
+ scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
5623
+ next acc if other == except || !UnknownStoreWidening.callee_floorable?(bound)
5624
+ next acc unless Acceptance.accepts(part, bound).yes?
5625
+
5626
+ floored = floor.call(bound)
5627
+ floored.nil? || floored == bound ? acc : acc.with_mutated_local(other, gradual_floor(floored))
5628
+ end
5629
+ end
5630
+
5631
+ # `type` with a bare `String` carrier made gradual (`Dynamic[String]`): a floor to the nominal `String` fails
5632
+ # against a literal parameter type (`("text") -> String`) that the unmutated local satisfied.
5633
+ def gradual_floor(type)
5634
+ return Type::Combinator.union(*type.members.map { |member| gradual_floor(member) }) if type.is_a?(Type::Union)
5635
+
5636
+ string_carrier?(type) ? Type::Combinator.dynamic(type) : type
5637
+ end
5638
+
5639
+ # `floored`, a callee floor (content or block channel) of an argument of a call to `def_node` under `owner`,
5640
+ # made gradual when the floor is an over-approximation ({#over_approximating_callee?}) or the call has several
5641
+ # callees. The one place both channels take the rule.
5642
+ def callee_gradual_floor(floored, def_node, owner, several)
5643
+ # Only a String carrier turns gradual, so the callee is walked only for one (this runs per floored argument).
5644
+ return floored unless string_carrier_in?(floored)
5645
+
5646
+ several || over_approximating_callee?(def_node, owner) ? gradual_floor(floored) : floored
5647
+ end
5648
+
5649
+ def string_carrier_in?(type)
5650
+ type.is_a?(Type::Union) ? type.members.any? { |member| string_carrier?(member) } : string_carrier?(type)
5651
+ end
5652
+
5653
+ # Issue #2515 — true when the floor a call applies to `def_node`'s arguments is an over-approximation, with no
5654
+ # proof that the callee writes the argument it floors: the owner is {FLOOR_ALL} (an inherited def, a
5655
+ # `method_missing`) or {FLOOR_EVERY} (a top-level def that may not be the one called), or the callee hands a
5656
+ # parameter on, through any hop, to one of those, by name or to several candidates. A def read as written that
5657
+ # writes the argument keeps the
5658
+ # precise floor, which is what a later `Integer` parameter or `no_such_method` on the String needs to report.
5659
+ # The walk is bounded by {CalleeMutationChain::MAX_NODES}; past it the answer is true.
5660
+ def over_approximating_callee?(def_node, owner)
5661
+ return true if OVER_APPROXIMATING.include?(owner)
5662
+
5663
+ seen = {}.compare_by_identity
5664
+ queue = [[def_node, owner]]
5665
+ until queue.empty?
5666
+ node, node_owner = queue.shift
5667
+ next if (seen[node] ||= {}).key?(node_owner)
5668
+
5669
+ seen[node][node_owner] = true
5670
+ return true if seen.sum { |_, owners| owners.size } > CalleeMutationChain::MAX_NODES
5671
+
5672
+ census_of(node_owner, :forwarding_edges, node).each do |edge|
5673
+ return true if edge.approx || OVER_APPROXIMATING.include?(edge.owner)
5674
+
5675
+ queue << [edge.callee, edge.owner || node_owner]
5676
+ end
5677
+ end
5678
+ false
5679
+ end
5680
+
5681
+ def string_carrier?(type) = type.is_a?(Type::Nominal) && type.class_name == "String"
5682
+
5683
+ # True when `def_node` has a rest, post or `...` parameter, so a slot no longer names the parameter an element
5684
+ # binds.
5685
+ def variable_arity?(def_node)
5686
+ params = def_node.parameters
5687
+ return false if params.nil?
5688
+
5689
+ !params.rest.nil? || !params.posts.empty? || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
5690
+ end
5691
+
5692
+ # Issue #2515 — true when a callee's rest parameter (`*args`, `*`, `...`) may be written, so an element a splat
5693
+ # binds to it floors: the body reads it anywhere but as a splat argument of a call (`args.first[:k] = 1`), or
5694
+ # hands it on (`b(*args)`) to a call that resolves to a def which writes a positional parameter or has a rest
5695
+ # parameter of its own that it hands on. A rest handed to a call that resolves to no user def hands off nothing,
5696
+ # as for any hand-off. A callee reached through an inherited lookup ({FLOOR_ALL}) reads as written, as its
5697
+ # parameters do.
5698
+ def rest_parameter_written?(def_node, owner)
5699
+ return true if def_node.nil?
5700
+ return false unless variable_arity?(def_node)
5701
+ return true if owner == FLOOR_ALL
5702
+
5703
+ other, calls = def_census(:rest_uses, def_node) { rest_uses(def_node) }
5704
+ return true if other
5705
+
5706
+ evaluator = owner.nil? ? self : owner_evaluator(owner)
5707
+ calls.any? { |call_node, depth| evaluator.__send__(:rest_hand_off_writes?, call_node, def_node, depth) }
5708
+ end
5709
+
5710
+ # True when a call that splats a rest parameter hands it to a def that writes a positional parameter or has a
5711
+ # rest or post parameter of its own, read in this evaluator, the one the call's def runs in. A nil callee is
5712
+ # {CAPPED_HAND_OFF}'s sentinel (a name with too many defs) and reads as written.
5713
+ def rest_hand_off_writes?(call_node, def_node, depth)
5714
+ hand_off_callees(call_node, def_node, depth).any? do |callee, _offset, callee_owner|
5715
+ next true if callee.nil? || variable_arity?(callee)
5716
+
5717
+ census = census_of(callee_owner, :callee_content_mutated_parameters, callee)
5718
+ census.value?(ANY_SLOT) || census.each_value.any?(Integer)
5719
+ end
5720
+ end
5721
+
5722
+ # `[other, calls]` for `def_node`'s rest parameter: `other` is true when it is read except as a splat argument
5723
+ # (`*args`, `*`) of a call, `calls` the calls that splat it ({#rest_parameter_written?}). An AST property.
5724
+ def rest_uses(def_node)
5725
+ params = def_node.parameters
5726
+ name = params.rest.is_a?(Prism::RestParameterNode) ? params.rest.name : nil
5727
+ forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
5728
+ reads = 0
5729
+ splatted = 0
5730
+ calls = []
5731
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
5732
+ reads += 1 if name && splat_reads?(node, name, depth)
5733
+ next unless node.is_a?(Prism::CallNode) && node.arguments
5734
+
5735
+ spread = node.arguments.arguments.select { |a| rest_splat?(a, name, forwarding, depth) }
5736
+ next if spread.empty?
5737
+
5738
+ splatted += spread.count { |a| a.is_a?(Prism::SplatNode) && a.expression }
5739
+ calls << [node, depth]
5740
+ end
5741
+ [reads > splatted, calls.freeze].freeze
5742
+ end
5743
+
5744
+ def rest_splat?(argument, name, forwarding, depth)
5745
+ case argument
5746
+ when Prism::ForwardingArgumentsNode then forwarding
5747
+ when Prism::SplatNode
5748
+ argument.expression.nil? ? name.nil? : !name.nil? && splat_reads?(argument.expression, name, depth)
5749
+ else false
5750
+ end
5751
+ end
5752
+
5753
+ def splat_reads?(node, name, depth)
5754
+ node.is_a?(Prism::LocalVariableReadNode) && node.name == name && node.depth == depth
5755
+ end
5756
+
5757
+ # True when a local `node` passes (positionally or as a keyword's value) is bound in `scope` to a type the
5758
+ # block accepts.
5759
+ def floorable_local_argument?(node, scope, &)
5760
+ node.arguments.arguments.any? do |argument|
5761
+ if argument.is_a?(Prism::KeywordHashNode)
5762
+ argument.elements.any? do |pair|
5763
+ pair.is_a?(Prism::AssocNode) && floorable_local?(pair.value, scope, &)
5764
+ end
5765
+ else
5766
+ floorable_local?(argument, scope, &)
5767
+ end
5768
+ end
5769
+ end
5770
+
5771
+ def floorable_local?(node, scope)
5772
+ node = node.value if node.is_a?(Prism::ImplicitNode)
5773
+ node.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(node.name) && yield(scope.local(node.name))
5774
+ end
5775
+
5776
+ # `base_scope` with each local `node` passes to a parameter of `mutated` rebound to what the block answers for
5777
+ # its binding; a nil answer, or an answer equal to the binding, leaves it.
5778
+ def floor_arguments(node, mutated, base_scope, offset, climbing = nil)
5779
+ arguments = arguments_for_slots(node, mutated, offset)
5780
+ return base_scope if arguments.empty?
5781
+
5782
+ arguments.reduce(base_scope) do |acc, argument|
5783
+ next acc unless acc.locals.key?(argument.name)
5784
+ next acc if climbing && argument.depth <= climbing
5785
+
5786
+ current = acc.local(argument.name)
5787
+ floored = yield(current)
5788
+ floored.nil? || floored == current ? acc : acc.with_mutated_local(argument.name, floored)
5789
+ end
5790
+ end
5791
+
5792
+ # The `{ name => slot }` parameters whose content the callee mutates, a slot being a positional index or a
5793
+ # keyword symbol: those it mutates itself ({#own_content_parameters}) and, issue #2104, those it passes on to a
5794
+ # self-call whose callee mutates them, through any number of such hand-offs ({CalleeMutationChain}). Each leaves
5795
+ # the caller's argument binding stale after the call, so each floors it. Memoised per def node (the closure is
5796
+ # otherwise recomputed at every call site), and across evaluators by {#resolved_census}.
5797
+ def callee_content_mutated_parameters(def_node)
5798
+ cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
5799
+ cache[def_node] ||= resolved_census(:close, def_node) do
5800
+ CalleeMutationChain.close(def_node, own: hop_census(:own_content_parameters),
5801
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
5802
+ end
5803
+ end
5804
+
5805
+ # Issue #2515 — `census` as the `(def, owner)` callback {CalleeMutationChain} reads a hop through: `owner` is the
5806
+ # evaluator a cross-class hand-off resolves in, nil for the evaluator the closure started in.
5807
+ def hop_census(census) = ->(def_node, owner) { census_of(owner, census, def_node) }
5808
+
5809
+ # ADR-127 WD2 — the run's successor store for {CalleeMutationChain} keyed by discovery: `def => owner => name =>
5810
+ # [callee, name, owner, ...]`, a graph fact (the nodes a parameter is handed to), never a walk's conclusion. The
5811
+ # edges do not depend on which census a closure reads, so one store serves all three. The nil owner is stored
5812
+ # under the side and class the closure runs in, the same context {#resolved_census} keys by. Bypassed (nothing
5813
+ # read or written) under {#hand_off_memo_bypassed?}, as the memos it sits beside.
5814
+ def callee_chain_store
5815
+ return NO_CHAIN_STORE if hand_off_memo_bypassed?
5816
+
5817
+ table = (Thread.current[CHAIN_STORE_KEY] ||= ObjectSpace::WeakKeyMap.new)
5818
+ self_type = scope.self_type
5819
+ { store: (table[scope.discovery] ||= {}.compare_by_identity),
5820
+ ctx: [self_type.is_a?(Type::Singleton), enclosing_class_name_for(self_type)] }
5821
+ end
5822
+
5823
+ CHAIN_STORE_KEY = RunScopedSlots.register(:rigor_callee_chain_successors)
5824
+ NO_CHAIN_STORE = {}.freeze
5825
+ private_constant :CHAIN_STORE_KEY, :NO_CHAIN_STORE
5826
+
5827
+ # Issue #2296 — the `{ name => slot }` parameters the callee mutates inside a block in its body
5828
+ # ({#own_block_content_parameters}), closed over hand-offs as {#callee_content_mutated_parameters} is.
5829
+ def callee_block_mutated_parameters(def_node)
5830
+ cache = (@callee_block_param_cache ||= {}.compare_by_identity)
5831
+ cache[def_node] ||= resolved_census(:close_block, def_node) do
5832
+ CalleeMutationChain.close(def_node, own: hop_census(:own_block_content_parameters),
5833
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
5834
+ end
5835
+ end
5836
+
5837
+ # Issue #2296 — the `{ name => slot }` parameters the callee grows through a Set-only adder
5838
+ # ({#own_set_content_parameters}), closed over hand-offs the same way.
5839
+ def callee_set_mutated_parameters(def_node)
5840
+ cache = (@callee_set_param_cache ||= {}.compare_by_identity)
5841
+ cache[def_node] ||= resolved_census(:close_set, def_node) do
5842
+ CalleeMutationChain.close(def_node, own: hop_census(:own_set_content_parameters),
5843
+ edges: hop_census(:forwarding_edges), **callee_chain_store)
5844
+ end
5845
+ end
5846
+
5847
+ # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callees}), so it
5848
+ # reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
5849
+ # resolve through. Those two key it: the index by identity, as it is immutable (one entry per live index, held
5850
+ # weakly: a table dropped with its index), the class by name. A run builds hundreds of
5851
+ # thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
5852
+ # thirty times over (#2295's cost on the release gate). Issue #2297 — the side self-calls resolve on
5853
+ # ({#resolve_self_callees}) keys it too: a singleton-side table apart from the instance-side one.
5854
+ #
5855
+ # While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
5856
+ # and in each capture window open at the time, and a hit would leave that read out of them. It is bypassed, read
5857
+ # and write, while this scope carries guard narrowings of constants ({#hand_off_memo_bypassed?}, #2524): they
5858
+ # survive `with_self_type` and change what a constant in the def's body types as, so the census is no longer
5859
+ # the def's alone, and the first caller's narrowing must not become every later caller's answer.
5860
+ def resolved_census(kind, def_node, &)
5861
+ return yield if hand_off_memo_bypassed?
5862
+
5863
+ discovery = scope.discovery
5864
+ table = (Thread.current[RESOLVED_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
5865
+ slot = (table[discovery] ||= [{}, {}])
5866
+ self_type = scope.self_type
5867
+ by_class = (slot[self_type.is_a?(Type::Singleton) ? 1 : 0][kind] ||= {})
5868
+ class_name = enclosing_class_name_for(self_type)
5869
+ by_def = (by_class[class_name] ||= {}.compare_by_identity)
5870
+ return by_def[def_node] = yield unless by_def.key?(def_node)
5871
+
5872
+ MemoAudit.check("resolved census", by_def[def_node], kind, class_name, &)
5873
+ end
5874
+
5875
+ RESOLVED_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_resolved_census)
5876
+ private_constant :RESOLVED_CENSUS_KEY
5877
+
5878
+ # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
5879
+ # run later / repeatedly) AND those mutated directly in the method body during the call itself
5880
+ # (`declaration[:prefix] = v`). A property of the def's AST alone, so it is memoised per def node for the thread
5881
+ # ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
5882
+ # call into it.
5883
+ def own_content_parameters(def_node)
5884
+ def_census(:own, def_node) do
5885
+ own = escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
5886
+ written = callee_write_census(def_node).written
5887
+ own = own.merge(written_slots(def_node, written)) unless written.empty?
5888
+ handed = super_forwarded_parameters(def_node)
5889
+ handed.empty? ? own : own.merge(handed)
5890
+ end
5891
+ end
5892
+
5893
+ # Issue #2595 — the parameter writes the content scans above miss: a shape mutator they do not count
5894
+ # (`h.merge!(x)`), a write through an alias (`other = h`, `h.tap { |x| }`, `[h].each { |x| }`, `@x = h`), and
5895
+ # the aliases themselves, which {#forwarded_parameters} follows as hand-offs ({CalleeWriteCensus}). A property
5896
+ # of the def's AST alone, memoised per def node for the thread.
5897
+ def callee_write_census(def_node)
5898
+ def_census(:writes, def_node) do
5899
+ CalleeWriteCensus.scan(def_node.body, census_seeds(def_node),
5900
+ tables: CENSUS_TABLES, rebinds: parameter_rebinds(def_node),
5901
+ returns: returns_counted?(def_node), anonymous: anonymous_rests?(def_node))
5902
+ end
5903
+ end
5904
+
5905
+ # Issue #2336 — the calls whose block is a method body of the class (`define_method(:go) { poke(h) }`): the
5906
+ # hand-off walk leaves it out ({#each_node_in_method_scope}), so the write census reads a call in it that passes
5907
+ # a parameter as written ({CalleeWriteCensus}). A block run on another `self` (`instance_exec`, `Class.new`)
5908
+ # stays out: a self-call there is not a call on the method's receiver.
5909
+ METHOD_BODY_BLOCK_CALLS = %i[define_method define_singleton_method].to_set.freeze
5910
+ private_constant :METHOD_BODY_BLOCK_CALLS
5911
+
5912
+ # Issue #2336 — whether the value a def returns counts as a write of the parameter it is ({CalleeWriteCensus}):
5913
+ # not for `initialize`, whose value `new` drops, nor for a setter (`x=`, `[]=`), whose value an assignment drops.
5914
+ def returns_counted?(def_node)
5915
+ name = def_node.name
5916
+ name != :initialize && name != :[]= && !SETTER_NAME.match?(name)
5917
+ end
5918
+
5919
+ # An attribute writer's name: an identifier followed by `=` (`format=`), not an operator (`==`, `<=`, `===`).
5920
+ SETTER_NAME = /\A[A-Za-z_][A-Za-z0-9_]*=\z/
5921
+ private_constant :SETTER_NAME
5922
+
5923
+ # Issue #2336 — true when `def_node` has an anonymous rest (`*`), keyword rest (`**`) or `...`, whose arguments
5924
+ # an opaque sink may receive ({CalleeWriteCensus::ANONYMOUS_REST}).
5925
+ def anonymous_rests?(def_node)
5926
+ params = def_node.parameters
5927
+ return false if params.nil?
5928
+
5929
+ (params.rest.is_a?(Prism::RestParameterNode) && params.rest.name.nil?) ||
5930
+ (params.keyword_rest.is_a?(Prism::KeywordRestParameterNode) && params.keyword_rest.name.nil?) ||
5931
+ params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
5932
+ end
5933
+
5934
+ # The parameters {#callee_write_census} follows: each content parameter holds the caller's argument itself
5935
+ # (`:same`); a named rest (`*args`) or keyword rest (`**kw`) holds the caller's arguments as elements (`:held`).
5936
+ def census_seeds(def_node)
5937
+ seeds = content_parameter_slots(def_node).transform_values { :same }
5938
+ rest_parameter_names(def_node).each_key { |name| seeds[name] = :held }
5939
+ seeds
5940
+ end
5941
+
5942
+ # `{ name => census key }` for a named rest (`*args` reads as {REST_PARAMETER}) and keyword rest (`**kw` reads as
5943
+ # {KEYWORD_REST_PARAMETER}) of `def_node`.
5944
+ def rest_parameter_names(def_node)
5945
+ params = def_node.parameters
5946
+ return NO_PARAMETER_SLOTS if params.nil?
5947
+
5948
+ rest = params.rest
5949
+ keyword_rest = params.keyword_rest
5950
+ names = {}
5951
+ names[rest.name] = REST_PARAMETER if rest.is_a?(Prism::RestParameterNode) && rest.name
5952
+ names[keyword_rest.name] = KEYWORD_REST_PARAMETER if keyword_rest.is_a?(Prism::KeywordRestParameterNode) &&
5953
+ keyword_rest.name
5954
+ names
5955
+ end
5956
+
5957
+ # The own-census entries for the parameter names {#callee_write_census} wrote: a content parameter at its slot,
5958
+ # a rest at {ANY_SLOT} and a keyword rest at {ANY_KEYWORD}, the slots every argument they bind reaches.
5959
+ #
5960
+ # Issue #2336 — an anonymous rest, keyword rest or `...` an opaque sink received
5961
+ # ({CalleeWriteCensus::ANONYMOUS_REST}) is written at its slot too.
5962
+ def written_slots(def_node, written)
5963
+ found = content_parameter_slots(def_node).slice(*written)
5964
+ rest_parameter_names(def_node).each do |name, key|
5965
+ found[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD if written.include?(name)
5966
+ end
5967
+ rests = rest_parameters(def_node)
5968
+ if written.include?(CalleeWriteCensus::ANONYMOUS_REST) && rests.include?(REST_PARAMETER)
5969
+ found[REST_PARAMETER] = ANY_SLOT
5970
+ end
5971
+ if written.include?(CalleeWriteCensus::ANONYMOUS_KEYWORD_REST) && rests.include?(KEYWORD_REST_PARAMETER)
5972
+ found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD
5973
+ end
5974
+ found
5975
+ end
5976
+
5977
+ # Issue #2521 (#2570 review) — the parameters `def_node` hands to the method it overrides: every content
5978
+ # parameter under a zsuper (`super`, which passes them all), and each one an explicit `super(...)` passes as an
5979
+ # argument or keyword value, at the block depth the call sits at. The overridden def is not followed; the
5980
+ # parameter reads as mutated, so a literal handed through an override that forwards it is floored.
5981
+ #
5982
+ # A rest parameter (`*`, `*args`, `...`) handed on whole (a zsuper, `super(*args)`, `super(...)`) reads as
5983
+ # mutated under {REST_PARAMETER}, the name an argument the call binds to the rest parameter reaches
5984
+ # ({#compute_forwarding_edges}), at {ANY_SLOT} ({#arguments_for_slots}).
5985
+ def super_forwarded_parameters(def_node)
5986
+ # A cheap gate first: the walk below runs for the few defs whose source mentions `super` at all.
5987
+ body = def_node.body
5988
+ return NO_PARAMETER_SLOTS if body.nil? || !body.slice.include?("super")
5989
+
5990
+ slots = content_parameter_slots(def_node)
5991
+ rests = rest_parameters(def_node)
5992
+ return NO_PARAMETER_SLOTS if slots.empty? && rests.empty?
5993
+
5994
+ names = super_forwarded_names(def_node, slots, rests)
5995
+ return NO_PARAMETER_SLOTS if names.nil?
5996
+
5997
+ found = slots.slice(*names)
5998
+ found[REST_PARAMETER] = ANY_SLOT if names.include?(REST_PARAMETER)
5999
+ found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD if names.include?(KEYWORD_REST_PARAMETER)
6000
+ found
6001
+ end
6002
+
6003
+ # The parameter names `def_node` hands to `super` ({#super_forwarded_parameters}), or nil when it calls none.
6004
+ # `rests` holds the rest names the def has ({#rest_parameters}).
6005
+ def super_forwarded_names(def_node, slots, rests)
6006
+ names = nil
6007
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
6008
+ case node
6009
+ when Prism::ForwardingSuperNode
6010
+ (names ||= Set.new).merge(slots.keys.reject { |name| after_rebind?(def_node, name, node) }).merge(rests)
6011
+ when Prism::SuperNode
6012
+ handed = super_argument_parameters(node, slots, depth).reject { |name| after_rebind?(def_node, name, node) }
6013
+ (names ||= Set.new).merge(handed)
6014
+ names.merge(rests & splatted_rests(node))
6015
+ end
6016
+ end
6017
+ names
6018
+ end
6019
+
6020
+ # The names {#super_forwarded_parameters} records a positional and a keyword rest parameter handed to `super`
6021
+ # under; no local can be named with a leading `*`.
6022
+ REST_PARAMETER = :"*rest"
6023
+ KEYWORD_REST_PARAMETER = :"**rest"
6024
+ REST_NAMES = [REST_PARAMETER].freeze
6025
+ KEYWORD_REST_NAMES = [KEYWORD_REST_PARAMETER].freeze
6026
+ private_constant :REST_PARAMETER, :KEYWORD_REST_PARAMETER, :REST_NAMES, :KEYWORD_REST_NAMES
6027
+
6028
+ # The rest names an argument the call binds to no named parameter of `callee` reaches, when `callee` hands that
6029
+ # rest parameter to `super` ({#super_forwarded_parameters}); empty otherwise. `slot` is the call's positional
6030
+ # index, or the keyword.
6031
+ def handed_rest_names(callee, slot)
6032
+ own = own_content_parameters(callee)
6033
+ if slot.is_a?(Symbol) then own.key?(KEYWORD_REST_PARAMETER) ? KEYWORD_REST_NAMES : NO_FORWARDS
6034
+ else own.key?(REST_PARAMETER) ? REST_NAMES : NO_FORWARDS
6035
+ end
6036
+ end
6037
+
6038
+ # The rest parameters `def_node` has, as {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}: `*args` / `*`, `**kw` /
6039
+ # `**`, both for `...`.
6040
+ def rest_parameters(def_node)
6041
+ params = def_node.parameters
6042
+ return NO_FORWARDS if params.nil?
6043
+
6044
+ forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
6045
+ rests = []
6046
+ rests << REST_PARAMETER if forwarding || params.rest.is_a?(Prism::RestParameterNode)
6047
+ rests << KEYWORD_REST_PARAMETER if forwarding || params.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
6048
+ rests
6049
+ end
6050
+
6051
+ # The rests an explicit `super(...)` passes on whole: a splat (`*args`, `*`), a double splat (`**kw`, `**`) in its
6052
+ # keywords, or `...` for both.
6053
+ def splatted_rests(node)
6054
+ (node.arguments&.arguments || []).flat_map do |argument|
6055
+ case argument
6056
+ when Prism::SplatNode then REST_NAMES
6057
+ when Prism::ForwardingArgumentsNode then [REST_PARAMETER, KEYWORD_REST_PARAMETER]
6058
+ when Prism::KeywordHashNode
6059
+ argument.elements.any?(Prism::AssocSplatNode) ? KEYWORD_REST_NAMES : NO_FORWARDS
6060
+ else NO_FORWARDS
6061
+ end
6062
+ end
6063
+ end
6064
+
6065
+ def super_argument_parameters(node, slots, depth)
6066
+ arguments = node.arguments&.arguments || []
6067
+ arguments.flat_map do |argument|
6068
+ if argument.is_a?(Prism::KeywordHashNode)
6069
+ argument.elements.filter_map do |pair|
6070
+ parameter_read_name(pair.value, slots, depth) if pair.is_a?(Prism::AssocNode)
6071
+ end
6072
+ else
6073
+ [parameter_read_name(argument, slots, depth)].compact
6074
+ end
6075
+ end
6076
+ end
6077
+
6078
+ # Issue #2296 — the parameters `def_node` content-mutates inside a block of its body, at any nesting: a
6079
+ # mutation whose receiver reads the parameter itself at the block depth it sits at, so a block parameter or
6080
+ # block-local of the same name, and a block-local copy (`t = [acc[i]]; t[0] = v`), do not count. A lambda or
6081
+ # nested def is not entered (its body is not run by the call), nor is a block that runs on another `self`
6082
+ # ({#each_node_in_method_scope}). The walk counts the method body too: a mutation there, or in an escaping
6083
+ # block, is one the bare census ({#own_content_parameters}) also counts, and its floor runs first, leaving this
6084
+ # one nothing to widen. A property of the AST alone, memoised per def node for the thread.
6085
+ def own_block_content_parameters(def_node) = block_and_set_parameters(def_node)[0]
6086
+
6087
+ # Issue #2296 — the parameters `def_node` grows through a Set-only adder ({SET_ONLY_ADDERS}) in its body or a
6088
+ # block in it, read at the depth it sits at as above. The name alone does not say the receiver is a Set
6089
+ # (`Hash#merge` returns a new Hash), so only a Set argument is floored on it
6090
+ # ({UnknownStoreWidening.callee_set_floor}).
6091
+ def own_set_content_parameters(def_node) = block_and_set_parameters(def_node)[1]
6092
+
6093
+ # Both censuses above, `[block, set]`, from one walk of the method scope, memoised per def node for the thread.
6094
+ def block_and_set_parameters(def_node)
6095
+ def_census(:block_and_set, def_node) do
6096
+ slots = content_parameter_slots(def_node)
6097
+ next NO_BLOCK_AND_SET if slots.empty?
6098
+
6099
+ block = nil
6100
+ set = nil
6101
+ each_node_in_method_scope(def_node.body, 0) do |node, depth|
6102
+ name = content_mutation_target(node) { |r| r.depth == depth }.first
6103
+ (block ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
6104
+ name = set_only_adder_target(node, depth)
6105
+ (set ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
6106
+ end
6107
+ [block.nil? ? NO_PARAMETER_SLOTS : slots.slice(*block), set.nil? ? NO_PARAMETER_SLOTS : slots.slice(*set)]
6108
+ end
6109
+ end
6110
+
6111
+ # True when `name`, the local the mutation `node` targets, is one of `slots`' parameters and `node` runs before
6112
+ # any rebind of it ({#after_rebind?}).
6113
+ def counted_mutation?(def_node, slots, name, node)
6114
+ !name.nil? && slots.key?(name) && !after_rebind?(def_node, name, node)
6115
+ end
6116
+
6117
+ # The local `node` grows through a Set-only adder when its receiver reads that local at `depth`, else nil.
6118
+ def set_only_adder_target(node, depth)
6119
+ return unless node.is_a?(Prism::CallNode) && SET_ONLY_ADDERS.include?(node.name)
6120
+
6121
+ receiver = node.receiver
6122
+ receiver.name if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
6123
+ end
6124
+
6125
+ NO_PARAMETER_SLOTS = {}.freeze
6126
+ NO_BLOCK_AND_SET = [NO_PARAMETER_SLOTS, NO_PARAMETER_SLOTS].freeze
6127
+ private_constant :NO_PARAMETER_SLOTS, :NO_BLOCK_AND_SET
6128
+
6129
+ # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
6130
+ def def_census(kind, def_node, &)
6131
+ table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
6132
+ entry = (table[def_node] ||= {})
6133
+ return entry[kind] = yield unless entry.key?(kind)
6134
+
6135
+ MemoAudit.check("def census", entry[kind], kind, &)
6136
+ end
6137
+
6138
+ DEF_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_def_census)
6139
+ private_constant :DEF_CENSUS_KEY
6140
+
6141
+ # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
6142
+ # a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
6143
+ # (not a block-local shadowing it), in the method body or a block in it. The callee resolves in this evaluator's
6144
+ # class, the receiver's class at every hop of a self-call chain. Memoised per def node, and across evaluators by
6145
+ # {#resolved_census}.
6146
+ def forwarding_edges(def_node)
6147
+ cache = (@forwarding_edge_cache ||= {}.compare_by_identity)
6148
+ cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
6149
+ end
6150
+
6151
+ # True for a hand-off answer with more than one candidate, or resolved by name ({UnionCallees}): its edges are
6152
+ # over-approximations ({CalleeMutationChain::Edge#approx}).
6153
+ def approximate_hand_off?(callees) = callees.size > 1 || callees.is_a?(UnionCallees)
6154
+
6155
+ # True for a hand-off answer that reads the handed parameter as written whatever the callee is: a name with too
6156
+ # many defs ({CAPPED_HAND_OFF}) or a top-level def that may not be the one called ({FLOOR_EVERY}).
6157
+ def floors_outright?(callees)
6158
+ callees.equal?(CAPPED_HAND_OFF) || callees.any? { |_callee, _offset, owner| owner == FLOOR_EVERY }
6159
+ end
6160
+
6161
+ def compute_forwarding_edges(def_node)
6162
+ slots = forwarding_slots(def_node)
6163
+ edges = []
6164
+ each_forwarding_call(def_node, slots) do |call_node, forwarded, depth|
6165
+ callees = hand_off_callees(call_node, def_node, depth)
6166
+ if floors_outright?(callees)
6167
+ forwarded.map(&:first).each do |name|
6168
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: def_node,
6169
+ callee_names: [name], owner: FLOOR_ALL)
6170
+ end
6171
+ next
6172
+ end
6173
+
6174
+ callees.each do |callee, offset, owner|
6175
+ callee_slots = content_parameter_slots(callee)
6176
+ forwarded.each do |name, index|
6177
+ names = bound_parameter_names(callee_slots, index.is_a?(Integer) ? index - offset : index)
6178
+ names = handed_rest_names(callee, index) if names.empty? && index != ANY_SLOT
6179
+ next if names.empty?
6180
+
6181
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names,
6182
+ owner: owner, approx: approximate_hand_off?(callees))
6183
+ end
6184
+ end
6185
+ end
6186
+ edges.empty? ? NO_FORWARDS : edges
6187
+ end
6188
+
6189
+ # Yields each self-call in `def_node`'s body that hands on one of `slots`' parameters, with
6190
+ # {#forwarded_parameters}' answer for it. The calls are an AST property, memoised per def node ({#def_census}).
6191
+ def each_forwarding_call(def_node, slots, &)
6192
+ return if slots.empty?
6193
+
6194
+ def_census(:forwards, def_node) { forwarding_calls(def_node, slots) }.each(&)
6195
+ end
6196
+
6197
+ def forwarding_calls(def_node, slots)
6198
+ calls = []
6199
+ context = HandOffContext.new(census: callee_write_census(def_node), seeds: census_seeds(def_node),
6200
+ rests: rest_parameter_names(def_node), rebinds: parameter_rebinds(def_node))
6201
+ each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
6202
+ next unless self_dispatch_call?(descendant) || hand_off_receiver?(descendant)
6203
+
6204
+ forwarded = forwarded_parameters(descendant, slots, depth, context)
6205
+ calls << [descendant, forwarded, depth] unless forwarded.empty?
6206
+ end
6207
+ calls.empty? ? NO_FORWARDS : calls
6208
+ end
6209
+
6210
+ NO_FORWARDS = [].freeze
6211
+ private_constant :NO_FORWARDS
6212
+
6213
+ # `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
6214
+ # `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
6215
+ # argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
6216
+ # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index among the call's
6217
+ # arguments, which {#compute_forwarding_edges} shifts by each callee's offset ({#resolve_self_callees}).
6218
+ # Issue #2595 — an argument that reads an alias of a parameter (`other = h; poke(other)`, `@x = h; poke(@x)`, a
6219
+ # block parameter of `h.tap`) hands on that parameter too (`census`, {#callee_write_census}).
6220
+ def forwarded_parameters(call_node, slots, depth, context = NO_HAND_OFF_CONTEXT)
6221
+ args = call_node.arguments
6222
+ return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
6223
+
6224
+ spread = false
6225
+ forwarded = []
6226
+ args.arguments.each_with_index do |argument, index|
6227
+ if argument.is_a?(Prism::KeywordHashNode)
6228
+ forwarded.concat(forwarded_keywords(argument, slots, depth, context))
6229
+ elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
6230
+ spread = true
6231
+ rest = splatted_rest(argument, depth, context)
6232
+ forwarded << [rest, ANY_SLOT] if rest
6233
+ else
6234
+ slot = spread ? ANY_SLOT : index
6235
+ handed_names(argument, slots, depth, context) { |name| forwarded << [name, slot] }
6236
+ end
6237
+ end
6238
+ forwarded
6239
+ end
6240
+
6241
+ # The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
6242
+ ANY_SLOT = :__any_positional__
6243
+
6244
+ # The census {FLOOR_EVERY} answers: the rest and keyword-rest slots, which {#arguments_for_slots} reads as every
6245
+ # positional argument and every keyword value of the call.
6246
+ EVERY_ARGUMENT = { REST_PARAMETER => ANY_SLOT, KEYWORD_REST_PARAMETER => ANY_KEYWORD }.freeze
6247
+ private_constant :ANY_SLOT, :EVERY_ARGUMENT
6248
+
6249
+ # The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
6250
+ # may reach: the one at that slot, or every positional one for {ANY_SLOT}.
6251
+ def bound_parameter_names(callee_slots, slot)
6252
+ if slot == ANY_SLOT
6253
+ callee_slots.filter_map { |name, at| name if at.is_a?(Integer) }
6254
+ elsif slot == ANY_KEYWORD
6255
+ callee_slots.filter_map { |name, at| name if at.is_a?(Symbol) }
6256
+ else
6257
+ callee_slots.filter_map { |name, at| name if at == slot }
6258
+ end
6259
+ end
6260
+
6261
+ # Issue #2336 — a double splat of the def's own keyword rest (`poke(**kw)`) hands it on whole, at {ANY_KEYWORD}.
6262
+ def forwarded_keywords(hash_node, slots, depth, context)
6263
+ found = []
6264
+ hash_node.elements.each do |pair|
6265
+ if pair.is_a?(Prism::AssocSplatNode)
6266
+ rest = splatted_keyword_rest(pair, depth, context)
6267
+ found << [rest, ANY_KEYWORD] if rest
6268
+ end
6269
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
6270
+
6271
+ keyword = pair.key.unescaped.to_sym
6272
+ handed_names(pair.value, slots, depth, context) { |name| found << [name, keyword] }
6273
+ end
6274
+ found
6275
+ end
6276
+
6277
+ # Yields each parameter `node` may hand on: the one it reads ({#parameter_read_name}) and those it may be through
6278
+ # an alias or an expression ({CalleeWriteCensus.handed_aliases}). An element of a named rest or keyword rest
6279
+ # (`x = args[0]; poke(x)`, `poke(kw[:h])`) hands on that rest, under {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}
6280
+ # ({#forwarding_slots}).
6281
+ #
6282
+ # Issue #2336 — a read of the parameter after the def rebound it to a new object (`acc = acc.dup; poke(acc)`)
6283
+ # hands on the copy, not the caller's argument ({#after_rebind?}).
6284
+ def handed_names(node, slots, depth, context)
6285
+ name = parameter_read_name(node, slots, depth)
6286
+ yield name if name && !CalleeWriteCensus.rebound?(context.rebinds, name, node)
6287
+ node = node.value if node.is_a?(Prism::ImplicitNode)
6288
+ aliases = CalleeWriteCensus.handed_aliases(context.census, context.seeds, node, depth,
6289
+ fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS)
6290
+ aliases.each do |aliased|
6291
+ next if aliased == name
6292
+
6293
+ if slots.key?(aliased) then yield aliased
6294
+ elsif (rest = context.rests[aliased]) then yield rest
6295
+ end
6296
+ end
6297
+ end
3985
6298
 
3986
- name = ScopeIndexer.refined_class_name(target, scope)
3987
- return nil if name.nil?
6299
+ # The keyword-rest key a `**kw` double splat of the def's own named keyword rest hands on whole, or nil.
6300
+ def splatted_keyword_rest(pair, depth, context)
6301
+ read = pair.value
6302
+ return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
3988
6303
 
3989
- [ClassFrame.new(name: name, singleton: false, refinement: true)]
6304
+ rest = context.rests[read.name]
6305
+ rest if rest == KEYWORD_REST_PARAMETER
3990
6306
  end
3991
6307
 
3992
- # The block calls whose body `return` leaves only the block ({ReturnBarrier.block_call?}). Like a `->` body
3993
- # ({#eval_lambda}), each runs with the enclosing method's return sink suspended.
3994
- def return_barrier_block?(node)
3995
- ReturnBarrier.block_call?(node)
6308
+ # The rest key a `*args` splat of the def's own named rest hands on whole (`poke(*args)`), or nil.
6309
+ def splatted_rest(argument, depth, context)
6310
+ read = argument.is_a?(Prism::SplatNode) ? argument.expression : nil
6311
+ return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
6312
+
6313
+ rest = context.rests[read.name]
6314
+ rest if rest == REST_PARAMETER
3996
6315
  end
3997
6316
 
3998
- # Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
3999
- def without_return_sink
4000
- outer_sink = Thread.current[RETURN_SINK_KEY]
4001
- Thread.current[RETURN_SINK_KEY] = nil
4002
- begin
4003
- yield
4004
- ensure
4005
- Thread.current[RETURN_SINK_KEY] = outer_sink
4006
- end
6317
+ # What {#forwarded_parameters} reads a def's hand-offs with: its write census, its census seeds, its named
6318
+ # rests and its parameter rebinds ({#parameter_rebinds}).
6319
+ HandOffContext = Data.define(:census, :seeds, :rests, :rebinds)
6320
+ NO_HAND_OFF_CONTEXT = HandOffContext.new(census: CalleeWriteCensus::EMPTY, seeds: {}.freeze, rests: {}.freeze,
6321
+ rebinds: {}.freeze)
6322
+ private_constant :HandOffContext, :NO_HAND_OFF_CONTEXT
6323
+
6324
+ # The slots a hand-off's forwarded name maps to: each content parameter's, and {ANY_SLOT} / {ANY_KEYWORD} for the
6325
+ # rest keys an element of a named rest or keyword rest hands on under ({#handed_names}).
6326
+ def forwarding_slots(def_node)
6327
+ slots = content_parameter_slots(def_node)
6328
+ rest_parameter_names(def_node).each_value { |key| slots[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD }
6329
+ slots
4007
6330
  end
4008
6331
 
4009
- # Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
4010
- # the block with `self` bound to the receiving instance. Without this the block inherits the class body's
4011
- # `Singleton[C]`, and #618's own-method veto asks the singleton side of a name the instance side answers.
4012
- # {DefineMethodBlockSelf} owns the match; a non-match leaves the entry scope exactly as it was.
4013
- #
4014
- # The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
4015
- # method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
4016
- # still carries the singleton frame although its `self` is the class object, and it is an instance method
4017
- # the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
4018
- # same exclusion, which has no frame stack of its own.
4019
- def narrow_define_method_block_self(call_node, block_entry)
4020
- narrowed = DefineMethodBlockSelf.narrow_self_type_for(
4021
- scope: scope, call_node: call_node
4022
- )
4023
- return block_entry unless narrowed
6332
+ # The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
6333
+ # `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
6334
+ def parameter_read_name(node, slots, depth)
6335
+ node = node.value if node.is_a?(Prism::ImplicitNode)
6336
+ return unless node.is_a?(Prism::LocalVariableReadNode) && node.depth == depth
4024
6337
 
4025
- block_entry.with_block_self_type(narrowed, keeps_unknown: scope.block_self_narrowing_unknown?(call_node))
6338
+ node.name if slots.key?(node.name)
4026
6339
  end
4027
6340
 
4028
- # Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
4029
- # singleton, so a `def` inside binds an instance method on it through the ordinary
4030
- # {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
4031
- # positions such a block is reached from — a statement-level call ({#evaluate_block_if_present}) and a
4032
- # constant-write rvalue ({#eval_constant_write}) — so the two cannot drift on what a class body's entry is.
6341
+ # The `[def, offset]` pairs a self-dispatch `node` resolves to in the enclosing class, `offset` being how many
6342
+ # leading arguments of `node` are not the def's. Issue #2521 — the instance def is looked up along the class's
6343
+ # ancestors ({Scope#user_def_through_ancestors}), as Ruby dispatches the call: an inherited def is now walked
6344
+ # under the receiver's class, so a self-call inside it to another inherited def must still be found there.
4033
6345
  #
4034
- # Issue #652 — the body keeps this evaluator's `Module.nesting`. A BLOCK never pushes a cref, however
4035
- # much `class_eval` semantics make it behave like a class body in every other respect, so
4036
- # `K = Class.new do … end` written inside `module Outer` reads `Outer::LABEL` at runtime and MUST NOT
4037
- # record a `Outer::K` entry: that is a rung Ruby's constant lookup does not have. The `class_context`
4038
- # frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
4039
- # divergence that makes the chain a separate record rather than a view of the frame stack.
6346
+ # Issue #2297 — in a singleton-side body (`def self.x`, a `def` in `class << self`), whose `self` is the class
6347
+ # object, a self-call resolves to the singleton def of that name AND to the same-named instance def, so the floor
6348
+ # applies when either mutates. Ruby calls only the singleton side, but the hand-offs that reach an instance def
6349
+ # from there (`new.call(acc)`, `instance.helper(acc)`) and the singleton defs the index does not record
6350
+ # (`module_function :poke`, `define_singleton_method`, `alias_method` in `class << self`) are not followed, and
6351
+ # dropping the instance def floored too little on correct code
6352
+ # (`approval-queue.d/callee-floor-singleton-side.md`).
6353
+ # An instance-side body resolves to instance defs only.
4040
6354
  #
4041
- # Issue #1759 — a `refine X do … end` block runs on the refinement module, not on X, which Rigor does not
4042
- # model: the body keeps X's frame and singleton so its `def`s and implicit-self calls resolve as before, but
4043
- # carries the unknown-`self` mark ({Scope#block_self_unknown?}), so an explicit `self.import_methods(M)` is
4044
- # not checked against `singleton(X)`. The indexer leaves a value-position refine block unknown the same way.
4045
- def enter_meta_class_body(block, block_entry, class_context)
4046
- # Issue #1717 — the body runs on the new class whatever encloses it, so its `self` is known.
4047
- entry = block_entry.with_block_self_type(self_type_for_class_body(class_context),
4048
- keeps_unknown: class_context.last.refinement &&
4049
- Scope::REFINEMENT_BLOCK_SELF)
4050
- .with_singleton_class_body(false)
4051
- sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
6355
+ # A `send(:name, ...)` / `public_send(:name, ...)` with a symbol literal resolves as the self-call `name(...)`,
6356
+ # offset 1, on each side that has no def of the `send` / `public_send` name itself; on a side that has one, it is
6357
+ # an ordinary self-call to that def, offset 0.
6358
+ def resolve_self_callees(node)
6359
+ self_type = scope.self_type
6360
+ class_name = enclosing_class_name_for(self_type)
6361
+ return top_level_callees(node, nil) if class_name.nil?
6362
+
6363
+ instance = ancestor_side_callee(node, class_name) do |name|
6364
+ scope.user_def_through_ancestors(class_name, name)
6365
+ end
6366
+ side = self_type.is_a?(Type::Singleton) ? :singleton : :instance
6367
+ singleton = inherited_singleton_callee(node, self_type) if side == :singleton
6368
+ return unanswered_self_callees(node, class_name, side) unless instance || singleton
6369
+ return [instance] if singleton.nil?
6370
+ return [singleton] if instance.nil?
6371
+ return [instance] if singleton.first.equal?(instance.first)
6372
+
6373
+ [singleton, instance]
6374
+ end
6375
+
6376
+ # Issue #2515 — the `[def, offset, owner]` a receiverless call resolves to in the project's top-level defs: a
6377
+ # top-level def is a private method on `Object`, so a call outside any class body, and a call in a class whose
6378
+ # chain does not answer the name, reaches it. `class_name` is nil outside a class (the callee's own hand-offs
6379
+ # resolve in this evaluator, owner nil), else the class and side the chain was searched on. The def is read as
6380
+ # written only when nothing else can answer it ({ToplevelDefCensus.reliable?}, and for a class the chain
6381
+ # {#chain_may_answer?}); otherwise it reads as {FLOOR_EVERY}, every argument written. Nothing when no top-level
6382
+ # def has the name.
6383
+ def top_level_callees(node, class_name, side = nil)
6384
+ target = nil
6385
+ found = side_callee(node) do |name|
6386
+ def_node = scope.top_level_def_for(name)
6387
+ target = name unless def_node.nil?
6388
+ def_node
6389
+ end
6390
+ return NO_CALLEES if found.nil?
6391
+
6392
+ def_node, offset = found
6393
+ floor = !ToplevelDefCensus.reliable?(scope, target) ||
6394
+ (!class_name.nil? && chain_may_answer?(class_name, side, target))
6395
+ [[def_node, offset, if floor
6396
+ FLOOR_EVERY
6397
+ else
6398
+ (class_name.nil? ? nil : TOPLEVEL_OWNER)
6399
+ end]]
6400
+ end
6401
+
6402
+ # A self-call the class's own chain found no def for: the chain's `method_missing` answers it, else the project's
6403
+ # top-level def of that name (Ruby searches `Object` before `method_missing`, so with both the top-level def runs
6404
+ # and the `method_missing` reading is the over-approximation; both are returned and the top-level one floors
6405
+ # every argument).
6406
+ #
6407
+ # Issue #2336 — a name nothing above answers floors every argument when a `define_method` /
6408
+ # `define_singleton_method` / `alias_method` call gives it a body ({#self_ownerless_body?}).
6409
+ def unanswered_self_callees(node, class_name, side)
6410
+ missing = self_method_missing_callees(node, class_name, side)
6411
+ top = top_level_callees(node, class_name, side)
6412
+ if missing.empty? && top.empty?
6413
+ name = send_hand_off?(node) ? send_target(node) : node.name
6414
+ return self_ownerless_body?(name, class_name, side) ? OPAQUE_CALLEES : NO_CALLEES
6415
+ end
6416
+ return top if missing.empty?
6417
+ return missing if top.empty?
6418
+
6419
+ missing + top.map { |def_node, offset, _owner| [def_node, offset, FLOOR_EVERY] }
4052
6420
  end
4053
6421
 
4054
- # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
4055
- # non-escaping:
4056
- #
4057
- # - 3b: attach a `dynamic_origin` `closure_escape` fact to the
4058
- # post-call scope so consumers can see that the closure may
4059
- # have been retained past the call.
4060
- # - 3c: drop the narrowed type of every captured outer local
4061
- # that the block body can rebind, replacing it with
4062
- # `Dynamic[Top]` through `Scope#with_local` (which also
4063
- # invalidates the local's `local_binding` facts). Locals
4064
- # shadowed by a block parameter or a `;`-prefixed
4065
- # block-local declaration are untouched. Locals the block
4066
- # only reads (without writing) are also untouched: read-only
4067
- # captures cannot rebind the outer variable.
4068
- #
4069
- # A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
4070
- def record_closure_escape_if_any(node)
4071
- # ADR-57 slice 3 work-item 1: an escaping block can also be attached to a RECEIVER call in a chain rather than
4072
- # to `node` itself — the canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end .parse!(argv)` idiom,
4073
- # where the content-mutating block hangs off `OptionParser.new` but the statement-level call node is the chained
4074
- # `.parse!`. A receiver call is evaluated as an expression, never as a statement, so its block never reaches
4075
- # this escape handler on its own. Fold each escaping receiver-chain block's content widening into the
4076
- # continuation here so the captured collection is floored regardless of how deep in the receiver chain its
4077
- # mutating block lives.
4078
- post_scope = widen_escaping_receiver_chain_captures(node, scope)
6422
+ # True when something besides `Object` may answer a receiverless call of `name` in `class_name`'s `side`: the
6423
+ # class is no project class, a definer other than a `def` sits on its chain (`attr_*`, `define_method`, a budget
6424
+ # cut), or an ancestor the project does not declare is unknown to RBS or declares the name there (a
6425
+ # `< ActiveRecord::Base` the environment has no RBS for may define anything). The project's own `def`s were
6426
+ # searched already ({#resolve_self_callees}).
6427
+ def chain_may_answer?(class_name, side, name)
6428
+ return true unless project_class?(class_name)
6429
+ return true if scope.discovered_method_through_ancestors?(class_name, name, side)
6430
+ return true if rbs_declares?(class_name, name) || (side == :singleton && rbs_declares?("Class", name))
4079
6431
 
4080
- return post_scope unless node.block.is_a?(Prism::BlockNode)
6432
+ external = scope.external_ancestor_name_candidates(class_name)
6433
+ external += scope.singleton_extends_of(class_name).map { |module_name| [module_name] } if side == :singleton
6434
+ external.any? do |candidates|
6435
+ known = candidates.find { |candidate| Reflection.rbs_class_known?(candidate, scope: scope) }
6436
+ known.nil? ? candidates.none? { |candidate| project_class?(candidate) } : rbs_declares?(known, name)
6437
+ end
6438
+ end
4081
6439
 
4082
- classification = classify_closure_escape(node)
4083
- return post_scope if classification == :non_escaping
6440
+ # A class or module the project declares, even one with no method of its own.
6441
+ def project_class?(name) = scope.known_user_class?(name) || scope.declared_class?(name)
4084
6442
 
4085
- post_scope = escaping_closure_captures(node.block, post_scope, foreign_self: foreign_self_block?(node))
4086
- post_scope.with_fact(
4087
- Analysis::FactStore::Fact.new(
4088
- bucket: :dynamic_origin,
4089
- target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
4090
- predicate: :closure_escape,
4091
- payload: { method_name: node.name.to_sym, classification: classification },
4092
- stability: :unstable
4093
- )
4094
- )
6443
+ def rbs_declares?(class_name, name)
6444
+ !Reflection.instance_method_definition(class_name, name, scope: scope).nil? ||
6445
+ !Reflection.singleton_method_definition(class_name, name, scope: scope).nil?
4095
6446
  end
4096
6447
 
4097
- # Floor each caller-argument local whose matching parameter the resolved callee escape-mutates (see the call-site
4098
- # comment). Only self-dispatch calls resolving to a discovered user def are considered; the per-def "which
4099
- # parameters escape-mutate" set is memoised on the def node.
4100
- def widen_callee_escaped_argument_captures(node, base_scope)
4101
- # Apply to the statement call AND every call in its receiver chain: the
4102
- # `build_option_parser(options).parse!(argv)` idiom puts the escape- mutating helper call in the RECEIVER
4103
- # position, where its argument is never the statement node's own argument.
4104
- acc = floor_callee_escaped_args_for_call(node, base_scope)
4105
- receiver = node.receiver
4106
- while receiver.is_a?(Prism::CallNode)
4107
- acc = floor_callee_escaped_args_for_call(receiver, acc)
4108
- receiver = receiver.receiver
6448
+ # The `[def, offset, owner]` a singleton-side self-call resolves to along the singleton chain: owner nil for a
6449
+ # def of the class itself, {FLOOR_ALL} for one it inherits ({#walked_owner}).
6450
+ def inherited_singleton_callee(node, self_type)
6451
+ class_name = self_type.class_name
6452
+ ancestor_side_callee(node, class_name) do |name|
6453
+ scope.singleton_def_through_ancestors(class_name, name)
4109
6454
  end
4110
- acc
4111
6455
  end
4112
6456
 
4113
- # Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
4114
- # before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
4115
- def floor_callee_escaped_args_for_call(node, base_scope)
4116
- arguments = content_mutated_arguments(node)
4117
- return base_scope if arguments.empty?
4118
-
4119
- arguments.reduce(base_scope) do |acc, argument|
4120
- next acc unless acc.locals.key?(argument.name)
4121
-
4122
- floored = content_floor_for(acc.local(argument.name))
4123
- floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
6457
+ # The `[def, offset, owner]` `node` resolves to through a lookup on `class_name` that answers `[def, defining]`
6458
+ # (the block), owner nil, or {FLOOR_ALL} ({#walked_owner}); nil when nothing answers.
6459
+ def ancestor_side_callee(node, class_name)
6460
+ defining = nil
6461
+ found = side_callee(node) do |name|
6462
+ def_node, owner = yield(name)
6463
+ defining = owner unless def_node.nil?
6464
+ def_node
4124
6465
  end
6466
+ found && [*found, walked_owner(defining, class_name, nil)]
4125
6467
  end
4126
6468
 
4127
- # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
4128
- # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
4129
- # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
4130
- # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
4131
- def callee_content_mutated_parameters(def_node)
4132
- cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
4133
- cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
6469
+ # {#method_missing_callees} for a self-call `node` that resolves to nothing; a `send(:name, ...)` hand-off
6470
+ # passes the name as `method_missing`'s own first argument.
6471
+ def self_method_missing_callees(node, class_name, side)
6472
+ if send_hand_off?(node)
6473
+ method_missing_callees(class_name, side, node.arguments.arguments.first.unescaped.to_sym, 1)
6474
+ else
6475
+ method_missing_callees(class_name, side, node.name, 0)
6476
+ end
4134
6477
  end
4135
6478
 
4136
- # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
4137
- # `Scope#user_def_for` reads; no ancestor walk (the boundary-escape idiom is same-class), keeping this off the hot
4138
- # path for the overwhelming majority of self-calls that resolve to nothing escaping.
4139
- def resolve_self_callee_def(node)
4140
- class_name = enclosing_class_name_for(scope.self_type)
4141
- return scope.top_level_def_for(node.name) if class_name.nil?
6479
+ # The `[def, offset]` `node` resolves to on one side, whose def table the block looks a name up in, or nil.
6480
+ def side_callee(node)
6481
+ if send_hand_off?(node)
6482
+ own = yield(node.name)
6483
+ return [own, 0] if own
6484
+
6485
+ target = yield(node.arguments.arguments.first.unescaped.to_sym)
6486
+ return target && [target, 1]
6487
+ end
6488
+
6489
+ found = yield(node.name)
6490
+ found && [found, 0]
6491
+ end
4142
6492
 
4143
- scope.user_def_for(class_name, node.name)
6493
+ # True for a `send` / `public_send` / `__send__` whose first argument is a literal target name: a symbol, or
6494
+ # (issue #2336) a string (`send("poke", h)`).
6495
+ def send_hand_off?(node)
6496
+ SEND_HAND_OFFS.include?(node.name) && CalleeWriteCensus.literal_send?(node)
4144
6497
  end
4145
6498
 
6499
+ SEND_HAND_OFFS = %i[send public_send __send__].freeze
6500
+ NO_CALLEES = [].freeze
6501
+ private_constant :SEND_HAND_OFFS, :NO_CALLEES
6502
+
4146
6503
  def self_dispatch_call?(node)
4147
6504
  return false unless node.is_a?(Prism::CallNode)
4148
6505
 
4149
6506
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
4150
6507
  end
4151
6508
 
4152
- # The set of `[name, position]` parameters of `def_node` whose content a block in the body escape-mutates.
4153
- # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
6509
+ # The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
6510
+ # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-mutated"
4154
6511
  # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
4155
6512
  # non-escaping.
4156
6513
  def escaped_content_parameters(def_node)
@@ -4159,7 +6516,7 @@ module Rigor
4159
6516
  end
4160
6517
 
4161
6518
  def compute_escaped_content_parameters(def_node)
4162
- positions = positional_parameter_positions(def_node)
6519
+ positions = content_parameter_slots(def_node)
4163
6520
  return {} if positions.empty?
4164
6521
 
4165
6522
  mutated = Set.new
@@ -4167,35 +6524,140 @@ module Rigor
4167
6524
  next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
4168
6525
  next if syntactically_non_escaping_call?(descendant)
4169
6526
 
4170
- collect_content_mutations(descendant.block.body).each_key do |name|
4171
- mutated << name if positions.key?(name)
6527
+ collect_content_mutations(descendant.block.body).each do |name, nodes|
6528
+ next unless positions.key?(name)
6529
+
6530
+ mutated << name if nodes.any? { |node| !after_rebind?(def_node, name, node) }
4172
6531
  end
4173
6532
  end
4174
6533
  positions.slice(*mutated)
4175
6534
  end
4176
6535
 
4177
- # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
4178
- # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
4179
- # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
6536
+ # The `{ name => slot }` parameters whose CONTENT the callee mutates directly in its method body — a top-level
6537
+ # `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case is
6538
+ # `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
4180
6539
  # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
4181
6540
  # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
6541
+ #
6542
+ # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted here: the bare-collection
6543
+ # floor this census applies dropped the element type (af3efef33, and again under #2104). Issue #2296 counts it in
6544
+ # {#own_block_content_parameters}, whose floor keeps the element type.
4182
6545
  def direct_content_parameters(def_node)
4183
6546
  cache = (@direct_param_cache ||= {}.compare_by_identity)
4184
6547
  cache[def_node] ||= compute_direct_content_parameters(def_node)
4185
6548
  end
4186
6549
 
4187
6550
  def compute_direct_content_parameters(def_node)
4188
- positions = positional_parameter_positions(def_node)
6551
+ positions = content_parameter_slots(def_node)
4189
6552
  return {} if positions.empty?
4190
6553
 
4191
6554
  mutated = Set.new
4192
6555
  each_node_outside_nested_scopes(def_node.body) do |descendant|
4193
6556
  name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
4194
- mutated << name if !name.nil? && positions.key?(name)
6557
+ mutated << name if !name.nil? && positions.key?(name) && !after_rebind?(def_node, name, descendant)
4195
6558
  end
4196
6559
  positions.slice(*mutated)
4197
6560
  end
4198
6561
 
6562
+ # Issue #2296 — true when `node` runs after the def has rebound its parameter `name` to a fresh object
6563
+ # (`acc = acc.dup` as a statement of the method body, ahead of `node`): from there `name` holds another object,
6564
+ # so a mutation of it does not reach the caller's argument. Only an unconditional rebind counts, a write that is
6565
+ # itself a statement of the body, not one under a branch, a loop, a block or `||=`. Its value MUST provably be a
6566
+ # new object ({#fresh_value?}): `acc = acc.to_a` or `s = s.to_s` hands back the same object, so a mutation after
6567
+ # it still reaches the caller. A def whose body has a `rescue` or `ensure` clause never rebinds, since the clause
6568
+ # runs with whichever binding the raise left. And only a node that starts after the write ends counts, so
6569
+ # `acc = poke(acc)` still passes the original.
6570
+ def after_rebind?(def_node, name, node)
6571
+ offset = parameter_rebinds(def_node)[name]
6572
+ !offset.nil? && node.location.start_offset >= offset
6573
+ end
6574
+
6575
+ # `{ name => end_offset }` of the first unconditional fresh rebind of each parameter ({#after_rebind?}),
6576
+ # memoised per def node for the thread.
6577
+ def parameter_rebinds(def_node)
6578
+ def_census(:rebinds, def_node) do
6579
+ slots = content_parameter_slots(def_node)
6580
+ rebinds = nil
6581
+ body_statements(def_node.body).each do |statement|
6582
+ next unless fresh_rebind?(statement, slots)
6583
+
6584
+ (rebinds ||= {})[statement.name] ||= statement.location.end_offset
6585
+ end
6586
+ rebinds || NO_PARAMETER_SLOTS
6587
+ end
6588
+ end
6589
+
6590
+ def fresh_rebind?(statement, slots)
6591
+ case statement
6592
+ when Prism::LocalVariableWriteNode
6593
+ statement.depth.zero? && slots.key?(statement.name) && fresh_value?(statement.value, statement.name)
6594
+ when Prism::LocalVariableOperatorWriteNode
6595
+ statement.depth.zero? && slots.key?(statement.name) &&
6596
+ FRESH_OPERATORS.include?(statement.binary_operator)
6597
+ else false
6598
+ end
6599
+ end
6600
+
6601
+ # A value that is always a new object: a `dup` / `clone`, an Array / Hash / String literal, `new` on a core
6602
+ # collection class ({#core_collection_constant?}), or one of {FRESH_COPIERS} called on the parameter itself. Any
6603
+ # other call may return its receiver.
6604
+ def fresh_value?(value, name)
6605
+ case value
6606
+ when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode then true
6607
+ when Prism::CallNode then fresh_call?(value, name)
6608
+ else false
6609
+ end
6610
+ end
6611
+
6612
+ def fresh_call?(call, name)
6613
+ return true if FRESH_DUPLICATORS.include?(call.name)
6614
+ return core_collection_constant?(call.receiver) if call.name == :new
6615
+
6616
+ receiver = call.receiver
6617
+ FRESH_COPIERS.include?(call.name) && receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
6618
+ end
6619
+
6620
+ # `::Array` / `::Hash` / `::String` / `::Set`, written `::`-rooted: the core classes whose `new` always builds a
6621
+ # new object. A project class's `new` may hand back its argument (`Ident.new(acc)`) or wrap it (`Wrap.new(acc)`,
6622
+ # whose `[]=` writes through), so it is no fresh rebind. The census reads the AST alone and cannot resolve a bare
6623
+ # `Array`, which a namespace may shadow (`M::Array`), so only the rooted spelling counts.
6624
+ def core_collection_constant?(node)
6625
+ node.is_a?(Prism::ConstantPathNode) && node.parent.nil? && FRESH_CONSTRUCTED.include?(node.name)
6626
+ end
6627
+
6628
+ FRESH_DUPLICATORS = %i[dup clone].to_set.freeze
6629
+ FRESH_CONSTRUCTED = %i[Array Hash String Set].to_set.freeze
6630
+ FRESH_COPIERS = %i[map collect select reject merge + -].to_set.freeze
6631
+ FRESH_OPERATORS = %i[+ -].to_set.freeze
6632
+ # The calls whose result {#callee_write_census} reads as a new object rather than an alias of the receiver.
6633
+ # The calls {#callee_write_census} reads as a new container of the receiver's elements, on an Array, a Hash and a
6634
+ # String alike: a write to the result misses the receiver, an element of it may be a part. `to_a`, `to_h`
6635
+ # (without a block; `to_h { }` is read as one in {CalleeWriteCensus}), `to_s`, `each_slice`, `each_cons` and
6636
+ # `each_with_index` stay out: each returns its receiver on one of them.
6637
+ CENSUS_COPIERS = %i[
6638
+ slice except compact transform_values transform_keys filter_map group_by partition sort sort_by uniq flatten
6639
+ reverse keys values inspect zip filter flat_map collect_concat take drop
6640
+ ].to_set.freeze
6641
+ # The calls whose result {#callee_write_census} reads as a new object holding nothing of the receiver's.
6642
+ FRESH_RESULTS = (FRESH_DUPLICATORS | FRESH_COPIERS | %i[join]).freeze
6643
+ private_constant :FRESH_DUPLICATORS, :FRESH_CONSTRUCTED, :FRESH_COPIERS, :FRESH_OPERATORS, :CENSUS_COPIERS,
6644
+ :FRESH_RESULTS
6645
+
6646
+ # The statements a def body runs in order: a statements list, with a parenthesised list (an endless def's
6647
+ # `= (a; b)`) read in place, or the lone expression. A body with a `rescue` / `ensure` clause (a `BeginNode`)
6648
+ # answers none, so it never rebinds.
6649
+ def body_statements(body)
6650
+ case body
6651
+ when nil, Prism::BeginNode then NO_FORWARDS
6652
+ when Prism::StatementsNode then body.body.flat_map { |statement| body_statements_of(statement) }
6653
+ else [body]
6654
+ end
6655
+ end
6656
+
6657
+ def body_statements_of(statement)
6658
+ statement.is_a?(Prism::ParenthesesNode) ? body_statements(statement.body) : [statement]
6659
+ end
6660
+
4199
6661
  # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
4200
6662
  # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
4201
6663
  # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
@@ -4210,8 +6672,45 @@ module Rigor
4210
6672
  end
4211
6673
  end
4212
6674
 
6675
+ # Yields every node reachable from `node` with the number of blocks it sits in: a read of the method's own local
6676
+ # there has that depth. A nested def or lambda is not entered: a def has no access to the method's locals, and a
6677
+ # lambda body is not run by the call. Nor is the block of a {#self_rebinding_block_call?}: a self-call there
6678
+ # either runs on another `self`, or runs later as a method body, so the walk leaves it out.
6679
+ def each_node_in_method_scope(node, depth, &)
6680
+ return if node.nil?
6681
+
6682
+ yield node, depth
6683
+ rebinding = self_rebinding_block_call?(node)
6684
+ node.rigor_each_child do |child|
6685
+ next if child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
6686
+
6687
+ if child.is_a?(Prism::BlockNode)
6688
+ each_node_in_method_scope(child, depth + 1, &) unless rebinding
6689
+ else
6690
+ each_node_in_method_scope(child, depth, &)
6691
+ end
6692
+ end
6693
+ end
6694
+
6695
+ # The calls whose block the hand-off walk leaves out. In an `instance_eval` / `class_exec` / … block, and in a
6696
+ # `Class.new` / `Module.new` / `Struct.new` / `Data.define` block ({AnonymousMetaClass.block_form_receiver}),
6697
+ # `self` is another object, so a self-call there is not a call on the method's receiver. A `define_method` or
6698
+ # `define_singleton_method` block keeps an instance of the class as `self`, but it is a method body that runs
6699
+ # later, if ever, so leaving it out is the conservative reading: a hand-off it holds is not one the call makes.
6700
+ SELF_REBINDING_BLOCK_CALLS = Set[
6701
+ :instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
6702
+ :define_singleton_method
6703
+ ].freeze
6704
+ private_constant :SELF_REBINDING_BLOCK_CALLS
6705
+
6706
+ def self_rebinding_block_call?(node)
6707
+ return false unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
6708
+
6709
+ SELF_REBINDING_BLOCK_CALLS.include?(node.name) || !AnonymousMetaClass.block_form_receiver(node).nil?
6710
+ end
6711
+
4213
6712
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
4214
- # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
6713
+ # a callee body where the block-owning call's receiver TYPE is not available. A call whose method name is a known
4215
6714
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
4216
6715
  # captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
4217
6716
  # unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
@@ -4230,18 +6729,20 @@ module Rigor
4230
6729
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
4231
6730
  end
4232
6731
 
4233
- # `{ name => position }` for the required / optional positional parameters of a def. Keyword / rest / block
4234
- # parameters are skipped — the boundary-escape idiom passes a plain positional collection.
4235
- def positional_parameter_positions(def_node)
6732
+ # `{ name => slot }` for the parameters a caller's argument binds by name or position: the required / optional
6733
+ # positional ones (slot: their index) and, issue #2104, the keyword ones (slot: the keyword symbol). Rest, post
6734
+ # and block parameters are skipped.
6735
+ def content_parameter_slots(def_node)
4236
6736
  params = def_node.parameters
4237
6737
  return {} if params.nil?
4238
6738
 
4239
6739
  ordered = (params.requireds || []) + (params.optionals || [])
4240
- positions = {}
6740
+ slots = {}
4241
6741
  ordered.each_with_index do |param, index|
4242
- positions[param.name] = index if param.respond_to?(:name)
6742
+ slots[param.name] = index if param.respond_to?(:name)
4243
6743
  end
4244
- positions
6744
+ (params.keywords || []).each { |param| slots[param.name] = param.name }
6745
+ slots
4245
6746
  end
4246
6747
 
4247
6748
  # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
@@ -4255,11 +6756,14 @@ module Rigor
4255
6756
  case arg
4256
6757
  when Prism::LocalVariableReadNode
4257
6758
  scope.locals.key?(arg.name)
6759
+ when Prism::SplatNode
6760
+ arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
4258
6761
  when Prism::KeywordHashNode
4259
6762
  arg.elements.any? do |pair|
4260
- pair.is_a?(Prism::AssocNode) &&
4261
- pair.value.is_a?(Prism::LocalVariableReadNode) &&
4262
- scope.locals.key?(pair.value.name)
6763
+ next false unless pair.is_a?(Prism::AssocNode)
6764
+
6765
+ value = pair.value.is_a?(Prism::ImplicitNode) ? pair.value.value : pair.value
6766
+ value.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(value.name)
4263
6767
  end
4264
6768
  else
4265
6769
  false
@@ -4349,6 +6853,18 @@ module Rigor
4349
6853
  Type::Combinator.untyped])
4350
6854
  elsif arrayish?(type)
4351
6855
  Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
6856
+ elsif setish?(type)
6857
+ Type::Combinator.nominal_of("Set", type_args: [Type::Combinator.untyped])
6858
+ end
6859
+ end
6860
+
6861
+ # A `Set` carrier, constant-folded or nominal, for the callee floor above (issue #2294).
6862
+ def setish?(type)
6863
+ case type
6864
+ when Type::Constant then SetMutation.constant?(type)
6865
+ when Type::Nominal then type.class_name == "Set"
6866
+ when Type::Union then type.members.any? { |m| setish?(m) }
6867
+ else false
4352
6868
  end
4353
6869
  end
4354
6870
 
@@ -4417,6 +6933,11 @@ module Rigor
4417
6933
  # The fresh `next`, `break`-value and `break`-scope sinks are defensive: every consumer of those sinks filters by
4418
6934
  # jump-node identity, so no example tells them apart from the outer sinks today. So is the `rescue`: a raise here
4419
6935
  # keeps the arms the earlier passes collected rather than taking out the method.
6936
+ #
6937
+ # Issue #2127 — a block passed to a project method whose body may run it more than once
6938
+ # ({ProjectYielder.may_run_block_repeatedly?}) gets the same pass. No write-back runs for such a call, so its
6939
+ # continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
6940
+ # so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
4420
6941
  def collect_repeated_block_returns(call_node, post_scope)
4421
6942
  return if Thread.current[RETURN_SINK_KEY].nil?
4422
6943
 
@@ -4440,22 +6961,25 @@ module Rigor
4440
6961
  end
4441
6962
 
4442
6963
  # Whether `block` is an ordinary block (not a return barrier or a class-body block) whose body may `return` from
4443
- # the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, or `Kernel#loop`,
4444
- # which that rule leaves out and which runs its block until it breaks or raises. The barrier, refinement and
6964
+ # the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, `Kernel#loop`, which
6965
+ # that rule leaves out and which runs its block until it breaks or raises, or a project method whose body may
6966
+ # run it more than once ({ProjectYielder.may_run_block_repeatedly?}, issue #2127). The barrier, refinement and
4445
6967
  # anonymous-class checks are defensive guards: no such call repeats under that rule today.
4446
6968
  def repeated_block_return_candidate?(call_node, block)
4447
6969
  return false unless block.is_a?(Prism::BlockNode)
4448
6970
  return false unless ReturnBarrier.exits_method?(block.body)
4449
6971
  return false if return_barrier_block?(call_node) || refined_class_context(call_node)
4450
6972
  return false if AnonymousMetaClass.name_for(call_node, scope.source_path)
6973
+ return true if BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
4451
6974
 
4452
- BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
6975
+ receiver_type = block_call_receiver_type(call_node)
6976
+ !receiver_type.nil? && ProjectYielder.may_run_block_repeatedly?(call_node, receiver_type, scope)
4453
6977
  end
4454
6978
 
4455
6979
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
4456
6980
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
4457
6981
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
4458
- # — `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
6982
+ # — `[].each { … }` — stays sound), value-pinned widened on the final permitted iteration, and floored to
4459
6983
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
4460
6984
  #
4461
6985
  # The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
@@ -5075,7 +7599,7 @@ module Rigor
5075
7599
  end
5076
7600
 
5077
7601
  # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
5078
- # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured- local content
7602
+ # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured-local content
5079
7603
  # mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
5080
7604
  # memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
5081
7605
  # is not a collection.
@@ -5094,28 +7618,239 @@ module Rigor
5094
7618
  body = block.body
5095
7619
  return call_type if body.nil?
5096
7620
 
7621
+ memo_after_block(call_node, call_type, memo_param, body)
7622
+ end
7623
+
7624
+ # {#each_with_object_return}'s answer once the call is known to bind a memo parameter in a block with a body.
7625
+ def memo_after_block(call_node, call_type, memo_param, body)
7626
+ memo_arg = call_node.arguments.arguments.first
7627
+ memo_type = scope.type_of(memo_arg, tracer: tracer)
7628
+ pre_state = lookup_mutated_seed(body, memo_param, memo_type, MEMO_SEED_MUTATORS) do |depth, nesting|
7629
+ depth == nesting
7630
+ end
7631
+ reach = memo_scan_reach(body, memo_param)
7632
+ return content_floor_for(pre_state) || call_type if reach == :escape
7633
+
5097
7634
  # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
5098
7635
  # captured-local walk.
5099
7636
  shadows = {}.compare_by_identity
5100
7637
  calls = body_content_mutations_on(body, memo_param, shadows)
5101
- return call_type if calls.empty?
7638
+ memo = if calls.empty?
7639
+ # Issue #2411 — a memo the block only reshapes (`m.merge!(s)`, `m.delete(k)`) answers its widened seed.
7640
+ pre_state.equal?(memo_type) ? call_type : pre_state
7641
+ else
7642
+ join_memo_content(call_node, memo_param, calls, pre_state, shadows) || call_type
7643
+ end
7644
+ memo = floor_memo_callee_escapes(body, memo_param, memo)
7645
+ reach == :values ? memo_value_floor(memo) : memo
7646
+ end
7647
+
7648
+ # Issue #2411 — `memo` with its values or elements floored to `Dynamic[top]` and a Hash's keys kept: a reader's
7649
+ # value is an element the block may then mutate where no scan sees it (`h[x] << "s"`, `v = h[:k]; v << "s"`,
7650
+ # `h[:k][:n] = "s"`), so the join's element types are not what the memo holds. A memo whose every value or
7651
+ # element is of a class no method mutates ({MEMO_IMMUTABLE_CLASSES}) keeps its join: `m[k] = m[k] + 1` reads an
7652
+ # Integer, which nothing in the block can change.
7653
+ def memo_value_floor(type)
7654
+ return type if memo_contents_immutable?(type)
5102
7655
 
5103
- pre_state = lookup_mutated_seed(body, memo_param, scope.type_of(memo_arg, tracer: tracer)) do |depth, nesting|
5104
- depth == nesting
7656
+ case type
7657
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| memo_value_floor(member) })
7658
+ when Type::Nominal
7659
+ return content_floor_for(type) || type unless type.class_name == "Hash" && type.type_args.size == 2
7660
+
7661
+ Type::Combinator.nominal_of("Hash", type_args: [type.type_args.first, Type::Combinator.untyped])
7662
+ else content_floor_for(type) || type
5105
7663
  end
5106
- joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
5107
- joined || call_type
5108
7664
  end
5109
7665
 
7666
+ # The classes whose instances no method mutates.
7667
+ MEMO_IMMUTABLE_CLASSES = Set[
7668
+ "Integer", "Float", "Symbol", "NilClass", "TrueClass", "FalseClass", "Rational", "Complex"
7669
+ ].freeze
7670
+ private_constant :MEMO_IMMUTABLE_CLASSES
7671
+
7672
+ # Whether every value of a Hash memo, or every element of an Array memo, is of a {MEMO_IMMUTABLE_CLASSES} class.
7673
+ def memo_contents_immutable?(type)
7674
+ case type
7675
+ when Type::Union then type.members.all? { |member| memo_contents_immutable?(member) }
7676
+ when Type::Nominal
7677
+ slot = type.class_name == "Hash" ? 1 : 0
7678
+ %w[Hash Array].include?(type.class_name) && !type.type_args[slot].nil? &&
7679
+ memo_immutable_value?(type.type_args[slot])
7680
+ when Type::Tuple then type.elements.all? { |element| memo_immutable_value?(element) }
7681
+ when Type::HashShape then type.pairs.values.all? { |value| memo_immutable_value?(value) }
7682
+ else false
7683
+ end
7684
+ end
7685
+
7686
+ def memo_immutable_value?(type)
7687
+ case type
7688
+ when Type::Union then type.members.all? { |member| memo_immutable_value?(member) }
7689
+ when Type::Constant then MEMO_IMMUTABLE_CLASSES.include?(type.value.class.name)
7690
+ when Type::Nominal then MEMO_IMMUTABLE_CLASSES.include?(type.class_name)
7691
+ when Type::IntegerRange, Type::FloatRange then true
7692
+ else false
7693
+ end
7694
+ end
7695
+
7696
+ # Issue #2411 — whether the block can change the memo where neither the join nor the callee floor sees it, so
7697
+ # the memo answers its content floor ({#content_floor_for}) instead of the join. Read as an expression, the
7698
+ # join's answer reaches every use of the call, and one that misses a store reports on correct code:
7699
+ # `|x, h| g = h; g["s"] = "v"; h[:a] = 2` joined `Hash[:a, 2]`, and `.fetch("s").upcase` read `2`. Every read
7700
+ # or write of the memo's name in its own scope ({#scope_nesting}) must be one the scan models
7701
+ # ({#memo_read_modelled?}); a rebinding write (`h = {}`) is not, since the stores after it reach another object.
7702
+ # Answers `:escape` for such a read or write, `:values` when the only unmodelled reads are readers whose value
7703
+ # is used ({#memo_value_floor}), and nil when every read is modelled.
7704
+ def memo_scan_reach(body, memo_param)
7705
+ reach = nil
7706
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
7707
+ next unless MEMO_NAME_NODES.any? { |klass| node.is_a?(klass) } && node.name == memo_param
7708
+ next unless node.depth == scope_nesting(ancestors)
7709
+
7710
+ modelled = node.is_a?(Prism::LocalVariableReadNode) && memo_read_modelled?(node, ancestors)
7711
+ return :escape unless modelled
7712
+
7713
+ reach = :values if modelled == :values
7714
+ end
7715
+ reach
7716
+ end
7717
+
7718
+ # The nodes that read or bind a local by name. `LocalVariableTargetNode` covers a multi-assign and a `rescue =>`.
7719
+ MEMO_NAME_NODES = [
7720
+ Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode, Prism::LocalVariableTargetNode,
7721
+ Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
7722
+ ].freeze
7723
+ private_constant :MEMO_NAME_NODES
7724
+
7725
+ # The memo reads that cannot hand the object to anything the scan does not see. A call on the memo is one when
7726
+ # it takes no block (`tap`, `each` and `then` yield it) and the scan knows the method: one whose value never is
7727
+ # the memo ({MEMO_VALUE_READERS}), or a mutator the join or the seed widening reads ({CONTENT_MUTATORS},
7728
+ # {MEMO_SEED_MUTATORS}) whose value, the memo for `h << x` and `h.merge!(s)`, is discarded
7729
+ # ({#memo_value_discarded?}). Any other name is not: `h.send(:store, :t, "t")` stores what no scan sees. An index
7730
+ # compound write on it is one. So is an argument of a self-call that resolves to a user def, whose census the
7731
+ # callee floor reads ({#floor_memo_callee_escapes}), when the call's value is discarded: `keep(h)["s"] = "v"`
7732
+ # stores through the value. Any other read — `g = h`, `[h]`, `Helper.fill(h, x)`, an unresolved self-call — is
7733
+ # not.
7734
+ def memo_read_modelled?(read, ancestors)
7735
+ parent = ancestors.last
7736
+ if parent.respond_to?(:receiver) && parent.receiver.equal?(read)
7737
+ return true if INDEX_WRITE_NODES.any? { |klass| parent.is_a?(klass) }
7738
+ return false unless parent.is_a?(Prism::CallNode) && parent.block.nil?
7739
+
7740
+ return memo_receiver_call_modelled?(parent, ancestors[0...-1])
7741
+ end
7742
+ memo_self_call_argument?(ancestors)
7743
+ end
7744
+
7745
+ # True, false, or `:values` for a reader whose value is used: the value may be an element the block mutates.
7746
+ # A mutator given a splat or `**` argument is not modelled: the join reads each argument by position, and
7747
+ # `h.store(*kv)` stored a pair it never saw.
7748
+ def memo_receiver_call_modelled?(call, ancestors)
7749
+ mutator = CONTENT_MUTATORS.include?(call.name) || MEMO_SEED_MUTATORS.include?(call.name)
7750
+ return false if mutator && memo_splat_arguments?(call)
7751
+ return memo_value_discarded?(call, ancestors) || :values if MEMO_VALUE_READERS.include?(call.name)
7752
+ return false unless mutator
7753
+
7754
+ memo_value_discarded?(call, ancestors)
7755
+ end
7756
+
7757
+ def memo_splat_arguments?(call)
7758
+ (call.arguments&.arguments || []).any? do |argument|
7759
+ argument.is_a?(Prism::SplatNode) ||
7760
+ (argument.is_a?(Prism::KeywordHashNode) && argument.elements.any?(Prism::AssocSplatNode))
7761
+ end
7762
+ end
7763
+
7764
+ # Methods of Hash and Array whose value is never the receiver itself, so using it hands nothing on.
7765
+ MEMO_VALUE_READERS = Set[
7766
+ :[], :[]=, :store, :fetch, :dig, :key?, :has_key?, :include?, :member?, :key, :value?, :has_value?,
7767
+ :size, :length, :count, :empty?, :any?, :none?, :keys, :values, :values_at, :fetch_values, :first, :last,
7768
+ :delete, :pop, :shift, :to_s, :inspect, :hash, :==, :!=, :eql?, :equal?, :frozen?, :nil?, :is_a?,
7769
+ :kind_of?, :instance_of?, :respond_to?, :class, :join, :sum, :min, :max, :dup, :clone
7770
+ ].freeze
7771
+ private_constant :MEMO_VALUE_READERS
7772
+
7773
+ # Iterators whose block's value the call never reads.
7774
+ MEMO_BLOCK_VALUE_DROPPING = Set[
7775
+ :each, :each_with_index, :each_pair, :each_key, :each_value, :each_index, :reverse_each, :each_slice,
7776
+ :each_cons, :each_entry, :each_char, :each_line, :each_byte, :times, :upto, :downto, :step, :loop
7777
+ ].freeze
7778
+ private_constant :MEMO_BLOCK_VALUE_DROPPING
7779
+
7780
+ # Whether `node`'s value is dropped: a statement that is not its sequence's last, or the last one of a sequence
7781
+ # whose own value is dropped, up through `if` / `unless` arms, `begin` / `rescue` bodies and parentheses, to the
7782
+ # `each_with_object` block body, whose value the call never answers, or to the body of a nested block whose
7783
+ # call never reads its value ({MEMO_BLOCK_VALUE_DROPPING}: `xs.each { |y| acc << y }`). Any other block's value
7784
+ # may be the memo handed on (`map { acc << y }`).
7785
+ def memo_value_discarded?(node, ancestors)
7786
+ return true if ancestors.empty?
7787
+
7788
+ parent = ancestors.last
7789
+ up = ancestors[0...-1]
7790
+ case parent
7791
+ when Prism::StatementsNode
7792
+ return true unless parent.body.last.equal?(node)
7793
+
7794
+ block = up.last
7795
+ return memo_value_discarded?(parent, up) unless block.is_a?(Prism::BlockNode)
7796
+
7797
+ up[-2].is_a?(Prism::CallNode) && MEMO_BLOCK_VALUE_DROPPING.include?(up[-2].name)
7798
+ when Prism::IfNode, Prism::UnlessNode
7799
+ !parent.predicate.equal?(node) && memo_value_discarded?(parent, up)
7800
+ when Prism::ElseNode, Prism::BeginNode, Prism::RescueNode, Prism::ParenthesesNode
7801
+ memo_value_discarded?(parent, up)
7802
+ when Prism::EnsureNode then true
7803
+ else false
7804
+ end
7805
+ end
7806
+
7807
+ # Whether the read under `ancestors` is a positional or keyword argument of a self-call resolving to a user def.
7808
+ # Its value must be discarded: the callee may return the memo (`def keep(h) = h`) or wrap it.
7809
+ def memo_self_call_argument?(ancestors)
7810
+ depth = ancestors.size - 1
7811
+ depth -= 2 if ancestors[depth].is_a?(Prism::AssocNode) && ancestors[depth - 1].is_a?(Prism::KeywordHashNode)
7812
+ call = ancestors[depth - 1]
7813
+ return false unless ancestors[depth].is_a?(Prism::ArgumentsNode) && call.is_a?(Prism::CallNode)
7814
+ return false unless self_dispatch_call?(call) && memo_value_discarded?(call, ancestors[0...(depth - 1)])
7815
+
7816
+ !resolve_self_callees(call).empty?
7817
+ end
7818
+
7819
+ # Issue #2411 — `memo` floored as a captured local is when the block hands it to a self-call whose callee
7820
+ # mutates it ({#floor_callee_escaped_args_for_call}, the walk {#floor_block_body_callee_captures} makes): the
7821
+ # join reads only the stores in the block, so `|x, out| record(out, x); out[[x, :e]] = 1` joined
7822
+ # `Hash[[Integer, :e], 1]` while `record` stored other keys, and a memo the block only passed on kept its `{}`.
7823
+ # Every read of the memo's name counts, a nested block's parameter shadowing it included: the floor only forgets.
7824
+ # The walk runs on an evaluator whose scope binds the memo, since the floor reads only locals its scope binds.
7825
+ def floor_memo_callee_escapes(body, memo_param, memo)
7826
+ StatementEvaluator.new(scope: scope.with_local(memo_param, memo), tracer: tracer)
7827
+ .callee_floored_local(body, memo_param)
7828
+ end
7829
+
7830
+ protected
7831
+
7832
+ # `name` as the self-calls in `body` leave it, each flooring the arguments its callee mutates
7833
+ # ({#floor_memo_callee_escapes}).
7834
+ def callee_floored_local(body, name)
7835
+ floored = scope
7836
+ each_node_in_caller_block(body, 0) do |node, _depth|
7837
+ floored = floor_callee_escaped_args_for_call(node, floored)
7838
+ end
7839
+ floored.local(name)
7840
+ end
7841
+
7842
+ private
7843
+
5110
7844
  # `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
5111
7845
  # never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
5112
7846
  # values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
5113
7847
  # `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
5114
7848
  # block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
5115
- # `seed` describes. A `def` opens a scope of its own, so nothing under one is.
5116
- def lookup_mutated_seed(body, name, seed)
7849
+ # `seed` describes. A `def` opens a scope of its own, so nothing under one is. `mutators` widens the call names
7850
+ # read; the `each_with_object` memo passes {MEMO_SEED_MUTATORS}.
7851
+ def lookup_mutated_seed(body, name, seed, mutators = HashLookupMutation::MUTATORS)
5117
7852
  Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
5118
- next unless node.is_a?(Prism::CallNode) && HashLookupMutation::MUTATORS.include?(node.name)
7853
+ next unless node.is_a?(Prism::CallNode) && mutators.include?(node.name)
5119
7854
  next if ancestors.any?(Prism::DefNode)
5120
7855
 
5121
7856
  receiver = node.receiver
@@ -5157,21 +7892,19 @@ module Rigor
5157
7892
  second.respond_to?(:name) ? second.name : nil
5158
7893
  end
5159
7894
 
5160
- # Content-mutator calls on the block-local `var_name` within `body` — directly, or from a nested block that
5161
- # reaches it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a
5162
- # different variable and does not count.
7895
+ # Content mutations of the block-local `var_name` within `body` — directly, or from a nested block that reaches
7896
+ # it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a different
7897
+ # variable and does not count. The sites are the ones {#captured_content_mutations} reads for a captured local
7898
+ # ({#content_mutation_target}), the index compound writes included: scanning calls alone, `h[x] ||= []` was no
7899
+ # store, and the memo kept its `{}`.
5163
7900
  def body_content_mutations_on(body, var_name, shadows)
5164
7901
  calls = []
5165
7902
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
5166
- next unless descendant.is_a?(Prism::CallNode)
5167
- next unless CONTENT_MUTATORS.include?(descendant.name)
5168
-
5169
- receiver = descendant.receiver
5170
- next unless receiver.is_a?(Prism::LocalVariableReadNode)
5171
- next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
7903
+ name, site = content_mutation_target(descendant) { |receiver| receiver.depth == scope_nesting(ancestors) }
7904
+ next unless name == var_name
5172
7905
 
5173
- calls << descendant
5174
- record_shadows(shadows, descendant, ancestors)
7906
+ calls << site
7907
+ record_shadows(shadows, site, ancestors)
5175
7908
  end
5176
7909
  calls
5177
7910
  end
@@ -5311,6 +8044,29 @@ module Rigor
5311
8044
  MutationWidening::HASH_MUTATORS)).freeze
5312
8045
  private_constant :CONTENT_MUTATORS
5313
8046
 
8047
+ # The memo's seed widens on every shape mutator its scan does not read evidence from, as a captured local's
8048
+ # binding does on the block seam ({MutationWidening.widen_after_block}): the {HashLookupMutation} calls, and the
8049
+ # rewriters and removers (`merge!`, `update`, `delete`, `replace`, …) whose added content no join can read. Read
8050
+ # off the memo argument alone, `xs.each_with_object({}) { |s, m| m.merge!(s) }` answered the empty `{}`.
8051
+ MEMO_SEED_MUTATORS = (MutationWidening::SHAPE_MUTATORS - CONTENT_MUTATORS).freeze
8052
+ private_constant :MEMO_SEED_MUTATORS
8053
+
8054
+ # Issue #2296 — the Set adders {CONTENT_MUTATORS} leaves out (`add`, `add?`, `merge`): a scan cannot see the
8055
+ # receiver's class, and `merge` on a Hash returns a new Hash, so the callee census keeps them apart and floors
8056
+ # only a Set argument on them.
8057
+ SET_ONLY_ADDERS = (SetMutation::CONTENT_ADDERS - CONTENT_MUTATORS).freeze
8058
+ private_constant :SET_ONLY_ADDERS
8059
+
8060
+ # Issue #2595 — every name the callee write census ({#callee_write_census}) counts as a write: the shape mutators
8061
+ # the widening reads ({MutationWidening::SHAPE_MUTATORS}) and the content scans' own names. A Set-only adder
8062
+ # stays out: `merge` on a Hash returns a new Hash.
8063
+ CALLEE_WRITE_MUTATORS = (MutationWidening::SHAPE_MUTATORS | CONTENT_MUTATORS).freeze
8064
+ # The tables {#callee_write_census} scans with.
8065
+ CENSUS_TABLES = CalleeWriteCensus::Tables.new(mutators: CALLEE_WRITE_MUTATORS, handled: CONTENT_MUTATORS,
8066
+ fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS,
8067
+ foreign_calls: METHOD_BODY_BLOCK_CALLS)
8068
+ private_constant :CALLEE_WRITE_MUTATORS, :CENSUS_TABLES
8069
+
5314
8070
  # The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
5315
8071
  # method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
5316
8072
  # `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
@@ -5523,12 +8279,7 @@ module Rigor
5523
8279
  # receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
5524
8280
  # (`Object` at the top level).
5525
8281
  def repeating_block_class(call_node)
5526
- receiver_type =
5527
- if call_node.receiver
5528
- explicit_receiver_type(call_node)
5529
- else
5530
- operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
5531
- end
8282
+ receiver_type = block_call_receiver_type(call_node)
5532
8283
  return nil if receiver_type.nil?
5533
8284
 
5534
8285
  method_name = call_node.name
@@ -5542,6 +8293,13 @@ module Rigor
5542
8293
  nil
5543
8294
  end
5544
8295
 
8296
+ # The receiver a block-carrying call dispatches on: its explicit receiver's type, else `self`, else `Object`.
8297
+ def block_call_receiver_type(call_node)
8298
+ return explicit_receiver_type(call_node) if call_node.receiver
8299
+
8300
+ operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
8301
+ end
8302
+
5545
8303
  # The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
5546
8304
  # `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
5547
8305
  # its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
@@ -6129,7 +8887,7 @@ module Rigor
6129
8887
  # inferred.
6130
8888
  def eval_return(node)
6131
8889
  sink = Thread.current[RETURN_SINK_KEY]
6132
- sink << jump_value_type(node) if sink
8890
+ sink&.record(node, jump_value_type(node))
6133
8891
  [Type::Combinator.bot, scope]
6134
8892
  end
6135
8893