rigortype 0.4.6 → 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 (417) 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 +50 -3
  8. data/docs/handbook/appendix-phpstan.md +4 -4
  9. data/docs/manual/02-cli-reference.md +152 -26
  10. data/docs/manual/03-configuration.md +36 -3
  11. data/docs/manual/06-baseline.md +6 -4
  12. data/docs/manual/09-editor-integration.md +13 -2
  13. data/docs/manual/10-mcp-server.md +38 -2
  14. data/docs/manual/12-caching.md +4 -3
  15. data/docs/manual/plugins/rigor-actionpack.md +19 -11
  16. data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
  17. data/lib/rigor/analysis/baseline.rb +24 -27
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  20. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +4 -4
  21. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  22. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +85 -13
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  24. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +0 -1
  25. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +1 -1
  27. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +3 -3
  28. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  29. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
  30. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  31. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  32. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  33. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +2 -2
  34. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  35. data/lib/rigor/analysis/check_rules.rb +124 -44
  36. data/lib/rigor/analysis/crash_signature.rb +2 -2
  37. data/lib/rigor/analysis/dependency_recorder.rb +4 -4
  38. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  39. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  40. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  42. data/lib/rigor/analysis/edit_replay.rb +93 -6
  43. data/lib/rigor/analysis/fact_store.rb +3 -3
  44. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -2
  45. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  46. data/lib/rigor/analysis/incremental_session.rb +27 -16
  47. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  48. data/lib/rigor/analysis/run_stats.rb +2 -2
  49. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +2 -2
  50. data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +193 -93
  52. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  53. data/lib/rigor/analysis/runner/pool_coordinator.rb +147 -323
  54. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -3
  55. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  56. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  57. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  58. data/lib/rigor/analysis/runner.rb +77 -71
  59. data/lib/rigor/analysis/self_call_resolution_recorder.rb +1 -1
  60. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  61. data/lib/rigor/analysis/template_units.rb +9 -1
  62. data/lib/rigor/analysis/worker_session.rb +24 -31
  63. data/lib/rigor/bleeding_edge.rb +3 -6
  64. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  65. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  66. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  67. data/lib/rigor/cache/descriptor.rb +6 -5
  68. data/lib/rigor/cache/incremental_snapshot.rb +31 -12
  69. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  70. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  71. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  72. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  73. data/lib/rigor/cache/rbs_descriptor.rb +54 -3
  74. data/lib/rigor/cache/rbs_environment.rb +4 -1
  75. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  76. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  77. data/lib/rigor/cache/store.rb +11 -10
  78. data/lib/rigor/ci_detector.rb +1 -1
  79. data/lib/rigor/cli/annotate_command.rb +24 -19
  80. data/lib/rigor/cli/baseline_command.rb +145 -55
  81. data/lib/rigor/cli/catalogue.rb +256 -0
  82. data/lib/rigor/cli/check_command.rb +108 -102
  83. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  84. data/lib/rigor/cli/command.rb +15 -1
  85. data/lib/rigor/cli/coverage_command.rb +21 -8
  86. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  87. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  88. data/lib/rigor/cli/coverage_scan.rb +3 -5
  89. data/lib/rigor/cli/diff_command.rb +25 -20
  90. data/lib/rigor/cli/docs_command.rb +1 -2
  91. data/lib/rigor/cli/doctor_command.rb +12 -12
  92. data/lib/rigor/cli/effects_command.rb +50 -40
  93. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  94. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  95. data/lib/rigor/cli/effects_renderer.rb +2 -1
  96. data/lib/rigor/cli/effects_snapshot_command.rb +18 -17
  97. data/lib/rigor/cli/explain_command.rb +18 -8
  98. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  99. data/lib/rigor/cli/json_document.rb +41 -0
  100. data/lib/rigor/cli/lens_command.rb +12 -8
  101. data/lib/rigor/cli/lsp_command.rb +250 -23
  102. data/lib/rigor/cli/mcp_command.rb +12 -7
  103. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  104. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  105. data/lib/rigor/cli/options.rb +40 -1
  106. data/lib/rigor/cli/plugin_command.rb +93 -46
  107. data/lib/rigor/cli/plugins_command.rb +17 -18
  108. data/lib/rigor/cli/plugins_renderer.rb +10 -6
  109. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  110. data/lib/rigor/cli/protection_renderer.rb +2 -1
  111. data/lib/rigor/cli/show_bleedingedge_command.rb +21 -15
  112. data/lib/rigor/cli/sig_gen_command.rb +84 -27
  113. data/lib/rigor/cli/skill_command.rb +32 -3
  114. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  115. data/lib/rigor/cli/skill_describe.rb +1 -1
  116. data/lib/rigor/cli/trace_command.rb +14 -9
  117. data/lib/rigor/cli/triage_command.rb +10 -11
  118. data/lib/rigor/cli/triage_renderer.rb +2 -1
  119. data/lib/rigor/cli/type_of_command.rb +16 -11
  120. data/lib/rigor/cli/type_of_project_scope.rb +1 -4
  121. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  122. data/lib/rigor/cli/type_scan_command.rb +14 -9
  123. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  124. data/lib/rigor/cli/unused_command.rb +27 -17
  125. data/lib/rigor/cli/upgrade_command.rb +5 -0
  126. data/lib/rigor/cli.rb +209 -54
  127. data/lib/rigor/config_audit.rb +1 -1
  128. data/lib/rigor/configuration/severity_profile.rb +5 -5
  129. data/lib/rigor/configuration.rb +72 -30
  130. data/lib/rigor/effects/definition_context.rb +0 -2
  131. data/lib/rigor/effects/envelope.rb +1 -1
  132. data/lib/rigor/effects/framework_units.rb +1 -1
  133. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  134. data/lib/rigor/effects/propagator.rb +1 -1
  135. data/lib/rigor/effects/registry.rb +2 -2
  136. data/lib/rigor/effects/snapshot.rb +1 -1
  137. data/lib/rigor/effects/taint_cause.rb +0 -2
  138. data/lib/rigor/effects/unit_scan.rb +9 -13
  139. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -9
  140. data/lib/rigor/environment/class_registry.rb +3 -3
  141. data/lib/rigor/environment/core_root.rb +77 -0
  142. data/lib/rigor/environment/default_libraries.rb +1 -1
  143. data/lib/rigor/environment/deferred_signature_plan.rb +283 -0
  144. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  145. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  146. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  147. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  148. data/lib/rigor/environment/member_consistency.rb +56 -7
  149. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  150. data/lib/rigor/environment/project_path_gems.rb +124 -0
  151. data/lib/rigor/environment/rbs_hierarchy.rb +6 -15
  152. data/lib/rigor/environment/rbs_loader.rb +480 -67
  153. data/lib/rigor/environment/reflection.rb +7 -7
  154. data/lib/rigor/environment/required_features.rb +0 -3
  155. data/lib/rigor/environment/ruby_library_roots.rb +1 -1
  156. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  157. data/lib/rigor/environment.rb +41 -10
  158. data/lib/rigor/flow_contribution/merger.rb +4 -4
  159. data/lib/rigor/inference/acceptance.rb +93 -32
  160. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  161. data/lib/rigor/inference/block_run_count.rb +178 -0
  162. data/lib/rigor/inference/budget_trace.rb +8 -2
  163. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  164. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  165. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  166. data/lib/rigor/inference/callee_mutation_chain.rb +88 -77
  167. data/lib/rigor/inference/callee_write_census.rb +815 -0
  168. data/lib/rigor/inference/construction_escape.rb +217 -0
  169. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  170. data/lib/rigor/inference/deep_erasure.rb +39 -7
  171. data/lib/rigor/inference/def_handle.rb +1 -1
  172. data/lib/rigor/inference/def_node_resolver.rb +1 -1
  173. data/lib/rigor/inference/def_return_typer.rb +46 -4
  174. data/lib/rigor/inference/definer_resolution.rb +4 -2
  175. data/lib/rigor/inference/definers_index.rb +106 -0
  176. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  177. data/lib/rigor/inference/element_read_widening.rb +49 -3
  178. data/lib/rigor/inference/expression_typer.rb +116 -49
  179. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  180. data/lib/rigor/inference/global_write_census.rb +76 -2
  181. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  182. data/lib/rigor/inference/hkt_body.rb +1 -2
  183. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  184. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  185. data/lib/rigor/inference/hkt_registry.rb +3 -5
  186. data/lib/rigor/inference/indexed_narrowing.rb +4 -7
  187. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  188. data/lib/rigor/inference/ivar_writer_census.rb +51 -0
  189. data/lib/rigor/inference/key_presence_guard.rb +2 -1
  190. data/lib/rigor/inference/loop_value_record.rb +8 -4
  191. data/lib/rigor/inference/match_rebinding/frame.rb +46 -0
  192. data/lib/rigor/inference/match_rebinding/yielding_match.rb +45 -0
  193. data/lib/rigor/inference/match_rebinding.rb +11 -1
  194. data/lib/rigor/inference/memo_audit.rb +55 -0
  195. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  196. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  197. data/lib/rigor/inference/method_dispatcher/block_folding.rb +1 -1
  198. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +3 -3
  199. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  200. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  201. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +1 -1
  202. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  203. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +39 -27
  204. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  205. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +203 -17
  206. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  207. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  208. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +2 -2
  209. data/lib/rigor/inference/method_dispatcher/set_folding.rb +9 -7
  210. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +83 -20
  211. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  212. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  213. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  214. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  215. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  216. data/lib/rigor/inference/method_dispatcher.rb +64 -14
  217. data/lib/rigor/inference/module_function_state.rb +11 -1
  218. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  219. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  220. data/lib/rigor/inference/mutation_widening.rb +157 -27
  221. data/lib/rigor/inference/narrowing.rb +47 -33
  222. data/lib/rigor/inference/optimistic_origin.rb +1 -1
  223. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  224. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  225. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  226. data/lib/rigor/inference/project_method_ownership.rb +6 -3
  227. data/lib/rigor/inference/project_yielder.rb +11 -17
  228. data/lib/rigor/inference/receiver_alias.rb +20 -0
  229. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +3 -1
  230. data/lib/rigor/inference/refined_dispatch/own_method.rb +2 -1
  231. data/lib/rigor/inference/remover_holder_scan.rb +233 -0
  232. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  233. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  234. data/lib/rigor/inference/scope_indexer/certainty.rb +0 -1
  235. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  236. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +58 -15
  237. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  238. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  239. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +135 -0
  240. data/lib/rigor/inference/scope_indexer.rb +137 -46
  241. data/lib/rigor/inference/self_call_ivar_writes.rb +84 -12
  242. data/lib/rigor/inference/set_mutation.rb +189 -0
  243. data/lib/rigor/inference/singleton_hook_decline.rb +1 -2
  244. data/lib/rigor/inference/statement_evaluator.rb +2328 -138
  245. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  246. data/lib/rigor/inference/synthetic_method_scanner.rb +5 -6
  247. data/lib/rigor/inference/toplevel_def_census.rb +59 -0
  248. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  249. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  250. data/lib/rigor/inference/void_tail_summary.rb +2 -1
  251. data/lib/rigor/language_server/buffer_table.rb +3 -3
  252. data/lib/rigor/language_server/completion_provider.rb +26 -20
  253. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  254. data/lib/rigor/language_server/diagnostic_publisher.rb +8 -3
  255. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  256. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  257. data/lib/rigor/language_server/hover_provider.rb +1 -7
  258. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  259. data/lib/rigor/language_server/project_context.rb +39 -1
  260. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  261. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  262. data/lib/rigor/language_server/server.rb +7 -3
  263. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  264. data/lib/rigor/lens/plugins.rb +1 -1
  265. data/lib/rigor/mcp/server.rb +73 -2
  266. data/lib/rigor/mcp.rb +1 -1
  267. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  268. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  269. data/lib/rigor/plugin/io_boundary.rb +1 -1
  270. data/lib/rigor/plugin/loader.rb +25 -8
  271. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  272. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  273. data/lib/rigor/plugin/manifest.rb +9 -13
  274. data/lib/rigor/plugin/registry.rb +3 -3
  275. data/lib/rigor/plugin/services.rb +2 -3
  276. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  277. data/lib/rigor/plugin/trust_policy.rb +2 -2
  278. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  279. data/lib/rigor/plugin.rb +6 -8
  280. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  281. data/lib/rigor/project_environment.rb +16 -2
  282. data/lib/rigor/protection/analysis_guard.rb +1 -1
  283. data/lib/rigor/protection/discovery_seed.rb +44 -5
  284. data/lib/rigor/protection/mutator.rb +1 -1
  285. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  286. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  287. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  288. data/lib/rigor/rbs_extended.rb +2 -3
  289. data/lib/rigor/reflection/constant_ancestors.rb +5 -3
  290. data/lib/rigor/rooted_glob.rb +15 -1
  291. data/lib/rigor/run_scoped_slots.rb +38 -0
  292. data/lib/rigor/runtime/jit.rb +1 -1
  293. data/lib/rigor/scope/discovery_index.rb +19 -6
  294. data/lib/rigor/scope/resolution_chain.rb +3 -1
  295. data/lib/rigor/scope.rb +365 -31
  296. data/lib/rigor/sig_gen/classification.rb +4 -0
  297. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  298. data/lib/rigor/sig_gen/generator.rb +371 -58
  299. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  300. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  301. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  302. data/lib/rigor/sig_gen/method_candidate.rb +23 -4
  303. data/lib/rigor/sig_gen/observation_collector.rb +3 -5
  304. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  305. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  306. data/lib/rigor/sig_gen/renderer.rb +15 -9
  307. data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
  308. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  309. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  310. data/lib/rigor/sig_gen/write_result.rb +23 -12
  311. data/lib/rigor/sig_gen/writer.rb +217 -37
  312. data/lib/rigor/type/acceptance_router.rb +2 -2
  313. data/lib/rigor/type/accepts_result.rb +1 -1
  314. data/lib/rigor/type/app.rb +4 -6
  315. data/lib/rigor/type/combinator.rb +9 -13
  316. data/lib/rigor/type/constant.rb +4 -5
  317. data/lib/rigor/type/hash_shape.rb +21 -40
  318. data/lib/rigor/type/union.rb +1 -1
  319. data/lib/rigor/type_node/identifier.rb +1 -2
  320. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  321. data/lib/rigor/type_node/string_literal.rb +1 -2
  322. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  323. data/lib/rigor/type_node/union.rb +3 -6
  324. data/lib/rigor/value_semantics.rb +1 -10
  325. data/lib/rigor/version.rb +1 -1
  326. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  327. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  328. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  329. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +38 -36
  330. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  331. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -4
  332. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  333. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  334. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  335. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  336. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  337. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +66 -19
  338. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  339. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -3
  340. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  341. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  342. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  343. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  344. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +2 -3
  345. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +0 -1
  346. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +5 -4
  347. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  348. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  349. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +7 -12
  350. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  351. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  352. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  353. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +19 -6
  354. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +26 -13
  355. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  356. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  357. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  358. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  359. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  360. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  361. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  362. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  363. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  364. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  365. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  366. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  367. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -5
  368. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  369. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  370. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  371. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  372. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -2
  373. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  374. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  375. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  376. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  377. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  378. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -3
  379. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  380. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  381. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  382. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  383. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  384. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  385. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  386. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  387. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  388. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +3 -2
  389. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  390. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  391. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +1 -1
  392. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  393. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +17 -21
  394. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  395. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +3 -2
  396. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  397. data/sig/rigor/analysis/baseline.rbs +0 -3
  398. data/sig/rigor/analysis/fact_store.rbs +1 -0
  399. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  400. data/sig/rigor/cli/diff_command.rbs +1 -1
  401. data/sig/rigor/cli/explain_command.rbs +1 -1
  402. data/sig/rigor/effects/registry.rbs +2 -2
  403. data/sig/rigor/inference.rbs +4 -1
  404. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  405. data/sig/rigor/plugin/base.rbs +1 -0
  406. data/sig/rigor/plugin/effect_attribution.rbs +3 -3
  407. data/sig/rigor/plugin/effect_edge.rbs +1 -1
  408. data/sig/rigor/plugin/effect_entry_points.rbs +3 -3
  409. data/sig/rigor/plugin/io_boundary.rbs +2 -1
  410. data/sig/rigor/plugin/manifest.rbs +9 -9
  411. data/sig/rigor/plugin/protocol_contract.rbs +3 -7
  412. data/sig/rigor/scope.rbs +23 -3
  413. data/skills/rigor-plugin-author/SKILL.md +4 -4
  414. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  415. data/skills/rigor-plugin-review/SKILL.md +3 -3
  416. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +12 -1
  417. metadata +36 -3
@@ -14,7 +14,10 @@ require_relative "block_call_timing"
14
14
  require_relative "block_repetition"
15
15
  require_relative "block_parameter_binder"
16
16
  require_relative "body_fixpoint"
17
+ require_relative "memo_audit"
17
18
  require_relative "callee_mutation_chain"
19
+ require_relative "toplevel_def_census"
20
+ require_relative "callee_write_census"
18
21
  require_relative "captured_locals"
19
22
  require_relative "dynamic_origin"
20
23
  require_relative "error_info"
@@ -26,10 +29,13 @@ require_relative "../analysis/check_rules/published_constant_guard"
26
29
  require_relative "struct_fold_safety"
27
30
  require_relative "closure_escape_analyzer"
28
31
  require_relative "content_join"
32
+ require_relative "construction_escape"
29
33
  require_relative "define_method_block_self"
30
34
  require_relative "macro_block_self_type"
35
+ require_relative "global_writer_reach"
31
36
  require_relative "guard_rebinding"
32
37
  require_relative "self_call_ivar_writes"
38
+ require_relative "direct_ivar_writes"
33
39
  require_relative "intervening_ivar_reset"
34
40
  require_relative "match_rebinding"
35
41
  require_relative "clause_widening"
@@ -42,6 +48,7 @@ require_relative "method_dispatcher"
42
48
  require_relative "method_parameter_binder"
43
49
  require_relative "multi_target_binder"
44
50
  require_relative "mutation_widening"
51
+ require_relative "out_buffer_mutation"
45
52
  require_relative "narrowing"
46
53
  require_relative "operand_effects"
47
54
  require_relative "operand_walk"
@@ -54,6 +61,7 @@ require_relative "rewrite_mutation"
54
61
  require_relative "unknown_store_widening"
55
62
  require_relative "untyped_rejoin"
56
63
  require_relative "version_guard"
64
+ require_relative "../run_scoped_slots"
57
65
 
58
66
  module Rigor
59
67
  module Inference
@@ -517,9 +525,13 @@ module Rigor
517
525
  # def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
518
526
  # callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
519
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.
520
531
  def content_mutated_parameter_positions(def_node)
521
- slots = content_parameter_slots(def_node)
522
- census = callee_content_mutated_parameters(def_node).values
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
523
535
  each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
524
536
  positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
525
537
  positions.sort + keywords.sort
@@ -531,25 +543,691 @@ module Rigor
531
543
  # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
532
544
  # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
533
545
  def content_mutated_arguments(call_node)
534
- 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
535
568
  # Fast path — only a local passed as an argument can be reported, so a call with none skips the def
536
569
  # resolution and the body scan entirely (the overwhelming common case).
537
- 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
1013
+
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)
1020
+
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
538
1057
 
539
- def_node = resolve_self_callee_def(call_node)
540
- return NO_ARGUMENT_READS if def_node.nil?
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
541
1172
 
542
- mutated = callee_content_mutated_parameters(def_node)
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)
543
1192
  return NO_ARGUMENT_READS if mutated.empty?
544
1193
 
545
1194
  argument_nodes = call_node.arguments.arguments
546
- mutated.values.uniq.filter_map do |slot|
547
- argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot]
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]
548
1200
  argument = argument.value if argument.is_a?(Prism::ImplicitNode)
549
1201
  argument if argument.is_a?(Prism::LocalVariableReadNode)
550
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
551
1225
  end
552
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
+
553
1231
  # The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
554
1232
  def keyword_argument(argument_nodes, name)
555
1233
  hash = argument_nodes.last
@@ -585,16 +1263,37 @@ module Rigor
585
1263
  # evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
586
1264
  # body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
587
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"`.
588
1273
  def index_compound_write_value(node)
589
1274
  return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
590
1275
 
591
1276
  current = index_read_type(node, scope)
592
1277
  rhs = scope.type_of(node.value, tracer: tracer)
593
- 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
594
1284
 
595
1285
  index_write_stored_type(node, scope, current: current, rhs: rhs)
596
1286
  end
597
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
+
598
1297
  private
599
1298
 
600
1299
  attr_reader :scope, :tracer
@@ -626,11 +1325,11 @@ module Rigor
626
1325
  end
627
1326
 
628
1327
  # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
629
- # propagate left-to-right) and binds `name` to the result type. Compound assignment forms (`+=` etc.) are deferred
630
- # to a follow-up; for now they degrade to "type the rhs, do not rebind" via the default branch in {#evaluate}.
1328
+ # propagate left-to-right) and binds `name` to the result type.
631
1329
  def eval_local_write(node)
632
1330
  rhs_type, post_rhs = sub_eval(node.value, scope)
633
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)
634
1333
  # Issue #667 — `m = AppConfig::MODE` makes `m` a copy of a constant the reader's file does not
635
1334
  # declare, so `m == :production` folding to `true` is the project's configuration and not a logic
636
1335
  # error the author can see. Stamped LAST, across every binding path above: an RHS can be both an
@@ -641,6 +1340,53 @@ module Rigor
641
1340
  [rhs_type, bound.with_published_constant_mark(:local, node.name)]
642
1341
  end
643
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
+
644
1390
  # The binding half of {#eval_local_write}: the three mutually exclusive provenance paths a local write
645
1391
  # can take, unchanged from when they were inline.
646
1392
  def bind_local_write(node, rhs_type, post_rhs)
@@ -740,8 +1486,7 @@ module Rigor
740
1486
  # Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
741
1487
  # and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
742
1488
  # type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
743
- # `build_method_entry_scope`, so writes do not leak across method boundaries until cross-method ivar/cvar tracking
744
- # lands.
1489
+ # `build_method_entry_scope`; cross-method ivar and cvar state enters it through the class-level seeds.
745
1490
  def eval_ivar_write(node)
746
1491
  rhs_type, post_rhs = sub_eval(node.value, scope)
747
1492
  # Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
@@ -980,8 +1725,7 @@ module Rigor
980
1725
  # narrowing keyed on `(receiver_kind, receiver_name,
981
1726
  # literal_key)` when both receiver and key are stable
982
1727
  # (see {Inference::IndexedNarrowing}). Unstable shapes
983
- # fall through to "no scope effect", matching the old
984
- # `Prism::IndexOrWriteNode` default-branch behaviour.
1728
+ # record no narrowing.
985
1729
  #
986
1730
  # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
987
1731
  # post-`||=` value, not the rvalue alone.
@@ -1029,6 +1773,29 @@ module Rigor
1029
1773
  erased || stored
1030
1774
  end
1031
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
+
1032
1799
  def erase_memo_container(type)
1033
1800
  any = Type::Combinator.untyped
1034
1801
  case type
@@ -1113,6 +1880,28 @@ module Rigor
1113
1880
  end
1114
1881
  end
1115
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
+
1116
1905
  # True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
1117
1906
  # reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
1118
1907
  def memoizing_index_read?(node, current, rhs)
@@ -1160,17 +1949,46 @@ module Rigor
1160
1949
  # Both mutator-receiver widenings, against one typing of the call's arguments: the bindings the
1161
1950
  # receiver NAMES ({MutationWidening.widen_after_call}) and, when it names none because the
1162
1951
  # receiver is an element read into a local, that element's pin inside its container
1163
- # ({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.
1164
1954
  def widen_mutated_receivers(call_node, current_scope)
1165
1955
  arg_types = mutator_arg_types(call_node, current_scope)
1166
1956
  widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
1167
- arg_types: arg_types)
1168
- ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened, arg_types: arg_types)
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)))
1169
1985
  end
1170
1986
 
1171
1987
  # `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
1172
1988
  def mutator_arg_types(call_node, current_scope)
1173
- 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)
1174
1992
  return MutationWidening::NO_ARG_TYPES
1175
1993
  end
1176
1994
  unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
@@ -1440,7 +2258,7 @@ module Rigor
1440
2258
  sub_eval(node.statements, scope)
1441
2259
  end
1442
2260
 
1443
- # `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
1444
2262
  # shared with every branch (including the else); branches are evaluated independently and merged with
1445
2263
  # nil-injection so half-bound names degrade to `T | nil`.
1446
2264
  def eval_case(node)
@@ -1646,6 +2464,11 @@ module Rigor
1646
2464
  InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1647
2465
  when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
1648
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))
1649
2472
  else
1650
2473
  body = when_lambda_body(condition)
1651
2474
  GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
@@ -3068,7 +3891,9 @@ module Rigor
3068
3891
  types = [coll_type]
3069
3892
  name = for_collection_variable(node)
3070
3893
  types << CapturedLocals.bound_type(continuation, name) if name
3071
- deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index))
3894
+ deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index)) do
3895
+ DeepErasure.holds_set?(coll_type)
3896
+ end
3072
3897
  Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
3073
3898
  end
3074
3899
 
@@ -3133,11 +3958,11 @@ module Rigor
3133
3958
  forget_guard_if_rebinds(body_entry, node.statements)
3134
3959
  end
3135
3960
 
3136
- # `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
3137
- # block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
3138
- # structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
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
3139
3964
  # the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
3140
- # signature, unknown receiver) we fall back to the structural extractor.
3965
+ # signature, unknown receiver) the structural answer stands.
3141
3966
  def for_iteration_element_type(coll_type)
3142
3967
  structural = collection_element_type(coll_type)
3143
3968
  return structural unless structural.equal?(Type::Combinator.untyped)
@@ -3199,7 +4024,7 @@ module Rigor
3199
4024
 
3200
4025
  # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
3201
4026
  # elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
3202
- # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
4027
+ # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; a `Range` constant reads the
3203
4028
  # literal range's element class. Anything else falls back to `untyped`.
3204
4029
  def collection_element_type(type)
3205
4030
  case type
@@ -3500,8 +4325,9 @@ module Rigor
3500
4325
 
3501
4326
  # `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
3502
4327
  # class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
3503
- # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
3504
- # the existing context and accept that nested defs degrade to the `Dynamic[Top]` default.
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.
3505
4331
  # Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
3506
4332
  # `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
3507
4333
  # `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
@@ -3567,11 +4393,9 @@ module Rigor
3567
4393
  invoked = call_operand_scope(node, walk, scope)
3568
4394
  invoked = forget_operand_specials(node, invoked)
3569
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}).
3570
4398
  call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
3571
- # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
3572
- # types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
3573
- # block-param and adopt it as the call's return type.
3574
- call_type = each_with_object_return(node, call_type)
3575
4399
  [call_type, invoke_from(node, invoked, call_type, operand_types)]
3576
4400
  end
3577
4401
 
@@ -3760,8 +4584,7 @@ module Rigor
3760
4584
  # B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
3761
4585
  # of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
3762
4586
  # binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
3763
- # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3764
- # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
4587
+ # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
3765
4588
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
3766
4589
  # Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
3767
4590
  # above, which would join a literal-name write back into the class seed.
@@ -3795,9 +4618,32 @@ module Rigor
3795
4618
  post_scope.forget_guard_narrowings
3796
4619
  end
3797
4620
  end
4621
+ post_scope = forget_reached_globals(node, post_scope)
3798
4622
  forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
3799
4623
  end
3800
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
+
3801
4647
  # True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
3802
4648
  # its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
3803
4649
  # so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
@@ -3917,7 +4763,8 @@ module Rigor
3917
4763
 
3918
4764
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
3919
4765
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3920
- # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
4766
+ # External-receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged, apart from
4767
+ # the literal-block case below.
3921
4768
  # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3922
4769
  #
3923
4770
  # Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
@@ -3925,11 +4772,19 @@ module Rigor
3925
4772
  # calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
3926
4773
  # code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
3927
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.
3928
4780
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3929
4781
  widened =
3930
4782
  if intervening_call_candidate?(call_node)
3931
- mark = !InterveningIvarReset.self_call?(call_node)
3932
- InterveningIvarReset.widen(current_scope, mark: mark) { self_call_ivar_writes(call_node) }
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
3933
4788
  else
3934
4789
  InterveningIvarReset.after_block(call_node, current_scope)
3935
4790
  end
@@ -4119,7 +4974,7 @@ module Rigor
4119
4974
  # `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
4120
4975
  # method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
4121
4976
  # cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
4122
- # plugin-recognised calls) lives behind a follow-up slice that introduces `:local` / `:argument_at` target kinds.
4977
+ # plugin-recognised calls) lives behind a follow-up slice that introduces an `:argument_at` target kind.
4123
4978
  def apply_plugin_assertions(call_node, current_scope)
4124
4979
  registry = current_scope.environment&.plugin_registry
4125
4980
  return current_scope if registry.nil? || registry.empty?
@@ -4274,7 +5129,7 @@ module Rigor
4274
5129
  def lookup_post_return_arg(call_node, method_def, target_name)
4275
5130
  # Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
4276
5131
  # directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
4277
- # mapping (`:local` / `:argument_at`) is a follow-up slice.
5132
+ # mapping (`:argument_at`) is a follow-up slice.
4278
5133
  return nil if method_def.nil?
4279
5134
 
4280
5135
  arguments = call_node.arguments&.arguments || []
@@ -4471,57 +5326,553 @@ module Rigor
4471
5326
  acc = floor_callee_escaped_args_for_call(receiver, acc)
4472
5327
  receiver = receiver.receiver
4473
5328
  end
4474
- acc
5329
+ # Issue #2397 — and every self-call in the call's own non-escaping block.
5330
+ floor_block_callee_captures(node, acc)
4475
5331
  end
4476
5332
 
4477
5333
  # Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
4478
5334
  # before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
4479
- def floor_callee_escaped_args_for_call(node, base_scope)
4480
- arguments = content_mutated_arguments(node)
4481
- return base_scope if arguments.empty?
4482
-
4483
- arguments.reduce(base_scope) do |acc, argument|
4484
- next acc unless acc.locals.key?(argument.name)
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) }
4485
5361
 
4486
- floored = content_floor_for(acc.local(argument.name))
4487
- floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
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
4488
5367
  end
5368
+ acc
4489
5369
  end
4490
5370
 
4491
- # The `{ name => slot }` parameters whose content the callee mutates, a slot being a positional index or a
4492
- # keyword symbol: those it mutates itself ({#own_content_parameters}) and, issue #2104, those it passes on to a
4493
- # self-call whose callee mutates them, through any number of such hand-offs ({CalleeMutationChain}). Each leaves
4494
- # the caller's argument binding stale after the call, so each floors it. Memoised per def node (the closure is
4495
- # otherwise recomputed at every call site), and across evaluators by {#resolved_census}.
4496
- def callee_content_mutated_parameters(def_node)
4497
- cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
4498
- cache[def_node] ||= resolved_census(:close, def_node) do
4499
- CalleeMutationChain.close(def_node, own: method(:own_content_parameters), edges: method(:forwarding_edges))
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
4500
5381
  end
5382
+ acc
4501
5383
  end
4502
5384
 
4503
- # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callee_def}), so it
4504
- # reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
4505
- # resolve through. Those two key it: the index by identity, as it is immutable (one slot, as a file's analysis
4506
- # reads one index; a scope indexed for another file replaces it), the class by name. A run builds hundreds of
4507
- # thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
4508
- # thirty times over (#2295's cost on the release gate).
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.
4509
5390
  #
4510
- # While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
4511
- # and in each capture window open at the time, and a hit would leave that read out of them.
4512
- def resolved_census(kind, def_node)
4513
- return yield if Analysis::DependencyRecorder.active?
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
4514
5406
 
4515
- discovery = scope.discovery
4516
- slot = Thread.current[RESOLVED_CENSUS_KEY]
4517
- slot = Thread.current[RESOLVED_CENSUS_KEY] = [discovery, {}] unless slot&.first.equal?(discovery)
4518
- by_class = (slot.last[kind] ||= {})
4519
- class_name = enclosing_class_name_for(scope.self_type)
4520
- by_def = (by_class[class_name] ||= {}.compare_by_identity)
4521
- by_def.fetch(def_node) { by_def[def_node] = yield }
5407
+ acc = floor_block_body_callee_captures(call, acc)
5408
+ end
5409
+ acc
4522
5410
  end
4523
5411
 
4524
- RESOLVED_CENSUS_KEY = :rigor_callee_resolved_census
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)
4525
5876
  private_constant :RESOLVED_CENSUS_KEY
4526
5877
 
4527
5878
  # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
@@ -4530,17 +5881,261 @@ module Rigor
4530
5881
  # ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
4531
5882
  # call into it.
4532
5883
  def own_content_parameters(def_node)
4533
- def_census(:own, def_node) { escaped_content_parameters(def_node).merge(direct_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
4534
6001
  end
4535
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
+
4536
6129
  # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
4537
- def def_census(kind, def_node)
6130
+ def def_census(kind, def_node, &)
4538
6131
  table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
4539
6132
  entry = (table[def_node] ||= {})
4540
- entry.fetch(kind) { entry[kind] = yield }
6133
+ return entry[kind] = yield unless entry.key?(kind)
6134
+
6135
+ MemoAudit.check("def census", entry[kind], kind, &)
4541
6136
  end
4542
6137
 
4543
- DEF_CENSUS_KEY = :rigor_callee_def_census
6138
+ DEF_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_def_census)
4544
6139
  private_constant :DEF_CENSUS_KEY
4545
6140
 
4546
6141
  # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
@@ -4553,19 +6148,39 @@ module Rigor
4553
6148
  cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
4554
6149
  end
4555
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
+
4556
6161
  def compute_forwarding_edges(def_node)
4557
- slots = content_parameter_slots(def_node)
6162
+ slots = forwarding_slots(def_node)
4558
6163
  edges = []
4559
- each_forwarding_call(def_node, slots) do |call_node, forwarded|
4560
- callee = resolve_self_callee_def(call_node)
4561
- next if callee.nil?
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
4562
6173
 
4563
- callee_slots = content_parameter_slots(callee)
4564
- forwarded.each do |name, callee_slot|
4565
- names = bound_parameter_names(callee_slots, callee_slot)
4566
- next if names.empty?
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?
4567
6180
 
4568
- edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names)
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
4569
6184
  end
4570
6185
  end
4571
6186
  edges.empty? ? NO_FORWARDS : edges
@@ -4581,11 +6196,13 @@ module Rigor
4581
6196
 
4582
6197
  def forwarding_calls(def_node, slots)
4583
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))
4584
6201
  each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
4585
- next unless self_dispatch_call?(descendant)
6202
+ next unless self_dispatch_call?(descendant) || hand_off_receiver?(descendant)
4586
6203
 
4587
- forwarded = forwarded_parameters(descendant, slots, depth)
4588
- calls << [descendant, forwarded] unless forwarded.empty?
6204
+ forwarded = forwarded_parameters(descendant, slots, depth, context)
6205
+ calls << [descendant, forwarded, depth] unless forwarded.empty?
4589
6206
  end
4590
6207
  calls.empty? ? NO_FORWARDS : calls
4591
6208
  end
@@ -4596,8 +6213,11 @@ module Rigor
4596
6213
  # `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
4597
6214
  # `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
4598
6215
  # argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
4599
- # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index.
4600
- def forwarded_parameters(call_node, slots, depth)
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)
4601
6221
  args = call_node.arguments
4602
6222
  return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
4603
6223
 
@@ -4605,11 +6225,14 @@ module Rigor
4605
6225
  forwarded = []
4606
6226
  args.arguments.each_with_index do |argument, index|
4607
6227
  if argument.is_a?(Prism::KeywordHashNode)
4608
- forwarded.concat(forwarded_keywords(argument, slots, depth))
6228
+ forwarded.concat(forwarded_keywords(argument, slots, depth, context))
4609
6229
  elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
4610
6230
  spread = true
4611
- elsif (name = parameter_read_name(argument, slots, depth))
4612
- forwarded << [name, spread ? ANY_SLOT : index]
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] }
4613
6236
  end
4614
6237
  end
4615
6238
  forwarded
@@ -4617,27 +6240,95 @@ module Rigor
4617
6240
 
4618
6241
  # The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
4619
6242
  ANY_SLOT = :__any_positional__
4620
- private_constant :ANY_SLOT
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
4621
6248
 
4622
6249
  # The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
4623
6250
  # may reach: the one at that slot, or every positional one for {ANY_SLOT}.
4624
6251
  def bound_parameter_names(callee_slots, slot)
4625
6252
  if slot == ANY_SLOT
4626
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) }
4627
6256
  else
4628
6257
  callee_slots.filter_map { |name, at| name if at == slot }
4629
6258
  end
4630
6259
  end
4631
6260
 
4632
- def forwarded_keywords(hash_node, slots, depth)
4633
- hash_node.elements.filter_map do |pair|
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
4634
6269
  next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
4635
6270
 
4636
- name = parameter_read_name(pair.value, slots, depth)
4637
- [name, pair.key.unescaped.to_sym] if name
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
4638
6296
  end
4639
6297
  end
4640
6298
 
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
6303
+
6304
+ rest = context.rests[read.name]
6305
+ rest if rest == KEYWORD_REST_PARAMETER
6306
+ end
6307
+
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
6315
+ end
6316
+
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
6330
+ end
6331
+
4641
6332
  # The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
4642
6333
  # `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
4643
6334
  def parameter_read_name(node, slots, depth)
@@ -4647,16 +6338,168 @@ module Rigor
4647
6338
  node.name if slots.key?(node.name)
4648
6339
  end
4649
6340
 
4650
- # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
4651
- # `Scope#user_def_for` reads; no ancestor walk (the boundary-escape idiom is same-class), keeping this off the hot
4652
- # path for the overwhelming majority of self-calls that resolve to nothing escaping.
4653
- def resolve_self_callee_def(node)
4654
- class_name = enclosing_class_name_for(scope.self_type)
4655
- return scope.top_level_def_for(node.name) if class_name.nil?
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.
6345
+ #
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.
6354
+ #
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?
4656
6418
 
4657
- scope.user_def_for(class_name, node.name)
6419
+ missing + top.map { |def_node, offset, _owner| [def_node, offset, FLOOR_EVERY] }
4658
6420
  end
4659
6421
 
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))
6431
+
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
6439
+
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)
6442
+
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?
6446
+ end
6447
+
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)
6454
+ end
6455
+ end
6456
+
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
6465
+ end
6466
+ found && [*found, walked_owner(defining, class_name, nil)]
6467
+ end
6468
+
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
6477
+ end
6478
+
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
6492
+
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)
6497
+ end
6498
+
6499
+ SEND_HAND_OFFS = %i[send public_send __send__].freeze
6500
+ NO_CALLEES = [].freeze
6501
+ private_constant :SEND_HAND_OFFS, :NO_CALLEES
6502
+
4660
6503
  def self_dispatch_call?(node)
4661
6504
  return false unless node.is_a?(Prism::CallNode)
4662
6505
 
@@ -4664,7 +6507,7 @@ module Rigor
4664
6507
  end
4665
6508
 
4666
6509
  # The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
4667
- # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
6510
+ # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-mutated"
4668
6511
  # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
4669
6512
  # non-escaping.
4670
6513
  def escaped_content_parameters(def_node)
@@ -4681,8 +6524,10 @@ module Rigor
4681
6524
  next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
4682
6525
  next if syntactically_non_escaping_call?(descendant)
4683
6526
 
4684
- collect_content_mutations(descendant.block.body).each_key do |name|
4685
- 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) }
4686
6531
  end
4687
6532
  end
4688
6533
  positions.slice(*mutated)
@@ -4694,9 +6539,9 @@ module Rigor
4694
6539
  # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
4695
6540
  # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
4696
6541
  #
4697
- # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted: the bare-collection floor
4698
- # it would apply turned haml's `AttributeCompiler#compile` (a `temple` array a helper fills in a `times` block,
4699
- # returned against a declared tuple) into a `def.return-type-mismatch` (af3efef33, and again under #2104).
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.
4700
6545
  def direct_content_parameters(def_node)
4701
6546
  cache = (@direct_param_cache ||= {}.compare_by_identity)
4702
6547
  cache[def_node] ||= compute_direct_content_parameters(def_node)
@@ -4709,11 +6554,110 @@ module Rigor
4709
6554
  mutated = Set.new
4710
6555
  each_node_outside_nested_scopes(def_node.body) do |descendant|
4711
6556
  name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
4712
- mutated << name if !name.nil? && positions.key?(name)
6557
+ mutated << name if !name.nil? && positions.key?(name) && !after_rebind?(def_node, name, descendant)
4713
6558
  end
4714
6559
  positions.slice(*mutated)
4715
6560
  end
4716
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
+
4717
6661
  # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
4718
6662
  # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
4719
6663
  # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
@@ -4766,7 +6710,7 @@ module Rigor
4766
6710
  end
4767
6711
 
4768
6712
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
4769
- # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
6713
+ # a callee body where the block-owning call's receiver TYPE is not available. A call whose method name is a known
4770
6714
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
4771
6715
  # captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
4772
6716
  # unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
@@ -4812,6 +6756,8 @@ module Rigor
4812
6756
  case arg
4813
6757
  when Prism::LocalVariableReadNode
4814
6758
  scope.locals.key?(arg.name)
6759
+ when Prism::SplatNode
6760
+ arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
4815
6761
  when Prism::KeywordHashNode
4816
6762
  arg.elements.any? do |pair|
4817
6763
  next false unless pair.is_a?(Prism::AssocNode)
@@ -4907,6 +6853,18 @@ module Rigor
4907
6853
  Type::Combinator.untyped])
4908
6854
  elsif arrayish?(type)
4909
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
4910
6868
  end
4911
6869
  end
4912
6870
 
@@ -5021,7 +6979,7 @@ module Rigor
5021
6979
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
5022
6980
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
5023
6981
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
5024
- # — `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
6982
+ # — `[].each { … }` — stays sound), value-pinned widened on the final permitted iteration, and floored to
5025
6983
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
5026
6984
  #
5027
6985
  # The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
@@ -5641,7 +7599,7 @@ module Rigor
5641
7599
  end
5642
7600
 
5643
7601
  # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
5644
- # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured- local content
7602
+ # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured-local content
5645
7603
  # mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
5646
7604
  # memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
5647
7605
  # is not a collection.
@@ -5660,28 +7618,239 @@ module Rigor
5660
7618
  body = block.body
5661
7619
  return call_type if body.nil?
5662
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
+
5663
7634
  # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
5664
7635
  # captured-local walk.
5665
7636
  shadows = {}.compare_by_identity
5666
7637
  calls = body_content_mutations_on(body, memo_param, shadows)
5667
- 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)
5668
7655
 
5669
- pre_state = lookup_mutated_seed(body, memo_param, scope.type_of(memo_arg, tracer: tracer)) do |depth, nesting|
5670
- 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
7663
+ end
7664
+ end
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))
5671
7761
  end
5672
- joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
5673
- joined || call_type
5674
7762
  end
5675
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
+
5676
7844
  # `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
5677
7845
  # never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
5678
7846
  # values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
5679
7847
  # `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
5680
7848
  # block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
5681
- # `seed` describes. A `def` opens a scope of its own, so nothing under one is.
5682
- def lookup_mutated_seed(body, name, seed)
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)
5683
7852
  Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
5684
- next unless node.is_a?(Prism::CallNode) && HashLookupMutation::MUTATORS.include?(node.name)
7853
+ next unless node.is_a?(Prism::CallNode) && mutators.include?(node.name)
5685
7854
  next if ancestors.any?(Prism::DefNode)
5686
7855
 
5687
7856
  receiver = node.receiver
@@ -5723,21 +7892,19 @@ module Rigor
5723
7892
  second.respond_to?(:name) ? second.name : nil
5724
7893
  end
5725
7894
 
5726
- # Content-mutator calls on the block-local `var_name` within `body` — directly, or from a nested block that
5727
- # reaches it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a
5728
- # different variable and does not count.
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 `{}`.
5729
7900
  def body_content_mutations_on(body, var_name, shadows)
5730
7901
  calls = []
5731
7902
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
5732
- next unless descendant.is_a?(Prism::CallNode)
5733
- next unless CONTENT_MUTATORS.include?(descendant.name)
5734
-
5735
- receiver = descendant.receiver
5736
- next unless receiver.is_a?(Prism::LocalVariableReadNode)
5737
- next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
7903
+ name, site = content_mutation_target(descendant) { |receiver| receiver.depth == scope_nesting(ancestors) }
7904
+ next unless name == var_name
5738
7905
 
5739
- calls << descendant
5740
- record_shadows(shadows, descendant, ancestors)
7906
+ calls << site
7907
+ record_shadows(shadows, site, ancestors)
5741
7908
  end
5742
7909
  calls
5743
7910
  end
@@ -5877,6 +8044,29 @@ module Rigor
5877
8044
  MutationWidening::HASH_MUTATORS)).freeze
5878
8045
  private_constant :CONTENT_MUTATORS
5879
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
+
5880
8070
  # The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
5881
8071
  # method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
5882
8072
  # `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.