rigortype 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (550) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +45 -13
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
  8. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
  9. data/docs/handbook/01-getting-started.md +6 -3
  10. data/docs/handbook/11-sig-gen.md +85 -3
  11. data/docs/handbook/appendix-phpstan.md +4 -4
  12. data/docs/install.md +20 -0
  13. data/docs/manual/01-installation.md +67 -26
  14. data/docs/manual/02-cli-reference.md +455 -113
  15. data/docs/manual/03-configuration.md +76 -5
  16. data/docs/manual/04-diagnostics.md +5 -4
  17. data/docs/manual/06-baseline.md +6 -4
  18. data/docs/manual/08-skills.md +11 -0
  19. data/docs/manual/09-editor-integration.md +21 -2
  20. data/docs/manual/10-mcp-server.md +38 -2
  21. data/docs/manual/12-caching.md +4 -3
  22. data/docs/manual/14-rails-quickstart.md +1 -1
  23. data/docs/manual/19-effect-labels.md +3 -4
  24. data/docs/manual/plugins/rigor-actionpack.md +32 -15
  25. data/docs/manual/plugins/rigor-activesupport-core-ext.md +23 -1
  26. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  27. data/lib/rigor/analysis/baseline.rb +24 -27
  28. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  29. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  30. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  31. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  32. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +12 -4
  33. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  34. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +85 -13
  35. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +16 -4
  36. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -1
  37. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  38. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  39. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +41 -12
  40. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  41. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +3 -3
  42. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  43. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +9 -2
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  46. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +2 -2
  49. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +2 -2
  50. data/lib/rigor/analysis/check_rules.rb +720 -141
  51. data/lib/rigor/analysis/crash_signature.rb +45 -3
  52. data/lib/rigor/analysis/dependency_recorder.rb +237 -80
  53. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  54. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  55. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  57. data/lib/rigor/analysis/edit_replay.rb +93 -6
  58. data/lib/rigor/analysis/fact_store.rb +3 -3
  59. data/lib/rigor/analysis/incremental.rb +11 -1
  60. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +4 -3
  61. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  62. data/lib/rigor/analysis/incremental_session.rb +306 -53
  63. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  64. data/lib/rigor/analysis/rule_catalog.rb +39 -3
  65. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  66. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  67. data/lib/rigor/analysis/run_stats.rb +2 -2
  68. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  69. data/lib/rigor/analysis/runner/declaration_position.rb +8 -8
  70. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +264 -112
  71. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +4 -9
  72. data/lib/rigor/analysis/runner/pool_coordinator.rb +173 -328
  73. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  74. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  75. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  76. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  77. data/lib/rigor/analysis/runner.rb +298 -109
  78. data/lib/rigor/analysis/self_call_resolution_recorder.rb +62 -23
  79. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  80. data/lib/rigor/analysis/template_units.rb +9 -1
  81. data/lib/rigor/analysis/worker_session.rb +65 -71
  82. data/lib/rigor/bleeding_edge.rb +22 -10
  83. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  84. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  85. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  86. data/lib/rigor/cache/descriptor.rb +6 -5
  87. data/lib/rigor/cache/file_digest.rb +39 -31
  88. data/lib/rigor/cache/incremental_snapshot.rb +95 -17
  89. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  90. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  91. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  92. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  93. data/lib/rigor/cache/rbs_descriptor.rb +90 -4
  94. data/lib/rigor/cache/rbs_environment.rb +8 -1
  95. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  96. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  97. data/lib/rigor/cache/store.rb +11 -10
  98. data/lib/rigor/ci_detector.rb +1 -1
  99. data/lib/rigor/cli/annotate_command.rb +24 -19
  100. data/lib/rigor/cli/baseline_command.rb +160 -57
  101. data/lib/rigor/cli/catalogue.rb +256 -0
  102. data/lib/rigor/cli/check_command.rb +112 -104
  103. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  104. data/lib/rigor/cli/command.rb +15 -1
  105. data/lib/rigor/cli/coverage_command.rb +21 -8
  106. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  107. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  108. data/lib/rigor/cli/coverage_scan.rb +3 -5
  109. data/lib/rigor/cli/diff_command.rb +25 -20
  110. data/lib/rigor/cli/docs_command.rb +1 -2
  111. data/lib/rigor/cli/doctor_command.rb +12 -12
  112. data/lib/rigor/cli/effects_command.rb +50 -40
  113. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  114. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  115. data/lib/rigor/cli/effects_renderer.rb +2 -1
  116. data/lib/rigor/cli/effects_snapshot_command.rb +26 -18
  117. data/lib/rigor/cli/explain_command.rb +18 -8
  118. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  119. data/lib/rigor/cli/json_document.rb +41 -0
  120. data/lib/rigor/cli/lens_command.rb +29 -10
  121. data/lib/rigor/cli/lsp_command.rb +250 -23
  122. data/lib/rigor/cli/mcp_command.rb +12 -7
  123. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  124. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  125. data/lib/rigor/cli/options.rb +40 -1
  126. data/lib/rigor/cli/plugin_command.rb +93 -46
  127. data/lib/rigor/cli/plugins_command.rb +17 -18
  128. data/lib/rigor/cli/plugins_renderer.rb +10 -6
  129. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  130. data/lib/rigor/cli/protection_renderer.rb +2 -1
  131. data/lib/rigor/cli/show_bleedingedge_command.rb +21 -15
  132. data/lib/rigor/cli/sig_gen_command.rb +92 -27
  133. data/lib/rigor/cli/skill_command.rb +32 -3
  134. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  135. data/lib/rigor/cli/skill_describe.rb +2 -1
  136. data/lib/rigor/cli/trace_command.rb +14 -9
  137. data/lib/rigor/cli/triage_command.rb +10 -11
  138. data/lib/rigor/cli/triage_renderer.rb +2 -1
  139. data/lib/rigor/cli/type_of_command.rb +16 -11
  140. data/lib/rigor/cli/type_of_project_scope.rb +1 -4
  141. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  142. data/lib/rigor/cli/type_scan_command.rb +14 -9
  143. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  144. data/lib/rigor/cli/unused_command.rb +27 -17
  145. data/lib/rigor/cli/upgrade_command.rb +5 -0
  146. data/lib/rigor/cli.rb +214 -56
  147. data/lib/rigor/config_audit.rb +33 -19
  148. data/lib/rigor/configuration/severity_profile.rb +14 -5
  149. data/lib/rigor/configuration.rb +104 -35
  150. data/lib/rigor/effects/collector.rb +53 -21
  151. data/lib/rigor/effects/definition_context.rb +0 -2
  152. data/lib/rigor/effects/envelope.rb +1 -1
  153. data/lib/rigor/effects/framework_units.rb +1 -1
  154. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  155. data/lib/rigor/effects/propagator.rb +1 -1
  156. data/lib/rigor/effects/registry.rb +2 -2
  157. data/lib/rigor/effects/snapshot.rb +1 -1
  158. data/lib/rigor/effects/taint_cause.rb +0 -2
  159. data/lib/rigor/effects/unit_scan.rb +9 -13
  160. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -33
  161. data/lib/rigor/environment/class_registry.rb +3 -3
  162. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  163. data/lib/rigor/environment/core_root.rb +77 -0
  164. data/lib/rigor/environment/default_libraries.rb +18 -1
  165. data/lib/rigor/environment/deferred_signature_plan.rb +381 -0
  166. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  167. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  168. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  169. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  170. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  171. data/lib/rigor/environment/member_consistency.rb +56 -7
  172. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  173. data/lib/rigor/environment/project_path_gems.rb +124 -0
  174. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  175. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  176. data/lib/rigor/environment/rbs_hierarchy.rb +12 -15
  177. data/lib/rigor/environment/rbs_loader.rb +772 -110
  178. data/lib/rigor/environment/reflection.rb +7 -7
  179. data/lib/rigor/environment/required_features.rb +0 -3
  180. data/lib/rigor/environment/ruby_library_roots.rb +1 -1
  181. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  182. data/lib/rigor/environment.rb +169 -42
  183. data/lib/rigor/flow_contribution/merger.rb +4 -4
  184. data/lib/rigor/inference/acceptance.rb +136 -40
  185. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  186. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  187. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  188. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  189. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  190. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  191. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  192. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  193. data/lib/rigor/inference/block_run_count.rb +185 -0
  194. data/lib/rigor/inference/budget_trace.rb +8 -2
  195. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  196. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  197. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  198. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  199. data/lib/rigor/inference/callee_mutation_chain.rb +88 -77
  200. data/lib/rigor/inference/callee_write_census.rb +815 -0
  201. data/lib/rigor/inference/captured_locals.rb +2 -0
  202. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  203. data/lib/rigor/inference/clause_widening.rb +1 -1
  204. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  205. data/lib/rigor/inference/construction_escape.rb +258 -0
  206. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  207. data/lib/rigor/inference/declaration_walk.rb +4 -0
  208. data/lib/rigor/inference/deep_erasure.rb +39 -7
  209. data/lib/rigor/inference/def_handle.rb +1 -1
  210. data/lib/rigor/inference/def_node_resolver.rb +33 -15
  211. data/lib/rigor/inference/def_return_typer.rb +47 -5
  212. data/lib/rigor/inference/definer_resolution.rb +19 -9
  213. data/lib/rigor/inference/definers_index.rb +162 -0
  214. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  215. data/lib/rigor/inference/element_read_widening.rb +49 -3
  216. data/lib/rigor/inference/error_info.rb +16 -0
  217. data/lib/rigor/inference/expression_typer.rb +1559 -320
  218. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  219. data/lib/rigor/inference/flow_tracer.rb +31 -14
  220. data/lib/rigor/inference/fork_map.rb +3 -0
  221. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  222. data/lib/rigor/inference/global_write_census.rb +81 -5
  223. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  224. data/lib/rigor/inference/hkt_body.rb +1 -2
  225. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  226. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  227. data/lib/rigor/inference/hkt_registry.rb +3 -5
  228. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  229. data/lib/rigor/inference/indexed_narrowing.rb +61 -15
  230. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  231. data/lib/rigor/inference/ivar_writer_census.rb +154 -8
  232. data/lib/rigor/inference/key_presence_guard.rb +51 -24
  233. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  234. data/lib/rigor/inference/last_line.rb +5 -2
  235. data/lib/rigor/inference/loop_value_record.rb +66 -14
  236. data/lib/rigor/inference/match_rebinding/frame.rb +57 -0
  237. data/lib/rigor/inference/match_rebinding/yielding_match.rb +101 -0
  238. data/lib/rigor/inference/match_rebinding.rb +28 -5
  239. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  240. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  241. data/lib/rigor/inference/method_dispatcher/block_folding.rb +44 -30
  242. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  243. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +3 -3
  244. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  245. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  246. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  247. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  248. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  249. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  250. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  251. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +1 -1
  252. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  253. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +51 -27
  254. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  255. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +606 -78
  256. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  257. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  258. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +3 -3
  259. data/lib/rigor/inference/method_dispatcher/set_folding.rb +9 -7
  260. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +467 -84
  261. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  262. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  263. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  264. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  265. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  266. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  267. data/lib/rigor/inference/method_dispatcher.rb +123 -25
  268. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  269. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  270. data/lib/rigor/inference/module_function_state.rb +24 -3
  271. data/lib/rigor/inference/multi_target_binder.rb +2 -2
  272. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  273. data/lib/rigor/inference/mutation_widening.rb +199 -29
  274. data/lib/rigor/inference/narrowing.rb +238 -50
  275. data/lib/rigor/inference/operand_effects.rb +43 -2
  276. data/lib/rigor/inference/optimistic_origin.rb +134 -9
  277. data/lib/rigor/inference/origin_lookup.rb +1 -1
  278. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  279. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  280. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  281. data/lib/rigor/inference/precision_scanner.rb +1 -1
  282. data/lib/rigor/inference/project_method_ownership.rb +30 -9
  283. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  284. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  285. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  286. data/lib/rigor/inference/project_yielder.rb +11 -17
  287. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  288. data/lib/rigor/inference/receiver_alias.rb +20 -0
  289. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  290. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  291. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +57 -17
  292. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -16
  293. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  294. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  295. data/lib/rigor/inference/remover_holder_scan.rb +287 -0
  296. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  297. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  298. data/lib/rigor/inference/scope_indexer/certainty.rb +0 -1
  299. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  300. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  301. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +70 -26
  302. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  303. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  304. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +295 -0
  305. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  306. data/lib/rigor/inference/scope_indexer.rb +2213 -226
  307. data/lib/rigor/inference/self_call_ivar_writes.rb +103 -12
  308. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  309. data/lib/rigor/inference/set_mutation.rb +189 -0
  310. data/lib/rigor/inference/singleton_hook_decline.rb +42 -8
  311. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  312. data/lib/rigor/inference/statement_evaluator.rb +3268 -358
  313. data/lib/rigor/inference/string_mutation.rb +14 -0
  314. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  315. data/lib/rigor/inference/synthetic_method_scanner.rb +40 -17
  316. data/lib/rigor/inference/toplevel_def_census.rb +67 -0
  317. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  318. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  319. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  320. data/lib/rigor/inference/void_tail_summary.rb +29 -9
  321. data/lib/rigor/language_server/buffer_table.rb +3 -3
  322. data/lib/rigor/language_server/completion_provider.rb +26 -20
  323. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  324. data/lib/rigor/language_server/diagnostic_publisher.rb +8 -3
  325. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  326. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  327. data/lib/rigor/language_server/hover_provider.rb +1 -7
  328. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  329. data/lib/rigor/language_server/project_context.rb +39 -1
  330. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  331. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  332. data/lib/rigor/language_server/server.rb +7 -3
  333. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  334. data/lib/rigor/lens/claims/comparison.rb +78 -0
  335. data/lib/rigor/lens/claims/request.rb +131 -0
  336. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  337. data/lib/rigor/lens/claims.rb +208 -0
  338. data/lib/rigor/lens/contract_reader.rb +5 -0
  339. data/lib/rigor/lens/document.rb +1 -1
  340. data/lib/rigor/lens/file_reader.rb +21 -8
  341. data/lib/rigor/lens/plugins.rb +1 -1
  342. data/lib/rigor/lens/run.rb +8 -0
  343. data/lib/rigor/lens.rb +1 -1
  344. data/lib/rigor/library_probe.rb +68 -0
  345. data/lib/rigor/mcp/server.rb +73 -2
  346. data/lib/rigor/mcp.rb +1 -1
  347. data/lib/rigor/memo_audit.rb +282 -0
  348. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  349. data/lib/rigor/plugin/base.rb +15 -2
  350. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  351. data/lib/rigor/plugin/io_boundary.rb +26 -4
  352. data/lib/rigor/plugin/loader.rb +25 -8
  353. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  354. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  355. data/lib/rigor/plugin/manifest.rb +16 -16
  356. data/lib/rigor/plugin/registry.rb +16 -5
  357. data/lib/rigor/plugin/services.rb +2 -3
  358. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  359. data/lib/rigor/plugin/trust_policy.rb +48 -2
  360. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  361. data/lib/rigor/plugin.rb +6 -8
  362. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  363. data/lib/rigor/project_environment.rb +16 -2
  364. data/lib/rigor/protection/analysis_guard.rb +1 -1
  365. data/lib/rigor/protection/discovery_seed.rb +44 -5
  366. data/lib/rigor/protection/mutation_cache.rb +4 -1
  367. data/lib/rigor/protection/mutator.rb +1 -1
  368. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  369. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  370. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  371. data/lib/rigor/rbs_extended.rb +2 -3
  372. data/lib/rigor/reflection/constant_ancestors.rb +81 -22
  373. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  374. data/lib/rigor/reflection/declarations.rb +58 -0
  375. data/lib/rigor/reflection/project_method.rb +177 -0
  376. data/lib/rigor/reflection.rb +2 -0
  377. data/lib/rigor/rooted_glob.rb +32 -1
  378. data/lib/rigor/run_scoped_slots.rb +38 -0
  379. data/lib/rigor/runtime/jit.rb +1 -1
  380. data/lib/rigor/scope/discovery_index.rb +168 -8
  381. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  382. data/lib/rigor/scope/resolution_chain.rb +901 -54
  383. data/lib/rigor/scope.rb +644 -37
  384. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  385. data/lib/rigor/sig_gen/classification.rb +18 -1
  386. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  387. data/lib/rigor/sig_gen/generator.rb +588 -66
  388. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  389. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  390. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  391. data/lib/rigor/sig_gen/method_candidate.rb +37 -4
  392. data/lib/rigor/sig_gen/observation_collector.rb +14 -7
  393. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  394. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  395. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  396. data/lib/rigor/sig_gen/renderer.rb +15 -9
  397. data/lib/rigor/sig_gen/skip_reason_catalog.rb +62 -2
  398. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  399. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  400. data/lib/rigor/sig_gen/write_result.rb +23 -12
  401. data/lib/rigor/sig_gen/writer.rb +238 -37
  402. data/lib/rigor/sig_gen.rb +1 -0
  403. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  404. data/lib/rigor/source/node_children.rb +13 -0
  405. data/lib/rigor/type/acceptance_router.rb +2 -2
  406. data/lib/rigor/type/accepts_result.rb +1 -1
  407. data/lib/rigor/type/app.rb +4 -6
  408. data/lib/rigor/type/combinator.rb +173 -21
  409. data/lib/rigor/type/constant.rb +4 -5
  410. data/lib/rigor/type/hash_shape.rb +21 -40
  411. data/lib/rigor/type/interface.rb +60 -0
  412. data/lib/rigor/type/union.rb +1 -1
  413. data/lib/rigor/type.rb +1 -0
  414. data/lib/rigor/type_node/identifier.rb +1 -2
  415. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  416. data/lib/rigor/type_node/string_literal.rb +1 -2
  417. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  418. data/lib/rigor/type_node/union.rb +3 -6
  419. data/lib/rigor/type_of_memo/serving.rb +99 -0
  420. data/lib/rigor/type_of_memo/stats.rb +43 -0
  421. data/lib/rigor/type_of_memo.rb +337 -0
  422. data/lib/rigor/typing_state.rb +1094 -0
  423. data/lib/rigor/value_semantics.rb +1 -10
  424. data/lib/rigor/version.rb +1 -1
  425. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  426. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  427. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  428. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +38 -36
  429. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  430. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -4
  431. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  432. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  433. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  434. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  435. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +3 -3
  436. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +115 -36
  437. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  438. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  439. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  440. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  441. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  442. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -3
  443. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  444. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  445. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  446. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  447. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  448. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +132 -33
  449. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -6
  450. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  451. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +54 -17
  452. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  453. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  454. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  455. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +11 -14
  456. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  457. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  458. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  459. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +30 -9
  460. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +55 -13
  461. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  462. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  463. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  464. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  465. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  466. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  467. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  468. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  469. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  470. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  471. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  472. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  473. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  474. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  475. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +8 -7
  476. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  477. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  478. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  479. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  480. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  481. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +8 -2
  482. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  483. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  484. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  485. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  486. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  487. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +200 -4
  488. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  489. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  490. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  491. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +4 -3
  492. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  493. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +5 -5
  494. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  495. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  496. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  497. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +3 -2
  498. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  499. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  500. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +210 -3
  501. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  502. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  503. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  504. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +97 -24
  505. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  506. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  507. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +4 -8
  508. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  509. data/sig/rigor/analysis/baseline.rbs +0 -3
  510. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  511. data/sig/rigor/analysis/fact_store.rbs +2 -2
  512. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  513. data/sig/rigor/cache/descriptor.rbs +14 -0
  514. data/sig/rigor/cli/diff_command.rbs +1 -1
  515. data/sig/rigor/cli/explain_command.rbs +1 -1
  516. data/sig/rigor/effects/registry.rbs +2 -2
  517. data/sig/rigor/environment.rbs +4 -1
  518. data/sig/rigor/inference.rbs +4 -1
  519. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  520. data/sig/rigor/plugin/base.rbs +53 -18
  521. data/sig/rigor/plugin/effect_attribution.rbs +3 -3
  522. data/sig/rigor/plugin/effect_edge.rbs +1 -1
  523. data/sig/rigor/plugin/effect_entry_points.rbs +3 -3
  524. data/sig/rigor/plugin/interfaces.rbs +51 -0
  525. data/sig/rigor/plugin/io_boundary.rbs +3 -3
  526. data/sig/rigor/plugin/manifest.rbs +9 -9
  527. data/sig/rigor/plugin/node_context.rbs +21 -0
  528. data/sig/rigor/plugin/protocol_contract.rbs +3 -7
  529. data/sig/rigor/reflection.rbs +37 -0
  530. data/sig/rigor/scope.rbs +43 -4
  531. data/sig/rigor/trinary.rbs +1 -0
  532. data/sig/rigor/type.rbs +9 -3
  533. data/sig/rigor.rbs +3 -2
  534. data/skills/rigor-ask/SKILL.md +1 -0
  535. data/skills/rigor-plugin-author/SKILL.md +4 -4
  536. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  537. data/skills/rigor-plugin-review/SKILL.md +3 -3
  538. data/skills/rigor-project-init/references/01-detect.md +1 -0
  539. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +17 -2
  540. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  541. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  542. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  543. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  544. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  545. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  546. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  547. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  548. data/skills/rigor-type-oracle/SKILL.md +26 -1
  549. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  550. metadata +82 -3
@@ -10,6 +10,9 @@ require_relative "../source/constant_path"
10
10
  require_relative "../source/node_children"
11
11
  require_relative "../source/node_walker"
12
12
  require_relative "../analysis/self_call_resolution_recorder"
13
+ require_relative "../run_scoped_slots"
14
+ require_relative "../typing_state"
15
+ require_relative "../type_of_memo"
13
16
  require_relative "block_call_timing"
14
17
  require_relative "block_repetition"
15
18
  require_relative "project_yielder"
@@ -19,6 +22,7 @@ require_relative "body_fixpoint"
19
22
  require_relative "budget_trace"
20
23
  require_relative "captured_locals"
21
24
  require_relative "closure_escape_analyzer"
25
+ require_relative "self_preserving_block"
22
26
  require_relative "receiver_blind_block"
23
27
  require_relative "def_node_resolver"
24
28
  require_relative "dynamic_origin"
@@ -40,6 +44,7 @@ require_relative "mutation_widening"
40
44
  require_relative "narrowing"
41
45
  require_relative "optimistic_origin"
42
46
  require_relative "receiver_alias"
47
+ require_relative "recursion_rebinding"
43
48
  require_relative "repeated_or_writes"
44
49
  require_relative "singleton_object_constant"
45
50
  require_relative "refined_dispatch"
@@ -259,17 +264,36 @@ module Rigor
259
264
  @typing_node = typing_node
260
265
  end
261
266
 
267
+ # ADR-129 WD2 — the calling thread's {TypingState::State}, cached on the typer the first time a slot is read, so
268
+ # a later read is an ivar and an array index rather than a `Thread.current` lookup. A typer lives for one query on
269
+ # one thread; an object kept across threads must not keep one (see {Analysis::IncrementalSession}'s effects
270
+ # evaluator), or it would write into a state whose thread's {RunScopedSlots.release} has already run. A frame
271
+ # install whose block's value is the answer calls {TypingState.current} instead: Rigor's own analysis does not
272
+ # adopt this memoised reader's type at a self-call (#525's self-purity), so the install's answer would read as
273
+ # `Dynamic` through it.
274
+ def typing_state = @typing_state ||= TypingState.current
275
+
262
276
  def type_of(node)
263
277
  previous = @typing_node
264
278
  @typing_node = node
279
+ # ADR-129 G1′: under the audit, the Frame declarations are asserted around every `type_of`.
280
+ audited = MemoAudit.enabled?
281
+ MemoAudit.enter_type_of if audited
265
282
  result = if FlowTracer.active?
266
283
  FlowTracer.trace_node(node) { untraced_type_of(node) }
284
+ elsif TypeOfMemo.enabled? && @tracer.nil? && @operand_types.nil?
285
+ # ADR-129 stage 3 — the shadow memo (G1); shut under a tracer and for a typer with instance state.
286
+ state = typing_state
287
+ TypeOfMemo.shadow(state, scope, node, callee: !state.stack(INFERENCE_GUARD).empty?) do
288
+ untraced_type_of(node)
289
+ end
267
290
  else
268
291
  untraced_type_of(node)
269
292
  end
270
293
  result
271
294
  ensure
272
295
  @typing_node = previous
296
+ MemoAudit.leave_type_of if audited
273
297
  end
274
298
 
275
299
  def untraced_type_of(node)
@@ -298,8 +322,10 @@ module Rigor
298
322
  # unchanged before skipping the callee's symbol dependents. nil for an abstract / bodyless def, or when
299
323
  # the memo refuses a transient result — the caller treats either as "not provably stable" (keeps the
300
324
  # dependents), the conservative direction.
301
- def return_type_for(def_node, receiver, arg_types)
302
- infer_user_method_return(def_node, receiver, arg_types)
325
+ #
326
+ # Issue #1237 — `positionals_unknown:` re-drives a key a splat call observed, binding it the way that call did.
327
+ def return_type_for(def_node, receiver, arg_types, positionals_unknown: false)
328
+ infer_user_method_return(def_node, receiver, arg_types, positionals_unknown: positionals_unknown)
303
329
  end
304
330
 
305
331
  # The `receiver[args]` read a compound index write performs before it stores (`c[k] += v` reads `c[k]`),
@@ -316,7 +342,7 @@ module Rigor
316
342
  # `name` anyway. Minting a `Prism::CallNode` for the read would hand plugins a node that is not in the
317
343
  # tree, through a constructor whose field list changed inside the `prism` range the gemspec accepts.
318
344
  def implicit_index_read_type(node, receiver, arg_types)
319
- return splat_index_read_type(node, receiver, arg_types) if node.arguments&.arguments&.any?(Prism::SplatNode)
345
+ return splat_index_read_type(node, receiver, arg_types) if splat_arguments?(node)
320
346
 
321
347
  try_overriding_def_dispatch(node, receiver, arg_types, method_name: :[]) ||
322
348
  index_read_dispatch(node, receiver, arg_types) ||
@@ -341,32 +367,60 @@ module Rigor
341
367
  recorded = self.class.recorded_inject_accumulator(block, scope)
342
368
  return recorded if recorded
343
369
 
344
- try_block_inject_fold(call_node, receiver, arg_types)
370
+ # ADR-129 WD1 — a read-through Table's miss: writing another Table from it touches.
371
+ typing_state.memo_miss(INJECT_ACCUMULATOR) { try_block_inject_fold(call_node, receiver, arg_types) }
345
372
  self.class.recorded_inject_accumulator(block, scope)
346
373
  end
347
374
 
348
- INJECT_ACCUMULATOR_KEY = :__rigor_inject_accumulator__
349
- private_constant :INJECT_ACCUMULATOR_KEY
375
+ # ADR-129 WD1 — a Table, and a read-through cache of the computation's own value: a record read back answers
376
+ # what typing the call again from the same scope would compute.
377
+ INJECT_ACCUMULATOR = TypingState.table(
378
+ :inject_accumulator,
379
+ run_scoped: true, read_through: true, entries: TypingState::WeakKeyMapEntries,
380
+ reason: "keyed by block node; a read-through cache of the fold that the call's own typing computes"
381
+ )
382
+ private_constant :INJECT_ACCUMULATOR
350
383
 
351
384
  # The accumulator type {#record_inject_accumulator} stored for `block` when the call was typed from `scope`
352
- # itself (by identity), else nil.
385
+ # itself (by identity) in the current ADR-55 summary generation (#2767, {LoopValueRecord.generation}), else nil.
353
386
  def self.recorded_inject_accumulator(block, scope)
354
- slot = Thread.current[INJECT_ACCUMULATOR_KEY]&.[](block)
355
- slot && slot[0].equal?(scope) ? slot[1] : nil
387
+ state = TypingState.current
388
+ slot = state[INJECT_ACCUMULATOR]&.[](block)
389
+ state.log_read(INJECT_ACCUMULATOR, block, slot || TypingState::ABSENT)
390
+ slot && slot[0].equal?(scope) && slot[2] == LoopValueRecord.generation ? slot[1] : nil
356
391
  end
357
392
 
358
393
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
359
394
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
360
395
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
361
- # empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives on
362
- # `Thread.current`, independent of any one `ExpressionTyper` instance.
363
- def self.harvest_return_memo
364
- slot = Thread.current[RETURN_MEMO_KEY]
365
- return {} if slot.nil?
396
+ # empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives in the thread's
397
+ # {TypingState}, independent of any one `ExpressionTyper` instance.
398
+ #
399
+ # `bucket` defaults to the calling thread's current one; `Runner#run` keeps the bucket its run filled
400
+ # ({.current_return_memo}) and passes it, since the run releases the thread's slot when it ends.
401
+ #
402
+ # Issue #3160 — the entries are what the memo STORED. A def one of whose calls it refused to store was observed
403
+ # under a key no entry lists; {.return_memo_refused?} says which.
404
+ def self.harvest_return_memo(bucket = current_return_memo)
405
+ return {} if bucket.nil?
406
+
407
+ bucket.transform_values { |per_def| per_def.except(RETURN_MEMO_REFUSED).values }
408
+ end
366
409
 
367
- slot[1].transform_values(&:values)
410
+ # Issue #3160 — whether `bucket` served a result of `def_node` that it refused to store (ADR-84 WD3). That
411
+ # result reached its caller, so the def's stored entries are not every key it was observed under.
412
+ def self.return_memo_refused?(bucket, def_node)
413
+ bucket&.[](def_node)&.key?(RETURN_MEMO_REFUSED) || false
368
414
  end
369
415
 
416
+ # Issue #3160 — the per-def key under which {#consult_and_store_return_memo} notes that it served a result it
417
+ # did not store. No call descriptor is a Symbol, so it never meets a real key.
418
+ RETURN_MEMO_REFUSED = :refused
419
+ private_constant :RETURN_MEMO_REFUSED
420
+
421
+ # The calling thread's return memo bucket (`{ def_node => { descriptor => MemoEntry } }`), or nil.
422
+ def self.current_return_memo = TypingState.current[RETURN_MEMO]&.last
423
+
370
424
  # Issue #720 — the value the block passed by the call site currently being re-typed evaluates to, or
371
425
  # `nil` outside such a frame. Read by {#type_of_yield}.
372
426
  #
@@ -375,22 +429,19 @@ module Rigor
375
429
  # inference rebuilds freely. It is installed unconditionally — with `nil` — at every user-method
376
430
  # inference frame, so a callee reached WITHOUT a block from inside a yielding body cannot read its
377
431
  # caller's block.
378
- YIELD_VALUE_KEY = :__rigor_yield_value_type__
379
- private_constant :YIELD_VALUE_KEY
432
+ YIELD_VALUE = TypingState.frame(
433
+ :yield_value_type, reason: "the caller's block value, installed per frame",
434
+ witness: "spec/fixtures/typing_state_witnesses/yield_value_type.rb"
435
+ )
436
+ private_constant :YIELD_VALUE
380
437
 
381
438
  def self.current_yield_value_type
382
- Thread.current[YIELD_VALUE_KEY]
439
+ TypingState.current[YIELD_VALUE]
383
440
  end
384
441
 
385
442
  # Runs `block` with `type` installed as the frame's yield value, restoring the previous frame on exit.
386
- def self.with_yield_value_type(type)
387
- previous = Thread.current[YIELD_VALUE_KEY]
388
- Thread.current[YIELD_VALUE_KEY] = type
389
- begin
390
- yield
391
- ensure
392
- Thread.current[YIELD_VALUE_KEY] = previous
393
- end
443
+ def self.with_yield_value_type(type, &)
444
+ TypingState.current.with(YIELD_VALUE, type, &)
394
445
  end
395
446
 
396
447
  # Issue #1125 — the argument types of the call currently being re-typed into the body being walked,
@@ -398,37 +449,48 @@ module Rigor
398
449
  # `m` itself was called with, so {#call_arg_types} expands the forwarding node to THIS list instead of
399
450
  # the `Dynamic[top]` a bare `Prism::ForwardingArgumentsNode` types as.
400
451
  #
401
- # A thread-local for the same reason the yield value is (see {YIELD_VALUE_KEY}): `...` names the
452
+ # A thread-local for the same reason the yield value is (see {YIELD_VALUE}): `...` names the
402
453
  # frame's caller, not a binding, and the body is walked through scopes the inference rebuilds freely.
403
454
  # It is installed unconditionally — with nil — at every user-method inference frame, so a body reached
404
455
  # without `...` cannot read its caller's argument list. Because each frame installs its OWN list, a
405
456
  # chain `a(...) -> b(...) -> c(...)` threads through: `b`'s frame holds the types `a` expanded and
406
457
  # `c`'s holds `b`'s. The list is the callee's call-site `arg_types`, which is what keeps the ADR-84
407
458
  # return memo's `(def_node, receiver, arg_types)` key complete for a forwarding def.
408
- FORWARDED_ARGS_KEY = :__rigor_forwarded_call_arg_types__
409
- private_constant :FORWARDED_ARGS_KEY
459
+ FORWARDED_ARGS = TypingState.frame(:forwarded_call_arg_types,
460
+ reason: "the caller's frozen argument list, installed per frame",
461
+ witness: "spec/fixtures/typing_state_witnesses/forwarded_call_arg_types.rb")
462
+ private_constant :FORWARDED_ARGS
410
463
 
411
464
  def self.current_forwarded_arg_types
412
- Thread.current[FORWARDED_ARGS_KEY]
465
+ TypingState.current[FORWARDED_ARGS]
413
466
  end
414
467
 
415
- # Runs `block` with `types` installed as the frame's forwarded argument list, restoring the previous
468
+ # Runs `block` with `types` (frozen) installed as the frame's forwarded argument list, restoring the previous
416
469
  # frame on exit.
417
- def self.with_forwarded_arg_types(types, &block)
418
- previous = Thread.current[FORWARDED_ARGS_KEY]
419
- Thread.current[FORWARDED_ARGS_KEY] = types
420
- begin
421
- block.call
422
- ensure
423
- Thread.current[FORWARDED_ARGS_KEY] = previous
424
- end
470
+ def self.with_forwarded_arg_types(types, &)
471
+ TypingState.current.with(FORWARDED_ARGS, types, &)
425
472
  end
426
473
 
427
474
  # The two call-site channels a user-method body may read from its caller — the block a `yield`
428
475
  # reaches ({#with_yield_value_type}) and the argument list `...` re-supplies
429
476
  # ({#with_forwarded_arg_types}) — installed together, both even when nil, for exactly one frame.
477
+ #
478
+ # The frame also clears the reflective-ivar repeat depth ({ReflectiveIvarWrite.in_repeating_body}): it says
479
+ # the CALLER's loop body is walked once for many runs, while the callee's body starts from its own scope and
480
+ # reads its own write. The depth only widens and the return memo's key has no term for it, so a body first
481
+ # reached from a `for` body stored a wider return that every later caller was served. It clears the caller's
482
+ # jump sinks and retry frames too ({StatementEvaluator.outside_caller_sinks}): a `next`, `break` or raise point
483
+ # in the callee's body is never the caller's, and a recursive re-walk of the caller's own block otherwise
484
+ # recorded the callee's arms into it. The block-body
485
+ # threading suppression is the same kind of caller flag and is NOT cleared here (issue #2782): it is what
486
+ # keeps a fold from nesting through callee frames, and clearing it re-walked unstorable frames
487
+ # exponentially, while keying the memo on it cost a fifth of `lib`'s allocations.
430
488
  def self.with_call_site_frame(yield_type, forwarded_arg_types, &)
431
- with_yield_value_type(yield_type) { with_forwarded_arg_types(forwarded_arg_types, &) }
489
+ ReflectiveIvarWrite.outside_repeating_body do
490
+ StatementEvaluator.outside_caller_sinks do
491
+ TypingState.current.with_pair(YIELD_VALUE, yield_type, FORWARDED_ARGS, forwarded_arg_types, &)
492
+ end
493
+ end
432
494
  end
433
495
 
434
496
  private
@@ -1052,7 +1114,7 @@ module Rigor
1052
1114
  # optimistic-carrier decline) and one terminating-arm rule, and cannot drift apart again. Only the
1053
1115
  # value is read; the post-scope belongs to whichever walk owns the statement.
1054
1116
  def type_of_conditional(node)
1055
- LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1117
+ LoopValueRecord.fetch(node, scope) || LoopValueRecord.miss { scope.evaluate(node, tracer: tracer).first }
1056
1118
  end
1057
1119
 
1058
1120
  def type_of_else(node)
@@ -1100,7 +1162,6 @@ module Rigor
1100
1162
  conditions = when_node.respond_to?(:conditions) ? when_node.conditions : []
1101
1163
  body_scope, next_scope = Narrowing.case_when_scopes(node.predicate, conditions, clause_scope)
1102
1164
  certainty = case_when_branch_certainty(subject_type, when_node)
1103
- # :no — drop the branch
1104
1165
  candidates << case_arm_type(when_node, body_scope) unless certainty == :no
1105
1166
  if certainty == :yes
1106
1167
  reached_yes = true
@@ -1284,7 +1345,7 @@ module Rigor
1284
1345
  def type_of_loop(node)
1285
1346
  return Type::Combinator.constant_of(nil) if break_free_condition_loop?(node)
1286
1347
 
1287
- LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1348
+ LoopValueRecord.fetch(node, scope) || LoopValueRecord.miss { scope.evaluate(node, tracer: tracer).first }
1288
1349
  end
1289
1350
 
1290
1351
  def break_free_condition_loop?(node)
@@ -1321,7 +1382,7 @@ module Rigor
1321
1382
  # to bare `Nominal[Range]` when no endpoint contributes a typable shape.
1322
1383
  def nominal_range_for_endpoints(left_node, right_node)
1323
1384
  endpoints = [left_node, right_node].compact.map { |n| range_endpoint_element_type(n) }
1324
- endpoints.reject! { |t| t.equal?(Type::Combinator.untyped) }
1385
+ endpoints.reject! { |t| Type::Combinator.untyped?(t) }
1325
1386
  return Type::Combinator.nominal_of("Range") if endpoints.empty?
1326
1387
 
1327
1388
  Type::Combinator.nominal_of("Range", type_args: [Type::Combinator.union(*endpoints)])
@@ -1519,9 +1580,8 @@ module Rigor
1519
1580
 
1520
1581
  # Stable single-hop chain narrowing — `receiver.method` after an `is_a?` / `kind_of?` / `instance_of?`
1521
1582
  # predicate established the narrowing on the dominated edge. The call MUST be no-arg + no-block +
1522
- # rooted at a local-var / ivar read; everything else falls through to the standard dispatcher. ROADMAP
1523
- # § Future cycles — "Method-call receiver narrowing across stable receivers" — Law-of-Demeter-justified
1524
- # single-hop scope.
1583
+ # rooted at a local-var / ivar read; everything else falls through to the standard dispatcher. The
1584
+ # single hop is Law-of-Demeter-justified.
1525
1585
  def method_chain_narrowing_for(node)
1526
1586
  return nil unless node.is_a?(Prism::CallNode)
1527
1587
  return nil unless node.block.nil?
@@ -1558,13 +1618,20 @@ module Rigor
1558
1618
  # engine walks can enumerate: a plugin's `dynamic_return` answer and the ADR-16 synthetic-method
1559
1619
  # index. They are members of `self` exactly as an `attr_reader` is, so a top-level `def` must not
1560
1620
  # bind ahead of them either.
1621
+ #
1622
+ # Issue #2861 — a bind the #316 gate declines answers `Dynamic[Top]`, never the RBS method of the same name: the
1623
+ # block's real `self` reaches either a same-named method of its own or the top-level `def`, which shadows
1624
+ # `Kernel#format` and every other `Object` / `Kernel` method on the way. Falling through typed
1625
+ # `format(r).bit_length` against `Kernel#format`'s `String` on code that runs the project's `def format`.
1561
1626
  def try_local_def_dispatch(node, receiver, arg_types, block_type = nil)
1562
- local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1563
- return nil unless local_def
1627
+ return nil unless node.receiver.nil?
1628
+
1629
+ local_def = scope.bindable_top_level_def_for(node.name)
1630
+ return declined_top_level_def_answer(node) if local_def.nil?
1564
1631
  return nil if self_type_answers?(node.name)
1565
1632
  return nil if plugin_member_answers?(node, receiver)
1566
1633
 
1567
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types, block_type)
1634
+ local_inference = infer_top_level_user_method(local_def, receiver, node, arg_types, block_type)
1568
1635
  return local_inference if local_inference
1569
1636
 
1570
1637
  # The local def matches by name but the inference was disqualified — the parameter shape is too
@@ -1576,6 +1643,16 @@ module Rigor
1576
1643
  dynamic_top
1577
1644
  end
1578
1645
 
1646
+ # Issue #2861 — the answer for an implicit-self call whose top-level `def` the #316 gate declined: nil (dispatch
1647
+ # as before) when the project defines no top-level `def` of the name, else `Dynamic[Top]`. The gate declines only
1648
+ # where `self_type` is nil, so no member of `self` ({#self_type_answers?}, {#plugin_member_answers?}) can answer.
1649
+ def declined_top_level_def_answer(node)
1650
+ return nil unless scope.opaque_block_self? && scope.self_type.nil?
1651
+ return nil if scope.top_level_def_for(node.name).nil?
1652
+
1653
+ dynamic_top
1654
+ end
1655
+
1579
1656
  # ADR-110 WD1 / #856 — the receiver's own `def` outranks a declaration that only an ANCESTOR carries.
1580
1657
  #
1581
1658
  # RBS resolves through the fully built method table, so `RbsDispatch` answers a subclass call with a
@@ -1708,8 +1785,15 @@ module Rigor
1708
1785
  return true if scope.discovered_method_through_ancestors?(class_name, method_name, :instance)
1709
1786
  return true if meta_member?(class_name, method_name)
1710
1787
  return true if resolve_user_def_through_ancestors(class_name, method_name)
1788
+ return true if rbs_ancestor_answers?(class_name, method_name)
1789
+
1790
+ # Issue #2968 — a class on the chain defines methods under names no table holds (`attr_accessor(*FLAGS)`, a
1791
+ # splatted `Struct.new(*FIELDS)`), so the name may be one of them, ahead of the top-level `def`.
1792
+ return true if Scope::DiscoveryIndex.open_names_on_chain?(scope, class_name, :instance)
1711
1793
 
1712
- rbs_ancestor_answers?(class_name, method_name)
1794
+ # Issue #2994 — a class on the chain mixes in a module no table names (`include(*MODS)`, `include helper`), so
1795
+ # the name may be that module's, ahead of the top-level `def`.
1796
+ Scope::DiscoveryIndex.unnamed_mixin_on_chain?(scope, class_name)
1713
1797
  end
1714
1798
 
1715
1799
  # The RBS arm of {#instance_self_answers?}. A project class is usually absent from the RBS
@@ -1736,7 +1820,13 @@ module Rigor
1736
1820
  return true if scope.discovered_method_through_ancestors?(class_name, method_name, :singleton)
1737
1821
  return true unless scope.singleton_def_through_ancestors(class_name, method_name).first.nil?
1738
1822
 
1739
- rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :singleton), class_name)
1823
+ declared = safe_rbs_method_definition(class_name, method_name, :singleton)
1824
+ return true if rbs_declared_on_class?(declared, class_name)
1825
+
1826
+ # Issue #2994 — a class object on the chain that a module no table names may extend through a hook (the
1827
+ # `:include_hook` open mark of a class with `include(*MODS)`), a class that `extend(*EMODS)`s, or an extended
1828
+ # module whose own module set is unknown, may answer any name ahead of the top-level `def`.
1829
+ Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods).unnamed_mixin?
1740
1830
  end
1741
1831
 
1742
1832
  # A `Struct.new(:a, :b)` / `Data.define(:a, :b)` member accessor. The layouts are a discovery table of
@@ -1771,7 +1861,164 @@ module Rigor
1771
1861
  def call_type_for(node)
1772
1862
  return safe_navigation_call_type(node) if node.safe_navigation?
1773
1863
 
1774
- attribute_write_value(node, call_dispatch_type_for(node))
1864
+ memo_object_value(node, class_test_find_value(node, attribute_write_value(node, call_dispatch_type_for(node))))
1865
+ end
1866
+
1867
+ CLASS_TEST_FINDERS = Set[:find, :detect, :rfind].freeze
1868
+ CLASS_TEST_PREDICATES = Set[:is_a?, :kind_of?, :instance_of?].freeze
1869
+ private_constant :CLASS_TEST_FINDERS, :CLASS_TEST_PREDICATES
1870
+
1871
+ # Issue #2719 — `find` / `detect` / `rfind` whose block is one class test on its one parameter
1872
+ # (`ancestors.rfind { |n| n.is_a?(Prism::DefNode) }`) returns an element that passed the test, or `nil`. The
1873
+ # dispatched `E?` is narrowed by that test, through the same {Narrowing} rule an `if n.is_a?(C)` edge takes, and
1874
+ # keeps its `nil`. Only an answer that already carries `nil` is narrowed, so an untyped receiver's `Dynamic`
1875
+ # stays as it was; a test that leaves no element (`Bot`, a class the element type is disjoint from) keeps the
1876
+ # dispatched answer too, since `nil` alone would be a verdict that the guard never matches. Any other block
1877
+ # shape, an `ifnone` argument, and a block with more than one parameter or statement keep today's answer.
1878
+ def class_test_find_value(node, result)
1879
+ param = class_test_find_parameter(node)
1880
+ return result if param.nil?
1881
+ return result unless result.is_a?(Type::Union) && result.members.any? { |m| nil_constant?(m) }
1882
+ return result unless element_or_nil?(node, result)
1883
+ # A refinement in effect may replace the finder with a body whose answer only happens to read as an element
1884
+ # or nil (`refine(Array) { def find = [2.5, 1, nil].sample }`), so the call keeps its answer.
1885
+ return result unless scope.in_effect_refinements(node).empty?
1886
+
1887
+ truthy, = Narrowing.predicate_scopes(node.block.body.body.first, scope.with_local(param, result))
1888
+ narrowed = truthy.local(param)
1889
+ return result if narrowed.nil?
1890
+
1891
+ non_nil = Narrowing.narrow_non_nil(narrowed)
1892
+ return result if non_nil.is_a?(Type::Bot) || introduces_dynamic?(non_nil, result)
1893
+
1894
+ Type::Combinator.union(non_nil, Type::Combinator.constant_of(nil))
1895
+ end
1896
+
1897
+ def nil_constant?(type) = type.is_a?(Type::Constant) && type.value.nil?
1898
+
1899
+ # True when the dispatched answer is exactly the receiver's element type or nil: the call really answers "an
1900
+ # element the block accepted, or nil". Whatever else wins the dispatch (a refinement, a reopened `Array` or
1901
+ # `Enumerable`, a gem's override) and answers anything else keeps its answer, and so does a receiver whose
1902
+ # element type cannot be read.
1903
+ def element_or_nil?(node, result)
1904
+ type = type_of(node.receiver)
1905
+ members = type.is_a?(Type::Union) ? type.members : [type]
1906
+ elements = members.map { |member| MethodDispatcher::IteratorDispatch.element_type_of(member) }
1907
+ return false if elements.include?(nil)
1908
+
1909
+ result == Type::Combinator.union(*elements, Type::Combinator.constant_of(nil))
1910
+ end
1911
+
1912
+ # A narrowing that cannot order an element's class against the tested one answers `Dynamic[top]` for it (a
1913
+ # project class with no RBS, typically); that is the test's "cannot tell", not a narrower element, so the
1914
+ # dispatched answer stands.
1915
+ def introduces_dynamic?(narrowed, result)
1916
+ members = narrowed.is_a?(Type::Union) ? narrowed.members : [narrowed]
1917
+ members.any? { |member| member.is_a?(Type::Dynamic) && !result.members.include?(member) }
1918
+ end
1919
+
1920
+ # The block parameter's name when `node` is a block-only `find` / `detect` / `rfind` whose block takes exactly
1921
+ # one plain parameter and whose body is one `is_a?` / `kind_of?` / `instance_of?` call on that parameter with a
1922
+ # single constant argument; nil otherwise.
1923
+ def class_test_find_parameter(node)
1924
+ # `detect(ifnone)` answers `ifnone.call` when nothing matches, which is not an element. A guard: today the
1925
+ # dispatched answer of an argument-bearing `detect` never carries the `nil` the narrowing needs, so the
1926
+ # call would already keep its answer.
1927
+ return nil unless CLASS_TEST_FINDERS.include?(node.name) && node.arguments.nil?
1928
+
1929
+ block = node.block
1930
+ return nil unless block.is_a?(Prism::BlockNode)
1931
+
1932
+ name = single_block_parameter(block)
1933
+ return nil if name.nil?
1934
+
1935
+ body = block.body
1936
+ return nil unless body.is_a?(Prism::StatementsNode) && body.body.size == 1
1937
+
1938
+ return nil unless class_test_on?(body.body.first, name)
1939
+
1940
+ core_finder_call?(node) ? name : nil
1941
+ end
1942
+
1943
+ # The owners whose `find` / `detect` / `rfind` return an element the block accepted: core `Array` and
1944
+ # `Enumerable` (`Hash`, `Set` and `Range` take `Enumerable`'s).
1945
+ CORE_FINDER_OWNERS = %w[Array Enumerable].freeze
1946
+ private_constant :CORE_FINDER_OWNERS
1947
+
1948
+ # True when every member of the call's receiver dispatches `node.name` to core `Array` or `Enumerable`: the
1949
+ # method's RBS owner is one of {CORE_FINDER_OWNERS} and the project defines no method by that name on the
1950
+ # class or an ancestor. A project or gem `find` may return anything its block did not test
1951
+ # (`def find = yield(1) ? 2.0 : nil`), so the name alone is not enough. {#element_or_nil?} closes the spellings
1952
+ # this cannot see (a refinement, a reopened module the discovery walk does not reach).
1953
+ def core_finder_call?(node)
1954
+ receiver = node.receiver
1955
+ return false if receiver.nil?
1956
+
1957
+ type = type_of(receiver)
1958
+ members = type.is_a?(Type::Union) ? type.members : [type]
1959
+ members.all? { |member| core_finder_member?(member, node.name) }
1960
+ end
1961
+
1962
+ def core_finder_member?(member, method_name)
1963
+ class_name =
1964
+ case member
1965
+ when Type::Nominal then member.class_name
1966
+ when Type::Tuple then "Array"
1967
+ when Type::HashShape then "Hash"
1968
+ end
1969
+ return false if class_name.nil?
1970
+ return false if scope.discovered_method_through_ancestors?(class_name, method_name, :instance)
1971
+
1972
+ defined_in = safe_rbs_method_definition(class_name, method_name, :instance)&.defined_in
1973
+ return false if defined_in.nil?
1974
+
1975
+ owner = defined_in.to_s.delete_prefix("::")
1976
+ return false unless CORE_FINDER_OWNERS.include?(owner)
1977
+
1978
+ # A reopened core owner (`module Enumerable; def find = 2.5; end`) replaces the method for every includer,
1979
+ # which the ancestor walk above does not reach for a class whose ancestry RBS alone records (`Set`).
1980
+ !scope.discovered_method?(owner, method_name, :instance)
1981
+ end
1982
+
1983
+ def single_block_parameter(block)
1984
+ params = block.parameters
1985
+ return nil unless params.is_a?(Prism::BlockParametersNode) && params.locals.empty?
1986
+
1987
+ list = params.parameters
1988
+ return nil if list.nil? || list.requireds.size != 1
1989
+ return nil unless list.optionals.empty? && list.rest.nil? && list.posts.empty? &&
1990
+ list.keywords.empty? && list.keyword_rest.nil? && list.block.nil?
1991
+
1992
+ required = list.requireds.first
1993
+ required.is_a?(Prism::RequiredParameterNode) ? required.name : nil
1994
+ end
1995
+
1996
+ def class_test_on?(test, name)
1997
+ return false unless test.is_a?(Prism::CallNode) && CLASS_TEST_PREDICATES.include?(test.name)
1998
+ return false if test.block || test.safe_navigation?
1999
+
2000
+ receiver = test.receiver
2001
+ return false unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
2002
+
2003
+ args = test.arguments&.arguments
2004
+ !args.nil? && args.size == 1 &&
2005
+ (args.first.is_a?(Prism::ConstantReadNode) || args.first.is_a?(Prism::ConstantPathNode))
2006
+ end
2007
+
2008
+ # Issue #2411 — `recv.each_with_object(memo) { |x, acc| acc[k] = v }` returns the memo as the block's
2009
+ # stores leave it. The statement evaluator owns that join ({StatementEvaluator#each_with_object_value}),
2010
+ # and reading it here gives every value position the statement's answer: a method's tail is typed through
2011
+ # this typer, and read off the dispatch alone it was the memo argument's literal `{}` / `[]`. A `break` ends
2012
+ # the call with its own value, so the block's break arms ({#call_break_arm_types}) join the memo: the join
2013
+ # alone read `break :stop if x == 2; a << x` as `Array[1 | 2]`, where Ruby answers `:stop`.
2014
+ def memo_object_value(node, result)
2015
+ return result unless node.name == :each_with_object && node.block.is_a?(Prism::BlockNode)
2016
+
2017
+ memo = StatementEvaluator.new(scope: scope, tracer: tracer).each_with_object_value(node, result)
2018
+ return memo if memo.equal?(result)
2019
+
2020
+ breaks = call_break_arm_types(node)
2021
+ breaks.empty? ? memo : Type::Combinator.union(memo, *breaks)
1775
2022
  end
1776
2023
 
1777
2024
  # The RHS-value override for plain attribute / index writes (#520); a non-write call keeps the
@@ -1918,7 +2165,7 @@ module Rigor
1918
2165
  return type unless type.is_a?(Type::Constant) && [true, false].include?(type.value)
1919
2166
  # The carrier read itself keeps its declared answer even when that is a literal `true` /
1920
2167
  # `false` — the mark says its nil-freeness is a bet, not that the value's class widened.
1921
- return type if scope.optimistic_origins[node]
2168
+ return type if scope.optimistic_origin(node)
1922
2169
  return type if OptimisticOrigin.resolve(node, scope).nil?
1923
2170
 
1924
2171
  # Widen only toward what a miss really answers: a safe-navigation call answers `nil`, and
@@ -2048,7 +2295,7 @@ module Rigor
2048
2295
  return dynamic_top if name && refined_name_for?(receiver, name, list)
2049
2296
 
2050
2297
  block_name = symbol_block_argument(node)
2051
- dynamic_top if block_name && refined_anywhere?(block_name, list)
2298
+ dynamic_top if block_name && RefinedDispatch.refined_anywhere?(scope, block_name, list)
2052
2299
  end
2053
2300
 
2054
2301
  REFINED_NAMING_CALLS = Set[:send, :__send__, :public_send, :respond_to?, :method, :public_method].freeze
@@ -2084,17 +2331,6 @@ module Rigor
2084
2331
  end
2085
2332
  end
2086
2333
 
2087
- # ADR-121 WD7 — a names-wildcard row counts as a refinement of every name.
2088
- def refined_anywhere?(name, list)
2089
- refinements = scope.discovered_refinements
2090
- wildcard = Scope::DiscoveryIndex::REFINEMENT_WILDCARD
2091
- return true if list.include?(InEffectRefinements::UNKNOWN) &&
2092
- refinements.any? { |_refined, methods| methods.key?(name) || methods.key?(wildcard) }
2093
-
2094
- !RefinedDispatch.targets(refinements, name, list).nil? ||
2095
- !RefinedDispatch.targets(refinements, wildcard, list).nil?
2096
- end
2097
-
2098
2334
  # ADR-121 WD7 — does the call's lexical list (a plugin-declared module is the plugin's declaration and never
2099
2335
  # opaque) hold an opaque module? `list` is the full list, which is the lexical one when nothing is declared.
2100
2336
  def opaque_refinement_span?(node, list)
@@ -2132,7 +2368,7 @@ module Rigor
2132
2368
  return nil if winner.nil?
2133
2369
  return dynamic_top if winner == RefinedDispatch::UNKNOWN
2134
2370
 
2135
- refined_body_return(winner, node.name, Type::Combinator.nominal_of(class_name), arg_types, block_type) ||
2371
+ refined_body_return(winner, node, Type::Combinator.nominal_of(class_name), arg_types, block_type) ||
2136
2372
  dynamic_top
2137
2373
  end
2138
2374
 
@@ -2151,15 +2387,17 @@ module Rigor
2151
2387
  #
2152
2388
  # A body another file wrote is re-typed with that file's in-effect refinements at the `def` (its own module
2153
2389
  # among them), so a sibling override it calls answers from its refine body too.
2154
- def refined_body_return(winner, method_name, self_type, arg_types, block_type)
2390
+ def refined_body_return(winner, call_node, self_type, arg_types, block_type)
2155
2391
  def_node, foreign =
2156
- InEffectRefinements.refinement_def_with_query(scope, winner.module_name, winner.refined_class, method_name)
2392
+ InEffectRefinements.refinement_def_with_query(scope, winner.module_name, winner.refined_class, call_node.name)
2157
2393
  return nil if def_node.nil?
2158
2394
 
2159
2395
  refinements = foreign&.at(def_node.location.start_offset) do |name|
2160
2396
  InEffectRefinements.activated_modules(scope, name)
2161
2397
  end
2162
- infer_user_method_return(def_node, self_type, arg_types, yield_type: block_type, refinements: refinements)
2398
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2399
+ infer_user_method_return(def_node, self_type, arg_types, yield_type: block_type, refinements: refinements,
2400
+ positionals_unknown: positionals_unknown)
2163
2401
  rescue StandardError
2164
2402
  nil
2165
2403
  end
@@ -2179,6 +2417,151 @@ module Rigor
2179
2417
  end
2180
2418
  end
2181
2419
 
2420
+ # Issue #1237 — whether the call's argument list leaves the positional correspondence unknown: it holds a
2421
+ # `*splat`, or a `...` with no forwarding frame to expand it from. `call_arg_types` carries either as ONE
2422
+ # argument, so a body re-typed from that list bound it to one parameter: `def count(*keys) = keys.size`
2423
+ # called as `r.count(*[0, 1])` folded to `1` and drew an always-falsey `== 2`. A Tuple splat of known
2424
+ # arity is not expanded either.
2425
+ def splat_arguments?(node)
2426
+ arguments = node.arguments&.arguments
2427
+ return false if arguments.nil?
2428
+
2429
+ arguments.any?(Prism::SplatNode) ||
2430
+ (forwards_arguments?(arguments) && ExpressionTyper.current_forwarded_arg_types.nil?)
2431
+ end
2432
+
2433
+ # The argument list and the `positionals_unknown:` flag a body tier re-types a user `def` with. Under
2434
+ # {#splat_arguments?} the body is still re-typed, so a body that always raises still answers `bot` and a
2435
+ # return that does not read its arguments is kept, but with no positional correspondence
2436
+ # ({#bind_params_without_correspondence}): the list is the call's keyword shape alone. Only a trailing
2437
+ # keyword-hash argument the call writes and {#call_arg_type} types as a `HashShape` supplies one. Any
2438
+ # other call leaves the keywords unknown (an open empty shape), because a splatted array's last element
2439
+ # is passed as keywords when it is a `ruby2_keywords` hash.
2440
+ #
2441
+ # Issue #3157 — a call whose every splat {#expand_tuple_splats} can expand is bound as the equivalent
2442
+ # positional call. Issue #3099 — a literal `**{}` passes no argument and is dropped. Issue #3191 — any other
2443
+ # trailing keyword hash holding a double splat is bound with no positional correspondence unless it is typed
2444
+ # as a non-empty closed shape ({#unknown_double_splat?}).
2445
+ def positional_correspondence(call_node, arg_types)
2446
+ arguments = call_node.arguments&.arguments
2447
+ unless splat_arguments?(call_node)
2448
+ return [arg_types[0...-1], false] if literal_empty_double_splat?(arguments&.last)
2449
+ return [[UNKNOWN_KEYWORDS], true] if unknown_double_splat?(arguments&.last, arg_types.last)
2450
+
2451
+ return [arg_types, false]
2452
+ end
2453
+
2454
+ expanded = expand_tuple_splats(arguments, arg_types)
2455
+ return [expanded, false] if expanded
2456
+
2457
+ last = arguments.last
2458
+ keywords = arg_types.last if last.is_a?(Prism::KeywordHashNode) && keyword_shape?(arg_types.last)
2459
+ [[keywords || UNKNOWN_KEYWORDS], true]
2460
+ end
2461
+
2462
+ UNKNOWN_KEYWORDS = Type::Combinator.hash_shape_of({}, extra_keys: :open)
2463
+ private_constant :UNKNOWN_KEYWORDS
2464
+
2465
+ # Issue #3099 — whether `argument` is a keyword hash built only of literal empty double splats (`**{}`),
2466
+ # which Ruby passes as no argument at all.
2467
+ def literal_empty_double_splat?(argument)
2468
+ argument.is_a?(Prism::KeywordHashNode) &&
2469
+ argument.elements.all? do |element|
2470
+ element.is_a?(Prism::AssocSplatNode) && element.value.is_a?(Prism::HashNode) &&
2471
+ element.value.elements.empty?
2472
+ end
2473
+ end
2474
+
2475
+ # Issues #3099 / #3191 — whether `argument` is a keyword hash holding a double splat whose type (`type`) is
2476
+ # not a non-empty closed shape. Ruby passes such a hash as a trailing positional `Hash` to a method that
2477
+ # declares no keywords when it is non-empty, and passes nothing when it is empty, so the call does not say
2478
+ # how many positionals the callee receives. An empty closed shape (`**h`, `h = {}`) may be a stale read of
2479
+ # a hash a callee filled; an open or shapeless one (`**opts`), a mixed `k: 1, **h`, two splats `**h, **h`
2480
+ # and `**nil` are not typed as a shape at all ({#double_splat_hash_shape}). Bound as given, each left a
2481
+ # positional `Hash`: `def count(*keys) = keys.size` called `r.count(1, 2, **opts)` folded to `3`.
2482
+ def unknown_double_splat?(argument, type)
2483
+ argument.is_a?(Prism::KeywordHashNode) && argument.elements.any?(Prism::AssocSplatNode) &&
2484
+ !keyword_shape?(type)
2485
+ end
2486
+
2487
+ # Issue #3157 — the argument list with each `*splat` replaced by its elements, or nil to keep the
2488
+ # no-correspondence binding. It expands only when every splat's operand is an array literal, so its arity is
2489
+ # syntactic, and every element is {#provably_not_hash?}: a splatted array's last element is passed as
2490
+ # keywords when it is a `ruby2_keywords` hash. A splat of a local or any other expression is not expanded,
2491
+ # because a `Tuple` read there may be a stale fold of an array that has since grown or shrunk. A trailing
2492
+ # keyword hash must be a literal `**{}` (dropped) or a non-empty shape; an open, mixed or empty non-literal
2493
+ # one leaves the keywords unknown. A list holding `...` is not expanded: its argument types do not line up
2494
+ # with its argument nodes. The splat's own entry in `arg_types` is `Dynamic[top]` ({#type_of_non_value}),
2495
+ # so the literal's elements are typed here.
2496
+ #
2497
+ # Only a call outside every re-typed body frame expands (the main walk's call sites). Inside a frame a rest
2498
+ # parameter holds the caller's arguments, so a chain `m1(*a) = m2(*[a, 1]) + m2(*[a, a])` handed each level
2499
+ # a deeper element type and walked every combination of every level
2500
+ # (spec/rigor/inference/splat_chain_growth_spec.rb).
2501
+ def expand_tuple_splats(arguments, arg_types)
2502
+ return nil if forwards_arguments?(arguments)
2503
+ return nil unless typing_state.stack(INFERENCE_GUARD).empty?
2504
+
2505
+ last = arguments.last
2506
+ trailing_keywords = last.is_a?(Prism::KeywordHashNode)
2507
+ return nil if trailing_keywords && !literal_empty_double_splat?(last) && !keyword_shape?(arg_types.last)
2508
+
2509
+ expanded = []
2510
+ arguments.each_with_index do |argument, index|
2511
+ next if argument.equal?(last) && trailing_keywords && literal_empty_double_splat?(last)
2512
+ next expanded << arg_types[index] unless argument.is_a?(Prism::SplatNode)
2513
+
2514
+ elements = literal_splat_elements(argument)
2515
+ return nil if elements.nil?
2516
+
2517
+ expanded.concat(elements)
2518
+ end
2519
+ expanded
2520
+ end
2521
+
2522
+ # Whether a trailing keyword-hash argument's type is a shape with at least one pair. {#call_arg_type} types a
2523
+ # keyword hash as a shape only when it is closed.
2524
+ def keyword_shape?(type)
2525
+ type.is_a?(Type::HashShape) && !type.pairs.empty?
2526
+ end
2527
+
2528
+ # The element types of `*[a, b]`, each typed on its own, or nil unless the operand is an array literal whose
2529
+ # every element is {#provably_not_hash?}. A splat element (`*[*t]`) types `Dynamic[top]`
2530
+ # ({#type_of_non_value}), so it declines too.
2531
+ def literal_splat_elements(splat)
2532
+ literal = unparenthesized(splat.expression)
2533
+ return nil unless literal.is_a?(Prism::ArrayNode)
2534
+
2535
+ types = literal.elements.map { |element| type_of(element) }
2536
+ types if types.all? { |type| provably_not_hash?(type) }
2537
+ end
2538
+
2539
+ # Classes no instance of which is a `Hash`. A `Nominal` of any other class may be a `Hash` subclass.
2540
+ NON_HASH_CLASSES = %w[
2541
+ Integer Float Rational Complex String Symbol Array Range Regexp NilClass TrueClass FalseClass Proc
2542
+ ].to_set.freeze
2543
+ private_constant :NON_HASH_CLASSES
2544
+
2545
+ # `*([1, 2])`'s array literal, or the node itself when it is not a parenthesised single expression.
2546
+ def unparenthesized(node)
2547
+ return node unless node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
2548
+
2549
+ statements = node.body.body
2550
+ statements.size == 1 ? statements.first : node
2551
+ end
2552
+
2553
+ # Whether no value of `type` is a `Hash`. Anything this does not recognise answers false. A `Constant` never
2554
+ # carries a `Hash` (it holds scalars only).
2555
+ def provably_not_hash?(type)
2556
+ case type
2557
+ when Type::Constant, Type::Tuple, Type::IntegerRange then true
2558
+ when Type::Nominal then NON_HASH_CLASSES.include?(type.class_name)
2559
+ when Type::Union then type.members.all? { |member| provably_not_hash?(member) }
2560
+ when Type::Refined, Type::Difference then provably_not_hash?(type.base)
2561
+ else false
2562
+ end
2563
+ end
2564
+
2182
2565
  # The post-dispatch tiers for a call `MethodDispatcher` could not answer, in their historical
2183
2566
  # order; extracted from {#call_dispatch_type_for} whole.
2184
2567
  def dispatch_miss_result(node, receiver, arg_types, block_type = nil)
@@ -2388,16 +2771,16 @@ module Rigor
2388
2771
  #
2389
2772
  # - the receiver is not `Nominal[T]` for some T;
2390
2773
  # - no def_node is recorded for that class/method (the receiver is foreign or has only an RBS sig);
2391
- # - the def has no body, or has a parameter shape we cannot bind from the call's positional args;
2392
- # - the inference is already in progress for this (class, method, signature) tuple — recursion safety
2393
- # net.
2774
+ # - the def has no body, or has a parameter shape we cannot bind from the call's positional args.
2394
2775
  # v0.0.3 A — re-types a top-level (or DSL-block-nested) `def` discovered by `ScopeIndexer` under the
2395
2776
  # `TOP_LEVEL_DEF_KEY` sentinel. Mirrors the `infer_user_method_return` shape but uses the current
2396
2777
  # `scope.self_type` (or implicit `Object`) as the receiver carrier so the body's own self is
2397
2778
  # consistent with the call site's. Returns nil when the parameter shape disqualifies the def, when the
2398
- # body is empty, or when a recursion cycle is detected.
2399
- def infer_top_level_user_method(def_node, receiver, arg_types, block_type = nil)
2400
- infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
2779
+ # body is empty, or when the inference raises.
2780
+ def infer_top_level_user_method(def_node, receiver, call_node, arg_types, block_type = nil)
2781
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2782
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
2783
+ positionals_unknown: positionals_unknown)
2401
2784
  rescue StandardError
2402
2785
  nil
2403
2786
  end
@@ -2442,13 +2825,16 @@ module Rigor
2442
2825
 
2443
2826
  # True when `type` is a concrete value — a `Type::Constant` or a `Type::Tuple` whose elements are
2444
2827
  # (recursively) all value-pinned. ADR-55 slice 1: a value-pinned self-call result is adopted even
2445
- # inside a class/method body (where WD3 otherwise keeps non-`Bot` returns as `Dynamic[top]`). A
2446
- # concrete value at a call site is strictly more precise and can never enable an undefined-method or
2447
- # argument-type false positive — it is FP-neutral by construction.
2828
+ # inside a class/method body (where WD3 otherwise keeps non-`Bot` returns as `Dynamic[top]`). A concrete
2829
+ # value at a call site is strictly more precise and can never enable an undefined-method or argument-type
2830
+ # false positive — it is FP-neutral by construction.
2448
2831
  def fully_value_pinned?(type)
2449
2832
  case type
2450
2833
  when Type::Constant then true
2451
2834
  when Type::Tuple then type.elements.all? { |element| fully_value_pinned?(element) }
2835
+ when Type::HashShape
2836
+ # Issue #3115 — the shape {#pinned_shape_descriptor} keys an unroll on is as pinned as a result.
2837
+ type.closed? && type.optional_keys.empty? && type.pairs.each_value.all? { |value| fully_value_pinned?(value) }
2452
2838
  else false
2453
2839
  end
2454
2840
  end
@@ -2535,9 +2921,10 @@ module Rigor
2535
2921
  def_node, owner, raising = resolve_typing_def_with_owner(receiver.class_name, method_name)
2536
2922
  return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2537
2923
 
2924
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2538
2925
  result = infer_user_method_return(def_node, receiver, arg_types,
2539
2926
  self_fold_safe: fold_safe_call_receiver?(call_node),
2540
- yield_type: block_type)
2927
+ yield_type: block_type, positionals_unknown: positionals_unknown)
2541
2928
  return result if result.nil?
2542
2929
 
2543
2930
  degrade_if_overridable(result, owner, method_name, :instance)
@@ -2619,7 +3006,9 @@ module Rigor
2619
3006
  # proven to be the method Ruby calls, so the call falls through to `Dynamic`.
2620
3007
  return nil if scope.discovery.contested?(:discovered_singleton_def_nodes, [owner, method_name.to_sym])
2621
3008
 
2622
- result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
3009
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
3010
+ result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
3011
+ positionals_unknown: positionals_unknown)
2623
3012
  return result if result.nil?
2624
3013
 
2625
3014
  degrade_if_overridable(result, owner, method_name, :singleton)
@@ -2641,12 +3030,240 @@ module Rigor
2641
3030
  cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
2642
3031
  table = (cache[class_name.to_s] ||= {})
2643
3032
  key = method_name.to_sym
2644
- return table[key] if table.key?(key)
3033
+ if table.key?(key)
3034
+ return MemoAudit.check("class graph singleton def", table[key], class_name, key) do
3035
+ compute_singleton_def_with_owner(class_name, method_name)
3036
+ end
3037
+ end
3038
+
3039
+ table[key] = MemoAudit.key_audit("class graph singleton def") do
3040
+ compute_singleton_def_with_owner(class_name, method_name)
3041
+ end
3042
+ end
2645
3043
 
3044
+ def compute_singleton_def_with_owner(class_name, method_name)
2646
3045
  found = scope.singleton_def_through_ancestors(class_name, method_name)
2647
3046
  owner = found.last
2648
3047
  found = [nil, nil] if owner && SingletonExternalAhead.withhold?(scope, class_name, method_name, owner)
2649
- table[key] = found
3048
+ found = [nil, nil] if found.last && dynamic_singleton_definer_ahead?(class_name, method_name, found.last)
3049
+ found = [nil, nil] if found.last && singleton_hook_order_split?(class_name, method_name)
3050
+ found = [nil, nil] if found.last && singleton_mixin_split?(class_name, method_name, found)
3051
+ found = [nil, nil] if found.last && unseen_definer_split?(class_name, method_name, found)
3052
+ found
3053
+ end
3054
+
3055
+ # A definer no def table holds that Ruby may call instead of `found`: a hook-placed one, or a foreign `def C.x`.
3056
+ def unseen_definer_split?(class_name, method_name, found)
3057
+ hidden_hook_split?(class_name, method_name, found.last) || foreign_definer_behind_copy?(method_name, found)
3058
+ end
3059
+
3060
+ # Issue #3155 — a `def C.x` outside C's body (in the extended module, another class or the file) defines C's
3061
+ # own `x`, which no def table holds, only its object-singleton mark. A fold copy found on C may then be a
3062
+ # body Ruby does not call, so the typing read withholds.
3063
+ def foreign_definer_behind_copy?(method_name, found)
3064
+ node, owner = found
3065
+ return false unless singleton_copy_head(owner, node) != owner
3066
+
3067
+ SingletonHookDecline.foreign_singleton_definer?(scope, owner, method_name)
3068
+ end
3069
+
3070
+ # The hook-placed definers a found singleton `def` may not be the one Ruby calls through.
3071
+ def hidden_hook_split?(class_name, method_name, owner)
3072
+ hidden_hook_definer?(class_name, method_name, owner) || hook_block_rival?(class_name, method_name, owner)
3073
+ end
3074
+
3075
+ # Issue #3005 — a class object's own `def self.x` and a `def self.x` a concern's `included` block defines on it
3076
+ # replace each other in the order the `def` and the `include` run, which the tables do not hold, so the typing
3077
+ # read withholds when the answer is a class object's own `def` and its level's hook-block module defines the
3078
+ # name too.
3079
+ def hook_block_rival?(class_name, method_name, owner)
3080
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3081
+ index = chain.entries.index { |entry| !entry.external? && entry.side == :singleton && entry.name == owner }
3082
+ !index.nil? && chain.hook_block_rival?(scope, index, method_name)
3083
+ end
3084
+
3085
+ # Issue #1592 — a module a hook extends the class with may define the name private (`class_methods do private;
3086
+ # def x`), which Ruby refuses on an explicit receiver. The visibility rule does not reach that module, so the
3087
+ # typing read withholds rather than answer a call Ruby raises on. A module the class `extend`s itself is left to
3088
+ # that rule.
3089
+ def hidden_hook_definer?(class_name, method_name, owner)
3090
+ visibility = scope.discovered_method_visibility(owner, method_name)
3091
+ return false if visibility.nil? || visibility == :public
3092
+
3093
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3094
+ return false unless chain.entries.any? { |entry| entry.name == owner && entry.side == :instance }
3095
+
3096
+ scope.singleton_extends_of(class_name).none? do |raw|
3097
+ written = raw.delete_prefix("::")
3098
+ owner == written || owner.end_with?("::#{written}")
3099
+ end
3100
+ end
3101
+
3102
+ # Issue #1592 — whether to withhold `method_name`'s class-side answer: a class object on the chain has
3103
+ # hook-extended modules whose order against its own `extend`s is not a fact, and the orders give different
3104
+ # definers (the retro world's against the chain's, or, with no retro world, the class objects' alone against
3105
+ # the chain's). A `def` the extends fold copied onto a class object is read where Ruby finds it, at the extended
3106
+ # module's own entry, since the copy sits ahead of every module in both orders.
3107
+ def singleton_hook_order_split?(class_name, method_name)
3108
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3109
+ return false unless chain.hook_order_marked?
3110
+
3111
+ answer = positioned_singleton_definer(chain, method_name)
3112
+ retro = chain.retro_world
3113
+ return answer != positioned_singleton_definer(retro, method_name) if retro
3114
+
3115
+ answer != positioned_singleton_definer(chain, method_name, copies: true)
3116
+ end
3117
+
3118
+ # Issues #2998 and #3020 — whether a singleton-side mixin of a class object on the chain may answer ahead of
3119
+ # `found`: a named prepend master's order does not place, or a module no table names.
3120
+ def singleton_mixin_split?(class_name, method_name, found)
3121
+ singleton_prepend_split?(class_name, method_name, found) ||
3122
+ unnamed_singleton_mixin_split?(class_name, method_name)
3123
+ end
3124
+
3125
+ # Issue #2998 — whether to withhold the class-side answer `found` on a chain whose class objects have
3126
+ # singleton-side prepends of their own: master's order, which a chain that does not stand answers, places no
3127
+ # such prepend, so its definer may not be the one Ruby's order reaches first. A `def` the extends fold copied
3128
+ # onto a class object is compared as its module's. A definer from a prepend region whose order is not a fact
3129
+ # is withheld whichever order answers.
3130
+ def singleton_prepend_split?(class_name, method_name, found)
3131
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3132
+ return false unless chain.singleton_prepend_marked?
3133
+
3134
+ answer = positioned_singleton_definer(chain, method_name)
3135
+ return true if unordered_prepend_definer?(chain, answer)
3136
+
3137
+ node, owner = found
3138
+ head = scope.singleton_def_for(owner, method_name).equal?(node) ? singleton_copy_head(owner, node) : owner
3139
+ head != answer
3140
+ end
3141
+
3142
+ # Whether `answer` is a module in the prepend region of a class object on the chain whose prepends have no known
3143
+ # order: the entries between the start of that class's level and its own entry.
3144
+ def unordered_prepend_definer?(chain, answer)
3145
+ chain.prepend_order_nodes.any? do |node|
3146
+ level = chain.level_classes.index(node)
3147
+ next true if level.nil?
3148
+
3149
+ start = chain.level_starts[level]
3150
+ own = chain.entries.index { |entry| entry.side == :singleton && entry.name == node }
3151
+ next true if own.nil?
3152
+
3153
+ chain.entries[start...own].any? { |entry| entry.name == answer }
3154
+ end
3155
+ end
3156
+
3157
+ # Issue #3020 — whether to withhold the class-side answer on a chain whose class objects mix a module no table
3158
+ # names into their singleton side, which may define the name. A prepend (`class << self; prepend(*MODS)`) sits
3159
+ # ahead of the class object's own `def self.`, so a definer at or behind that class object's level is withheld;
3160
+ # an `extend(*MODS)` or a `class << self` `include(*MODS)` sits right behind it, so a definer behind the class
3161
+ # object's own entry is withheld and its own `def self.` still answers.
3162
+ def unnamed_singleton_mixin_split?(class_name, method_name)
3163
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3164
+ prepends = chain.unnamed_prepend_nodes
3165
+ extends = chain.unnamed_extend_nodes
3166
+ return false if prepends.empty? && extends.empty?
3167
+
3168
+ answer = positioned_singleton_definer(chain, method_name)
3169
+ prepends.any? { |node| !answered_ahead?(chain, answer, level_start_of(chain, node)) } ||
3170
+ extends.any? { |node| !answered_ahead?(chain, answer, own_singleton_index(chain, node)&.succ) }
3171
+ end
3172
+
3173
+ # Whether `answer` owns an entry before `stop` (nil when the position is unknown: never). A nil `answer` (no class
3174
+ # object's own def) owns none.
3175
+ def answered_ahead?(chain, answer, stop)
3176
+ !stop.nil? && chain.entries[0...stop].any? { |entry| entry.name == answer }
3177
+ end
3178
+
3179
+ def level_start_of(chain, node)
3180
+ level = chain.level_classes.index(node)
3181
+ level && chain.level_starts[level]
3182
+ end
3183
+
3184
+ def own_singleton_index(chain, node)
3185
+ chain.entries.index { |entry| entry.side == :singleton && entry.name == node }
3186
+ end
3187
+
3188
+ # The owner of the first `def` of the name along the chain: a fold copy is skipped, or with `copies:` read as
3189
+ # its module's, on the class objects alone (the answer master gives).
3190
+ def positioned_singleton_definer(chain, method_name, copies: false)
3191
+ chain.search(scope, side: copies ? :singleton : nil) do |entry|
3192
+ next if entry.external?
3193
+
3194
+ name = entry.name
3195
+ if entry.side == :singleton
3196
+ node = scope.singleton_def_for(name, method_name)
3197
+ next nil if node.nil?
3198
+
3199
+ head = singleton_copy_head(name, node)
3200
+ copies ? head : (name if head == name)
3201
+ elsif scope.user_def_for(name, method_name)
3202
+ name
3203
+ end
3204
+ end
3205
+ end
3206
+
3207
+ # The module a receiverless singleton `def` was written in when the extends fold copied it onto `owner`, else
3208
+ # `owner`.
3209
+ def singleton_copy_head(owner, node)
3210
+ head = node.receiver.nil? ? scope.discovery.recorded_def_nesting(node)&.first : nil
3211
+ head.nil? ? owner : head
3212
+ end
3213
+
3214
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
3215
+ private_constant :SINGLETON_WILDCARD_SLOT
3216
+
3217
+ # Issue #2284 — a project entry nearer than the `def`'s owner on the singleton chain may define the name
3218
+ # without a `def`, which the def tables do not hold: `class S < B; define_singleton_method(:x) { "s" }; end`
3219
+ # calls S's block, not `B.x`. The farther `def` is then withheld and the call types `Dynamic`. A class's own
3220
+ # singleton side is read from the visibility-contest census: `define_singleton_method(:x)`,
3221
+ # `singleton_class.define_method(:x)` / `attr_*` and a computed name contest the entry's slot whether or not a
3222
+ # `def` is recorded (#1984, #2013). An `extend`ed module's side is read from `discovered_methods`, which holds
3223
+ # its `attr_*` and `define_method` rows, and the module's `def self.extended` hook may define any name on the
3224
+ # extender, so it withholds too.
3225
+ #
3226
+ # An `inherited` hook anywhere on the chain past the receiver's own entry runs when the receiver's class is
3227
+ # created and may define any singleton method on it (`def self.inherited(s) = s.define_singleton_method(:x)`),
3228
+ # so a `def` the receiver only inherits is withheld then as well: a class's `def self.inherited`, or an
3229
+ # `inherited` an extended module defines.
3230
+ def dynamic_singleton_definer_ahead?(class_name, method_name, owner)
3231
+ name = method_name.to_sym
3232
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3233
+ return true if class_name.to_s != owner.to_s && inherited_hook_on_chain?(chain, class_name.to_s)
3234
+
3235
+ chain.entries.each do |entry|
3236
+ next if entry.external?
3237
+ return false if entry.name == owner.to_s
3238
+ return true if entry_defines_without_def?(entry, name)
3239
+ # Issue #1592 — a hook of a module the class includes may have extended it with a module no table names.
3240
+ return true if entry.side == :singleton && chain.hook_open_nodes.include?(entry.name)
3241
+ end
3242
+ false
3243
+ end
3244
+
3245
+ def inherited_hook_on_chain?(chain, receiver_name)
3246
+ chain.entries.any? do |entry|
3247
+ next false if entry.external? || entry.name == receiver_name
3248
+
3249
+ if entry.side == :singleton
3250
+ !scope.singleton_def_for(entry.name, :inherited).nil?
3251
+ else
3252
+ scope.discovered_method?(entry.name, :inherited, :instance)
3253
+ end
3254
+ end
3255
+ end
3256
+
3257
+ def entry_defines_without_def?(entry, name)
3258
+ unless entry.side == :singleton
3259
+ return scope.discovered_method?(entry.name, name, :instance) ||
3260
+ !scope.singleton_def_for(entry.name, :extended).nil?
3261
+ end
3262
+
3263
+ discovery = scope.discovery
3264
+ # No edge of its own: `singleton_def_through_ancestors`' chain search filed every entry ahead of the owner.
3265
+ discovery.contested?(:discovered_method_visibilities, [entry.name, [:singleton, name]]) ||
3266
+ discovery.contested?(:discovered_method_visibilities, [entry.name, SINGLETON_WILDCARD_SLOT])
2650
3267
  end
2651
3268
 
2652
3269
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
@@ -2672,7 +3289,9 @@ module Rigor
2672
3289
  def_node = SingletonObjectConstant.def_node_for(call_node, receiver, method_name, scope)
2673
3290
  return nil if def_node.nil?
2674
3291
 
2675
- infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
3292
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
3293
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
3294
+ positionals_unknown: positionals_unknown)
2676
3295
  rescue StandardError
2677
3296
  nil
2678
3297
  end
@@ -2684,8 +3303,14 @@ module Rigor
2684
3303
  # external entries this lookup passes over. Cross-file: the chain is built from the tables the runner
2685
3304
  # seeds from the project-wide pre-pass, cycle-guarded and capped at `Scope::ANCESTOR_WALK_LIMIT`.
2686
3305
 
2687
- CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
2688
- private_constant :CLASS_GRAPH_CACHE_KEY
3306
+ CLASS_GRAPH_CACHE = TypingState.memo(
3307
+ :class_graph_cache,
3308
+ run_scoped: true,
3309
+ audit: ["class graph user def", "class graph typing def", "class graph singleton def", "class graph yields",
3310
+ "class graph self pure"],
3311
+ reason: "pure functions of the frozen discovery index it keys on"
3312
+ )
3313
+ private_constant :CLASS_GRAPH_CACHE
2689
3314
 
2690
3315
  # Memo for the static class-graph resolvers below. They are pure functions of the scope's *frozen*
2691
3316
  # discovery tables — `user_def_for` / `superclass_of` / `includes_of` and the ancestor-name resolver
@@ -2703,8 +3328,8 @@ module Rigor
2703
3328
  #
2704
3329
  # ONE slot, replaced rather than accumulated — the same shape, and now the same key, as
2705
3330
  # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. `ExpressionTyper` is rebuilt per
2706
- # `Scope#type_of`, so the slot lives on `Thread.current` rather than on `self`. A `Scope` merges its
2707
- # file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
3331
+ # `Scope#type_of`, so the slot lives in the thread's {TypingState} rather than on `self`. A `Scope` merges
3332
+ # its file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
2708
3333
  # identity-keyed *store* grew one bucket per file and, because the keys ARE the tables, pinned every
2709
3334
  # file's whole discovery index for the length of the run. What that bought, measured over
2710
3335
  # `lib`+`plugins`: 552 buckets holding 1,834 entries between them, and 58 of 80,106 calls answered
@@ -2714,23 +3339,32 @@ module Rigor
2714
3339
  # because one `equal?` is cheaper than three identity-hash lookups.
2715
3340
  #
2716
3341
  # Steady-state cost is one `equal?` check and zero allocation; the array and the buckets are
2717
- # allocated once per index. (Pool mode forks per worker, so the `Thread.current` slot is
3342
+ # allocated once per index. (Pool mode forks per worker, so the per-thread slot is
2718
3343
  # process-local and never crosses a project boundary — and note that the parent's reported
2719
3344
  # `Memory peak` therefore does not see a worker's share of this at all.)
3345
+ #
3346
+ # ADR-129 WD1 — every bucket's hit goes through `MemoAudit.check`; an audit recomputation gets fresh buckets that
3347
+ # nothing stores, so it reads no entry and leaves none.
2720
3348
  def class_graph_buckets
3349
+ return fresh_class_graph_buckets if MemoAudit.enabled? && MemoAudit.suspended?
3350
+
2721
3351
  discovery = scope.discovery
2722
- slot = Thread.current[CLASS_GRAPH_CACHE_KEY]
3352
+ state = typing_state
3353
+ slot = state[CLASS_GRAPH_CACHE]
2723
3354
  unless slot && slot[0].equal?(discovery)
2724
3355
  # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
2725
3356
  # is a pure function of the same frozen index — the sibling resolver it walks reads nothing else.
2726
3357
  # `singleton_def` is added lazily by {#singleton_def_through_ancestors}'s caller.
2727
- slot = [discovery,
2728
- { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }]
2729
- Thread.current[CLASS_GRAPH_CACHE_KEY] = slot
3358
+ slot = [discovery, fresh_class_graph_buckets]
3359
+ state[CLASS_GRAPH_CACHE] = slot
2730
3360
  end
2731
3361
  slot[1]
2732
3362
  end
2733
3363
 
3364
+ def fresh_class_graph_buckets
3365
+ { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }
3366
+ end
3367
+
2734
3368
  # ADR-119 C2-b1 — the TYPING read of {#resolve_user_def_with_owner}: the same `[def_node, owner]` pair, but
2735
3369
  # answered through {DefinerResolution} so a chain that does not stand for the name (a conditional definer,
2736
3370
  # a conditional include, a fork, an external ancestor that may answer first) is `[nil, nil]` and the call
@@ -2745,17 +3379,26 @@ module Rigor
2745
3379
  cache = class_graph_buckets[:typing_def]
2746
3380
  table = (cache[class_name.to_s] ||= {})
2747
3381
  key = method_name.to_sym
2748
- return table[key] if table.key?(key)
2749
-
2750
- table[key] =
2751
- case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
2752
- in DefinerResolution::Known(answer: [node, owner])
2753
- [node, owner]
2754
- in DefinerResolution::UNKNOWN
2755
- [nil, nil, every_project_definer(class_name, method_name)]
2756
- in DefinerResolution::ABSENT
2757
- [nil, nil]
3382
+ if table.key?(key)
3383
+ return MemoAudit.check("class graph typing def", table[key], class_name, key) do
3384
+ compute_typing_def_with_owner(class_name, method_name)
2758
3385
  end
3386
+ end
3387
+
3388
+ table[key] = MemoAudit.key_audit("class graph typing def") do
3389
+ compute_typing_def_with_owner(class_name, method_name)
3390
+ end
3391
+ end
3392
+
3393
+ def compute_typing_def_with_owner(class_name, method_name)
3394
+ case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
3395
+ in DefinerResolution::Known(answer: [node, owner])
3396
+ [node, owner]
3397
+ in DefinerResolution::UNKNOWN
3398
+ [nil, nil, every_project_definer(class_name, method_name)]
3399
+ in DefinerResolution::ABSENT
3400
+ [nil, nil]
3401
+ end
2759
3402
  end
2760
3403
 
2761
3404
  # ADR-119 C2-b1 errata (the `bot` exception) — an UNKNOWN chain does not say WHICH definer Ruby calls, but
@@ -2769,9 +3412,11 @@ module Rigor
2769
3412
  return nil if defs.nil?
2770
3413
 
2771
3414
  self_fold_safe = fold_safe_call_receiver?(call_node)
3415
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2772
3416
  defs.each do |def_node|
2773
3417
  result = infer_user_method_return(def_node, receiver, arg_types,
2774
- self_fold_safe: self_fold_safe, yield_type: block_type)
3418
+ self_fold_safe: self_fold_safe, yield_type: block_type,
3419
+ positionals_unknown: positionals_unknown)
2775
3420
  return nil unless result.is_a?(Type::Bot)
2776
3421
  end
2777
3422
  Type::Combinator.bot
@@ -2846,9 +3491,15 @@ module Rigor
2846
3491
  cache = class_graph_buckets[:user_def]
2847
3492
  table = (cache[class_name.to_s] ||= {})
2848
3493
  key = method_name.to_sym
2849
- return table[key] if table.key?(key)
3494
+ if table.key?(key)
3495
+ return MemoAudit.check("class graph user def", table[key], class_name, key) do
3496
+ compute_user_def_with_owner(class_name, method_name)
3497
+ end
3498
+ end
2850
3499
 
2851
- table[key] = compute_user_def_with_owner(class_name, method_name)
3500
+ table[key] = MemoAudit.key_audit("class graph user def") do
3501
+ compute_user_def_with_owner(class_name, method_name)
3502
+ end
2852
3503
  end
2853
3504
 
2854
3505
  # ADR-24 slice 2 — the walk itself lives on `Scope` ({Scope#user_def_through_ancestors}); it reads
@@ -2885,8 +3536,12 @@ module Rigor
2885
3536
  dynamic_top
2886
3537
  end
2887
3538
 
2888
- OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
2889
- private_constant :OVERRIDE_GATE_CACHE_KEY
3539
+ OVERRIDE_GATE_CACHE = TypingState.memo(
3540
+ :overridable_method_gate,
3541
+ run_scoped: true, audit: "overridable method gate",
3542
+ reason: "pure functions of the frozen discovery index it keys on"
3543
+ )
3544
+ private_constant :OVERRIDE_GATE_CACHE
2890
3545
 
2891
3546
  # Memo for {#overridden_in_project?}, nested `kind → owner → method_name`. The predicate is a pure
2892
3547
  # function of the scope's frozen discovery tables, so that index's *identity* is what says whether a
@@ -2916,10 +3571,11 @@ module Rigor
2916
3571
  # keyed and bounded differently for exactly that reason.
2917
3572
  def override_gate_buckets
2918
3573
  discovery = scope.discovery
2919
- slot = Thread.current[OVERRIDE_GATE_CACHE_KEY]
3574
+ state = typing_state
3575
+ slot = state[OVERRIDE_GATE_CACHE]
2920
3576
  unless slot && slot[0].equal?(discovery)
2921
3577
  slot = [discovery, { instance: {}, singleton: {} }]
2922
- Thread.current[OVERRIDE_GATE_CACHE_KEY] = slot
3578
+ state[OVERRIDE_GATE_CACHE] = slot
2923
3579
  end
2924
3580
  slot[1]
2925
3581
  end
@@ -2930,10 +3586,18 @@ module Rigor
2930
3586
  # reopen of `owner` itself is NOT an override (monkey-patch reopen shares the owner identity). Memoized
2931
3587
  # per `(owner, method_name, kind)`.
2932
3588
  def overridden_in_project?(owner, method_name, kind)
3589
+ return compute_overridden_in_project?(owner, method_name, kind) if MemoAudit.enabled? && MemoAudit.suspended?
3590
+
2933
3591
  by_owner = (override_gate_buckets[kind][owner] ||= {})
2934
- return by_owner[method_name] if by_owner.key?(method_name)
3592
+ if by_owner.key?(method_name)
3593
+ return MemoAudit.check("overridable method gate", by_owner[method_name], owner, method_name) do
3594
+ compute_overridden_in_project?(owner, method_name, kind)
3595
+ end
3596
+ end
2935
3597
 
2936
- by_owner[method_name] = compute_overridden_in_project?(owner, method_name, kind)
3598
+ by_owner[method_name] = MemoAudit.key_audit("overridable method gate") do
3599
+ compute_overridden_in_project?(owner, method_name, kind)
3600
+ end
2937
3601
  end
2938
3602
 
2939
3603
  def compute_overridden_in_project?(owner, method_name, kind)
@@ -2958,16 +3622,23 @@ module Rigor
2958
3622
  EMPTY_REDEFINERS = [].freeze
2959
3623
  private_constant :EMPTY_REDEFINERS
2960
3624
 
2961
- METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
2962
- private_constant :METHOD_DEFINERS_INDEX_KEY
2963
-
2964
- # The second way. Two flat thread slots rather than one slot holding an array of ways: a
2965
- # `Thread.current[KEY] ||= […]` seeds the store with an array LITERAL, which folds to a tuple whose
2966
- # elements are pinned, and the engine then reads the `way[0].equal?(…)` guards below as always-falsey
2967
- # (`make check` fires `flow.always-truthy-condition` on this very file). Two keys read as two plain
2968
- # `Thread.current` reads and keep the guards analysable.
2969
- METHOD_DEFINERS_INDEX_ALT_KEY = :__rigor_method_definers_index_alt__
2970
- private_constant :METHOD_DEFINERS_INDEX_ALT_KEY
3625
+ METHOD_DEFINERS_INDEX = TypingState.memo(
3626
+ :method_definers_index,
3627
+ run_scoped: true, audit: "method definers index",
3628
+ reason: "pure functions of the def tables it keys on"
3629
+ )
3630
+ private_constant :METHOD_DEFINERS_INDEX
3631
+
3632
+ # The second way. Two flat slots rather than one slot holding an array of ways: a `slot ||= […]` seeds the
3633
+ # store with an array LITERAL, which folds to a tuple whose elements are pinned, and the engine then reads the
3634
+ # `way[0].equal?(…)` guards below as always-falsey (`make check` fires `flow.always-truthy-condition` on this
3635
+ # very file). Two slots read as two plain reads and keep the guards analysable.
3636
+ METHOD_DEFINERS_INDEX_ALT = TypingState.memo(
3637
+ :method_definers_index_alt,
3638
+ run_scoped: true, audit: "method definers index alt",
3639
+ reason: "pure functions of the def tables it keys on"
3640
+ )
3641
+ private_constant :METHOD_DEFINERS_INDEX_ALT
2971
3642
 
2972
3643
  # `method_name (Symbol) → [owner names]` inverted index over the instance / singleton def tables,
2973
3644
  # valid for exactly as long as the table it inverts: a new analysis generation, or a `Scope` that swaps
@@ -3000,36 +3671,60 @@ module Rigor
3000
3671
  # the singleton index.
3001
3672
  #
3002
3673
  # DELIBERATELY not keyed on the whole {Scope::DiscoveryIndex}, unlike {#class_graph_buckets} and
3003
- # {#override_gate_buckets} above. {#build_method_definers_index} reads the ONE table it is handed and
3004
- # nothing else, so the def tables are already the complete key, and the index object is a strictly
3674
+ # {#override_gate_buckets} above. {DefinersIndex.for} reads the ONE table it is handed, and the seed that
3675
+ # table was merged over, and nothing else, so the def tables are already the complete key, and the index
3676
+ # object is a strictly
3005
3677
  # narrower one: the project-seed scope carries a fresh index per file while its def tables stay the
3006
3678
  # same object. Over the 200 synthetic files above, an index-keyed memo misses all 400 times where a
3007
3679
  # def-table-keyed one misses 201 — it would rebuild the seed's 934-entry table once per file and hand
3008
3680
  # back the +22.6 % this bound exists to avoid. Check that number before widening this key to match
3009
3681
  # its siblings.
3682
+ #
3683
+ # ADR-129 WD1 — a hit in either way is audited against an index built with every memo bypassed, compared in
3684
+ # {DefinersIndex.canonical} form (a `Layered` index and a whole one hold the same owners, in any order).
3010
3685
  def method_definers_index(kind)
3011
- def_nodes = scope.discovered_def_nodes
3012
- singleton_def_nodes = scope.discovered_singleton_def_nodes
3013
- way = Thread.current[METHOD_DEFINERS_INDEX_KEY]
3014
- unless way && way[0].equal?(def_nodes) && way[1].equal?(singleton_def_nodes)
3015
- alt = Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY]
3016
- alt = nil unless alt && alt[0].equal?(def_nodes) && alt[1].equal?(singleton_def_nodes)
3017
- Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY] = way
3018
- way = alt || [def_nodes, singleton_def_nodes, nil, nil]
3019
- Thread.current[METHOD_DEFINERS_INDEX_KEY] = way
3020
- end
3021
3686
  singleton = kind == :singleton
3022
- way[singleton ? 3 : 2] ||= build_method_definers_index(singleton ? singleton_def_nodes : def_nodes)
3687
+ table = singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
3688
+ return DefinersIndex.for(table) if MemoAudit.enabled? && MemoAudit.suspended?
3689
+
3690
+ state = typing_state
3691
+ primary = current_definers_way?(state[METHOD_DEFINERS_INDEX])
3692
+ way = primary ? state[METHOD_DEFINERS_INDEX] : rotate_definers_ways(state)
3693
+ position = singleton ? 3 : 2
3694
+ index = way[position]
3695
+ return way[position] = MemoAudit.key_audit("method definers index") { DefinersIndex.for(table) } if index.nil?
3696
+
3697
+ audit_definers_index(primary, index, kind, table) if MemoAudit.enabled?
3698
+ index
3699
+ end
3700
+
3701
+ # Whether `way` was built over this scope's two def tables.
3702
+ def current_definers_way?(way)
3703
+ !way.nil? && way[0].equal?(scope.discovered_def_nodes) && way[1].equal?(scope.discovered_singleton_def_nodes)
3023
3704
  end
3024
3705
 
3025
- def build_method_definers_index(table)
3026
- index = {}
3027
- table.each do |class_name, methods|
3028
- next if class_name == Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
3706
+ # Makes the alternate way current when it was built over this scope's def tables, or a fresh way otherwise; the
3707
+ # way it replaces becomes the alternate.
3708
+ def rotate_definers_ways(state)
3709
+ alt = state[METHOD_DEFINERS_INDEX_ALT]
3710
+ alt = nil unless current_definers_way?(alt)
3711
+ state[METHOD_DEFINERS_INDEX_ALT] = state[METHOD_DEFINERS_INDEX]
3712
+ state[METHOD_DEFINERS_INDEX] =
3713
+ alt || [scope.discovered_def_nodes, scope.discovered_singleton_def_nodes, nil, nil]
3714
+ end
3029
3715
 
3030
- methods.each_key { |method_name| (index[method_name] ||= []) << class_name }
3716
+ # A hit in the current way (`primary`) or the alternate one, checked in {DefinersIndex.canonical} form.
3717
+ def audit_definers_index(primary, index, kind, table)
3718
+ canonical = DefinersIndex.canonical(index)
3719
+ if primary
3720
+ MemoAudit.check("method definers index", canonical, kind) do
3721
+ DefinersIndex.canonical(DefinersIndex.for(table))
3722
+ end
3723
+ else
3724
+ MemoAudit.check("method definers index alt", canonical, kind) do
3725
+ DefinersIndex.canonical(DefinersIndex.for(table))
3726
+ end
3031
3727
  end
3032
- index
3033
3728
  end
3034
3729
 
3035
3730
  # True when `candidate`'s ancestor chain (superclasses + included/prepended modules) reaches `owner` —
@@ -3042,18 +3737,30 @@ module Rigor
3042
3737
  !chain.search(scope) { |entry| entry.name == owner }.nil?
3043
3738
  end
3044
3739
 
3045
- INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
3046
- private_constant :INFERENCE_GUARD_KEY
3740
+ # ADR-129 WD3 — an id-determined Resource: {TypingState::State#push} and {TypingState::State#pop} each take a
3741
+ # fresh id, so the stack's contents are fixed while the id is and a read of it refuses nothing.
3742
+ INFERENCE_GUARD = TypingState.resource(
3743
+ :user_method_inference_stack,
3744
+ read_exemption: :id_determined,
3745
+ reason: "the ADR-55 recursion guard, pushed and popped in place by each inference frame"
3746
+ )
3747
+ private_constant :INFERENCE_GUARD
3047
3748
 
3048
- INFERENCE_UNROLL_FUEL_KEY = :__rigor_user_method_unroll_fuel__
3049
- private_constant :INFERENCE_UNROLL_FUEL_KEY
3749
+ INFERENCE_UNROLL_FUEL = TypingState.resource(
3750
+ :user_method_unroll_fuel,
3751
+ reason: "ADR-55 unroll fuel, consumed in place per outermost entry"
3752
+ )
3753
+ private_constant :INFERENCE_UNROLL_FUEL
3050
3754
 
3051
3755
  # ADR-55 slice 2 — thread-local fixpoint return-summary table, keyed by the plain `(receiver, method)`
3052
3756
  # signature (NOT the value-extended signature: extended frames from slice 1 share the same summary).
3053
3757
  # Each entry is `{ assumption:, consulted: }` where `assumption` is the current Kleene iterate (seeded
3054
3758
  # `bot`) and `consulted` flips true when an in-cycle re-entry returns it.
3055
- INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
3056
- private_constant :INFERENCE_SUMMARY_KEY
3759
+ INFERENCE_SUMMARY = TypingState.resource(
3760
+ :user_method_return_summary,
3761
+ reason: "ADR-55 Kleene summaries and their `:consulted` flag, rewritten in place"
3762
+ )
3763
+ private_constant :INFERENCE_SUMMARY
3057
3764
 
3058
3765
  # Per-thread append-only log of the seed depths of every in-flight summary `consult_summary` read
3059
3766
  # (ADR-55 slice 2 mutual-recursion soundness fix, 2026-06-12). Each fixpoint owner records the guard
@@ -3064,8 +3771,11 @@ module Rigor
3064
3771
  # `untyped` rather than fold one branch's seed into a "final" constant. Own-signature consults log
3065
3772
  # depth == own depth, and a nested fixpoint that completes within the evaluation logs depths > own
3066
3773
  # depth; neither is foreign. Cleared with the summary table when the guard stack drains to empty.
3067
- SUMMARY_CONSULT_DEPTHS_KEY = :__rigor_user_method_summary_consult_depths__
3068
- private_constant :SUMMARY_CONSULT_DEPTHS_KEY
3774
+ SUMMARY_CONSULT_DEPTHS = TypingState.resource(
3775
+ :user_method_summary_consult_depths,
3776
+ reason: "ADR-55 consult log, appended in place and read as a delta"
3777
+ )
3778
+ private_constant :SUMMARY_CONSULT_DEPTHS
3069
3779
 
3070
3780
  # ADR-57 follow-up — run-scoped memo for resolved user-method return types. The ADR-57 gate-open made
3071
3781
  # every resolved in-body self-call adopt the callee's inferred return, which re-types the callee body
@@ -3088,7 +3798,7 @@ module Rigor
3088
3798
  # post-hoc (ADR-84 WD3): candidacy only requires that the plain signature not already be on the
3089
3799
  # recursion guard stack (a frame inside its own cycle returns a Kleene iterate by construction), and
3090
3800
  # the store gate brackets the compute over the transient-event depth log
3091
- # (TRANSIENT_EVENT_DEPTHS_KEY), refusing only when an event referenced a frame BELOW the bracket's
3801
+ # (TRANSIENT_EVENT_DEPTHS), refusing only when an event referenced a frame BELOW the bracket's
3092
3802
  # entry depth — proof the ancestor context (an in-flight ancestor summary, the shared unroll fuel, a
3093
3803
  # possibly-ancestor-caused clamp) influenced the result. Events at-or-above the entry depth are the
3094
3804
  # compute's own deterministic machinery — a recursive method's own converged fixpoint stores from a
@@ -3110,11 +3820,70 @@ module Rigor
3110
3820
  # `[receiver, *args]` descriptor tuple; a callee may appear under at most two node identities per run
3111
3821
  # (the project-index parse every OTHER file resolves through, plus the defining file's own analysis
3112
3822
  # parse). Only the current generation's bucket is retained (single slot) — a rolled-over run's entries
3113
- # become garbage instead of accumulating across LSP runs. `ExpressionTyper` is rebuilt per
3114
- # `Scope#type_of`, so the store lives on `Thread.current`; fork-pool workers are separate processes,
3115
- # so it never crosses a project boundary (ADR-84 WD5).
3116
- RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
3117
- private_constant :RETURN_MEMO_KEY
3823
+ # become garbage instead of accumulating across LSP runs, and the run that filled the slot releases it
3824
+ # when it ends ({RunScopedSlots}; `Runner#run` keeps its bucket for `#return_summaries`).
3825
+ # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store lives in the thread's {TypingState}; fork-pool
3826
+ # workers are separate processes, so it never crosses a project boundary (ADR-84 WD5).
3827
+ #
3828
+ # ADR-129 WD1 — a Table, not a Memo: its WD3 serves a standalone result where a fresh compute inside a cycle
3829
+ # would be tainted, and its WD6 serves a stored `untyped`, so it is not extensional. Its reads and stores are
3830
+ # journaled ({#log_return_memo}).
3831
+ # ADR-129 WD3 — the return memo's entry protocol. The slot holds `[generation, bucket]` ({#return_memo_bucket}),
3832
+ # and a journal record keys an entry on `[def_node, memo_key]`. A replay writes into the bucket the slot holds: a
3833
+ # `type_of` memo entry lives no longer than its file's analysis, inside one run generation, so a slot with no
3834
+ # bucket means the entry outlived its run, which is a defect to surface rather than a bucket to invent.
3835
+ module ReturnMemoEntries
3836
+ module_function
3837
+
3838
+ def entry(slot_value, key)
3839
+ per_def = slot_value&.[](1)&.[](key[0])
3840
+ per_def.nil? ? TypingState::ABSENT : per_def.fetch(key[1], TypingState::ABSENT)
3841
+ end
3842
+
3843
+ def put(slot_value, key, value)
3844
+ (slot_value[1][key[0]] ||= {})[key[1]] = value
3845
+ end
3846
+
3847
+ def delete(slot_value, key)
3848
+ per_def = slot_value && slot_value[1][key[0]]
3849
+ per_def&.delete(key[1])
3850
+ end
3851
+
3852
+ def container(state, slot)
3853
+ state[slot] || raise(ArgumentError, "no return memo bucket to replay into: its entry outlived its run")
3854
+ end
3855
+ end
3856
+
3857
+ RETURN_MEMO = TypingState.table(
3858
+ :user_method_return_memo,
3859
+ run_scoped: true, computed: "return memo", entries: ReturnMemoEntries,
3860
+ reason: "ADR-84 return memo; not extensional (its WD3 and WD6), so its reads and stores are journaled"
3861
+ )
3862
+ private_constant :RETURN_MEMO
3863
+
3864
+ # ADR-129 WD1's key audit of a return-memo miss. The key carries the yield value ({YIELD_VALUE}, read through
3865
+ # `key_frame`). {.with_call_site_frame} installs the forwarded-argument list, derived from `arg_types`, repeat
3866
+ # depth zero and none of the caller's jump sinks or retry frames before the key is built, and
3867
+ # {#infer_user_method_return} installs the key-presence walk off, so the computation reads those forms. The
3868
+ # bucket keys on the run generation ({#return_memo_bucket}), within which ADR-130's run-level flags are fixed, so
3869
+ # the key carries them. Two Frames are read without a key term, each a known gap: the block-body threading
3870
+ # suppression (#2782) and Acceptance's unread-shape refusal (#2866).
3871
+ RETURN_MEMO_KEY_AUDIT = MemoAudit.key_declaration(
3872
+ forced: %i[forwarded_call_arg_types reflective_ivar_write_repeating key_presence_walk next_sink break_sink
3873
+ break_value_sink retry_frames],
3874
+ carried: {
3875
+ interface_erasure: "installed around a whole run; the bucket keys on the run generation",
3876
+ interface_dispatch_off: "installed around a whole run; the bucket keys on the run generation"
3877
+ },
3878
+ gaps: {
3879
+ threaded_block_body: "#2782: the return memo reads the block-body threading suppression without keying on " \
3880
+ "it; keying on it cost a fifth of lib's allocations, and clearing it per callee " \
3881
+ "frame re-walked unstorable frames exponentially",
3882
+ acceptance_unread_shape_refused: "#2866: a callee body typed inside Acceptance.refusing_unread " \
3883
+ "reads the caller's refusal, and the return memo does not key on it"
3884
+ }
3885
+ ).freeze
3886
+ private_constant :RETURN_MEMO_KEY_AUDIT
3118
3887
 
3119
3888
  # ADR-84 WD2 — one memo entry: the final inferred return plus, when the entry was computed under
3120
3889
  # ADR-46 dependency recording, the frozen `Analysis::DependencyRecorder::ReadSet` its body walk
@@ -3126,7 +3895,10 @@ module Rigor
3126
3895
  # when every return is unchanged, skips its symbol dependents. Carrying two frozen type refs is
3127
3896
  # negligible on the memo-store path (already in scope) and never enters memo equality (entries are
3128
3897
  # keyed by `memo_key`, retrieved by `.result` / `.read_set`).
3129
- MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types)
3898
+ #
3899
+ # Issue #1237 — `positionals_unknown` marks an entry a splat call stored ({#positional_correspondence}): the
3900
+ # session re-drives its key the same way ({#return_type_for}).
3901
+ MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types, :positionals_unknown)
3130
3902
  private_constant :MemoEntry
3131
3903
 
3132
3904
  # ADR-84 WD3 — thread-local transient-machinery event log, appended by `note_transient_fallback` at
@@ -3182,8 +3954,11 @@ module Rigor
3182
3954
  # sense above — position 0 is always a sound (conservative) choice; spec/rigor/inference/
3183
3955
  # return_memo_taint_spec.rb pins this (no raw `BudgetTrace.hit` on the recursion categories outside
3184
3956
  # the helper).
3185
- TRANSIENT_EVENT_DEPTHS_KEY = :__rigor_user_method_transient_event_depths__
3186
- private_constant :TRANSIENT_EVENT_DEPTHS_KEY
3957
+ TRANSIENT_EVENT_DEPTHS = TypingState.resource(
3958
+ :user_method_transient_event_depths,
3959
+ reason: "ADR-84 WD3 event log, appended in place and read as a delta"
3960
+ )
3961
+ private_constant :TRANSIENT_EVENT_DEPTHS
3187
3962
 
3188
3963
  # Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
3189
3964
  # Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
@@ -3191,7 +3966,7 @@ module Rigor
3191
3966
  # carried separately (it is the body owner, not call context).
3192
3967
  RecursionContext = Data.define(
3193
3968
  :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded,
3194
- :self_fold_safe
3969
+ :self_fold_safe, :positionals_unknown
3195
3970
  )
3196
3971
  private_constant :RecursionContext
3197
3972
 
@@ -3231,24 +4006,27 @@ module Rigor
3231
4006
  # The return memo and the recursion guard do not key on them, which holds because such a node comes only from
3232
4007
  # `DefNodeResolver.refinement_query`'s own parse and always carries that file's one list; a caller that types
3233
4008
  # the same node under another list must add it to the key.
4009
+ #
4010
+ # Issue #1237 — `positionals_unknown:` re-types the body with no positional correspondence
4011
+ # ({#positional_correspondence}): `arg_types` is then the call's keyword shape alone. Such a frame reads and
4012
+ # stores the return memo under a key of its own ({#return_memo_key}), which the incremental session re-drives
4013
+ # with the same flag ({#return_type_for}), and installs no `...` forwarding list.
3234
4014
  def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false, yield_type: nil,
3235
- refinements: nil)
4015
+ refinements: nil, positionals_unknown: false)
3236
4016
  return nil if def_node.body.nil?
3237
- unless KeyPresenceGuard.active?
3238
- return infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type,
3239
- refinements)
3240
- end
3241
4017
 
3242
- KeyPresenceGuard.without_guards do
3243
- infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type, refinements)
3244
- end
4018
+ call = [def_node, receiver, arg_types, self_fold_safe, yield_type, refinements, positionals_unknown]
4019
+ return infer_user_method_return_unguarded(*call) unless KeyPresenceGuard.forcing_off?
4020
+
4021
+ KeyPresenceGuard.without_guards { infer_user_method_return_unguarded(*call) }
3245
4022
  end
3246
4023
 
3247
4024
  def infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type,
3248
- refinements = nil)
4025
+ refinements, positionals_unknown)
3249
4026
  yield_type = nil unless yield_type && body_yields?(def_node)
3250
4027
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types,
3251
- self_fold_safe: self_fold_safe)
4028
+ self_fold_safe: self_fold_safe,
4029
+ positionals_unknown: positionals_unknown)
3252
4030
  return nil if body_scope.nil?
3253
4031
 
3254
4032
  body_scope = body_scope.with_declared_refinements(refinements) if refinements
@@ -3261,8 +4039,7 @@ module Rigor
3261
4039
  # resolving during the main walk. `describe(:short)` keeps non-Nominal receivers (the implicit
3262
4040
  # `Object` carrier for top-level / DSL-block defs) printable.
3263
4041
  plain_signature = [receiver.describe(:short), def_node.name]
3264
- stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
3265
- summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
4042
+ stack = typing_state.stack(INFERENCE_GUARD)
3266
4043
 
3267
4044
  # WD0 (RIGOR_BUDGET_TRACE) — one entry into user-method return inference. No-op when disabled.
3268
4045
  BudgetTrace.hit(BudgetTrace::MEMO_ENTRIES)
@@ -3285,11 +4062,12 @@ module Rigor
3285
4062
  # below reads it back from here rather than taking a second parameter, so key and frame cannot
3286
4063
  # disagree). It is installed even when nil, which is what stops a blockless callee reached from
3287
4064
  # inside a yielding body from inheriting the outer caller's block.
3288
- ExpressionTyper.with_call_site_frame(yield_type, forwarded_frame_types(def_node, arg_types)) do
4065
+ forwarded = positionals_unknown ? nil : forwarded_frame_types(def_node, arg_types)
4066
+ ExpressionTyper.with_call_site_frame(yield_type, forwarded) do
3289
4067
  unless memo_candidate?(stack, plain_signature)
3290
4068
  trace_memo_refusal(stack, plain_signature)
3291
- next compute_user_method_return(def_node, body_scope, stack, summaries,
3292
- receiver, arg_types, plain_signature)
4069
+ next compute_user_method_return(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4070
+ positionals_unknown: positionals_unknown)
3293
4071
  end
3294
4072
 
3295
4073
  # INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
@@ -3306,8 +4084,8 @@ module Rigor
3306
4084
  # (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
3307
4085
  # spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
3308
4086
  # dependency_recorder_spec.rb's transitive deep-edge example.
3309
- consult_and_store_return_memo(def_node, body_scope, stack, summaries,
3310
- receiver, arg_types, plain_signature)
4087
+ consult_and_store_return_memo(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4088
+ positionals_unknown: positionals_unknown)
3311
4089
  end
3312
4090
  end
3313
4091
 
@@ -3328,9 +4106,15 @@ module Rigor
3328
4106
  # replaced with the analysis generation cannot outlive the nodes it holds.
3329
4107
  def body_yields?(def_node)
3330
4108
  cache = class_graph_buckets[:yields]
3331
- return cache[def_node] if cache.key?(def_node)
4109
+ if cache.key?(def_node)
4110
+ return MemoAudit.check("class graph yields", cache[def_node], def_node.name) do
4111
+ yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4112
+ end
4113
+ end
3332
4114
 
3333
- cache[def_node] = yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4115
+ cache[def_node] = MemoAudit.key_audit("class graph yields") do
4116
+ yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4117
+ end
3334
4118
  end
3335
4119
 
3336
4120
  def yield_reachable?(node, block_name)
@@ -3361,7 +4145,7 @@ module Rigor
3361
4145
  # The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
3362
4146
  # and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
3363
4147
  # The store gate (ADR-84 WD3): a result is stored when no transient-machinery event during the
3364
- # bracket referenced a stack frame BELOW the bracket's entry depth (see TRANSIENT_EVENT_DEPTHS_KEY).
4148
+ # bracket referenced a stack frame BELOW the bracket's entry depth (see TRANSIENT_EVENT_DEPTHS).
3365
4149
  # A separate ADR-55 fixpoint-consult bracket counter used to sit in front of this test; it is gone
3366
4150
  # (issue #875) because the event log already subsumes it — the sole `consult_summary` call site is
3367
4151
  # the in-cycle guard hit, which logs its event at the consulted owner's position first, so a consult
@@ -3375,8 +4159,11 @@ module Rigor
3375
4159
  # is already `Dynamic[top]` is stored anyway — see `top_result?`. A hit under ADR-46 recording
3376
4160
  # replays the entry's captured read-set into the current consumer (see the INVARIANT comment at the
3377
4161
  # call site).
3378
- def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
3379
- receiver, arg_types, plain_signature)
4162
+ #
4163
+ # ADR-129 WD3 — the ADR-55 summaries are not read here: a hit neither reads nor writes them, and a miss's
4164
+ # compute takes them ({#return_summaries}).
4165
+ def consult_and_store_return_memo(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4166
+ positionals_unknown: false)
3380
4167
  per_def = (return_memo_bucket[def_node] ||= {})
3381
4168
  # Issue #525 — the `:self` fold-safety grant is a THIRD call-site-varying dimension: the same
3382
4169
  # `(receiver, arg_types)` body returns a folded member type when the caller's receiver expression
@@ -3387,11 +4174,8 @@ module Rigor
3387
4174
  # passed in for the same reason: a yielding callee's return depends on the block THIS call site
3388
4175
  # hands it, so two call sites passing differently-typed blocks must not share an entry. It is nil
3389
4176
  # for every def that cannot reach a `yield`, which is where the memo's mass is.
3390
- memo_key = [receiver.describe(:short),
3391
- arg_types.map { |type| type.describe(:short) },
3392
- body_scope.struct_fold_safe?(:self),
3393
- ExpressionTyper.current_yield_value_type&.describe(:short)]
3394
- if (entry = per_def[memo_key])
4177
+ memo_key = return_memo_key(body_scope, receiver, arg_types, positionals_unknown: positionals_unknown)
4178
+ if (entry = recall_return_memo(per_def, def_node, memo_key))
3395
4179
  BudgetTrace.hit(BudgetTrace::MEMO_HITS)
3396
4180
  Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
3397
4181
  return entry.result
@@ -3401,20 +4185,65 @@ module Rigor
3401
4185
 
3402
4186
  entry_depth = stack.size
3403
4187
  event_mark = transient_event_mark
3404
- result, read_set = compute_with_read_capture(def_node, body_scope, stack, summaries,
3405
- receiver, arg_types, plain_signature)
4188
+ result, read_set = MemoAudit.key_audit("return memo", RETURN_MEMO_KEY_AUDIT, memo_key) do
4189
+ compute_with_read_capture(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4190
+ positionals_unknown)
4191
+ end
3406
4192
 
3407
4193
  if context_tainted?(event_mark, entry_depth) && !top_result?(result)
3408
4194
  BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_TRANSIENT)
4195
+ per_def[RETURN_MEMO_REFUSED] = true
3409
4196
  else
3410
- per_def[memo_key] = MemoEntry.new(result: result, read_set: read_set,
3411
- receiver: receiver, arg_types: arg_types)
4197
+ store_return_memo(per_def, def_node, memo_key,
4198
+ MemoEntry.new(result: result, read_set: read_set, receiver: receiver, arg_types: arg_types,
4199
+ positionals_unknown: positionals_unknown))
3412
4200
  end
3413
4201
  result
3414
4202
  end
3415
4203
 
4204
+ # The return memo's key: the call descriptor, the body scope's `:self` grant, and the yield-value Frame, read as a
4205
+ # key term (ADR-129 WD1's key audit). Issue #1237 — a frame with no positional correspondence keys one term more,
4206
+ # {POSITIONALS_UNKNOWN_KEY}, so it never shares an entry with a positional call whose list reads the same; every
4207
+ # other key is as it was.
4208
+ def return_memo_key(body_scope, receiver, arg_types, positionals_unknown: false)
4209
+ MemoAudit.key do
4210
+ key = [receiver.describe(:short),
4211
+ arg_types.map { |type| type.describe(:short) },
4212
+ body_scope.struct_fold_safe?(:self),
4213
+ typing_state.key_frame(YIELD_VALUE)&.describe(:short)]
4214
+ positionals_unknown ? key << POSITIONALS_UNKNOWN_KEY : key
4215
+ end
4216
+ end
4217
+
4218
+ POSITIONALS_UNKNOWN_KEY = :positionals_unknown
4219
+ private_constant :POSITIONALS_UNKNOWN_KEY
4220
+
4221
+ # The entry `per_def` holds for `memo_key`, or nil, journaled as a Table read (ADR-129 WD3).
4222
+ def recall_return_memo(per_def, def_node, memo_key)
4223
+ entry = per_def[memo_key]
4224
+ log_return_memo(:read, def_node, memo_key, entry || TypingState::ABSENT)
4225
+ entry
4226
+ end
4227
+
4228
+ # Stores `entry` for `memo_key`, journaled as a Table write (ADR-129 WD3), logged before the store so a priors
4229
+ # window notes the entry it replaces.
4230
+ def store_return_memo(per_def, def_node, memo_key, entry)
4231
+ log_return_memo(:write, def_node, memo_key, entry)
4232
+ per_def[memo_key] = entry
4233
+ end
4234
+
4235
+ # ADR-129 WD3 — journals a return-memo read or store under `[def_node, memo_key]`. The composite key is built
4236
+ # only while a capture is open, so a run with the `type_of` memo off allocates nothing here.
4237
+ def log_return_memo(kind, def_node, memo_key, value)
4238
+ state = typing_state
4239
+ return unless state.capturing?
4240
+
4241
+ key = [def_node, memo_key].freeze
4242
+ kind == :read ? state.log_read(RETURN_MEMO, key, value) : state.log_write(RETURN_MEMO, key, value)
4243
+ end
4244
+
3416
4245
  def transient_event_mark
3417
- Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]&.size || 0
4246
+ typing_state.read(TRANSIENT_EVENT_DEPTHS)&.size || 0
3418
4247
  end
3419
4248
 
3420
4249
  # ADR-84 WD3 — true when a transient-machinery event logged during the bracket (entries past
@@ -3426,7 +4255,7 @@ module Rigor
3426
4255
  # outermost entry, so the suffix slice allocated an array per candidate compute — a term quadratic in
3427
4256
  # the event count, and the `ary_ensure_room_for_push` / GC-sweep profile issue #870 sampled.
3428
4257
  def context_tainted?(event_mark, entry_depth)
3429
- log = Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]
4258
+ log = typing_state.read(TRANSIENT_EVENT_DEPTHS)
3430
4259
  return false if log.nil? || log.size <= event_mark
3431
4260
 
3432
4261
  index = event_mark
@@ -3458,31 +4287,29 @@ module Rigor
3458
4287
  # happen in practice is the byte-identical `--format json` runs over `lib`, `plugins/*/lib`,
3459
4288
  # `examples/*/lib` and the survey projects recorded on issue #872.
3460
4289
  def top_result?(result)
3461
- result.equal?(Type::Combinator.untyped)
4290
+ Type::Combinator.untyped?(result)
3462
4291
  end
3463
4292
 
3464
4293
  # ADR-84 WD2 — the memo-miss compute, wrapped in a `DependencyRecorder.capture` window when ADR-46
3465
4294
  # recording is active so the entry can carry its replayable read-set. Returns `[result, read_set]`
3466
4295
  # (`read_set` nil when not recording).
3467
- def compute_with_read_capture(def_node, body_scope, stack, summaries,
3468
- receiver, arg_types, plain_signature)
4296
+ def compute_with_read_capture(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4297
+ positionals_unknown)
4298
+ call = [def_node, body_scope, stack, receiver, arg_types, plain_signature]
3469
4299
  unless Analysis::DependencyRecorder.active?
3470
- return [compute_user_method_return(def_node, body_scope, stack, summaries,
3471
- receiver, arg_types, plain_signature), nil]
4300
+ return [compute_user_method_return(*call, positionals_unknown: positionals_unknown), nil]
3472
4301
  end
3473
4302
 
3474
- Analysis::DependencyRecorder.capture do
3475
- compute_user_method_return(def_node, body_scope, stack, summaries,
3476
- receiver, arg_types, plain_signature)
3477
- end
4303
+ Analysis::DependencyRecorder.capture { compute_user_method_return(*call, positionals_unknown: positionals_unknown) }
3478
4304
  end
3479
4305
 
3480
4306
  # The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
3481
4307
  # out so `infer_user_method_return` is a thin memo wrapper (the memo is the ADR-57 follow-up; this is
3482
4308
  # unchanged from pre-memo behaviour).
3483
- def compute_user_method_return(def_node, body_scope, stack, summaries,
3484
- receiver, arg_types, plain_signature)
4309
+ def compute_user_method_return(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4310
+ positionals_unknown: false)
3485
4311
  trace_body_eval(plain_signature)
4312
+ summaries = return_summaries
3486
4313
  # ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
3487
4314
  # descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
3488
4315
  # folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
@@ -3502,7 +4329,11 @@ module Rigor
3502
4329
  # `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
3503
4330
  # if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
3504
4331
  # fallbacks also route here when a summary is active.
3505
- return consult_summary(summaries, plain_signature)
4332
+ # Issue #3162 — the summary is read only where its frame covers the re-entry's own parameter
4333
+ # bindings and block. A value-keyed re-entry is tested like any other: the unroll frame it
4334
+ # re-enters holds its constants, but the summary it reads is the outermost frame's.
4335
+ floor = uncovered_reentry_floor(summaries[plain_signature], def_node, body_scope, positionals_unknown)
4336
+ return floor || consult_summary(summaries, plain_signature)
3506
4337
  end
3507
4338
 
3508
4339
  # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may only ever surface a fully
@@ -3522,11 +4353,16 @@ module Rigor
3522
4353
  # Issue #525 — read off the built scope so the bot-collapse retry rebuilds a widened scope with
3523
4354
  # the SAME `:self` polarity the first attempt used; a retry that silently dropped the grant would
3524
4355
  # return a different type for the same call.
3525
- self_fold_safe: body_scope.struct_fold_safe?(:self)
4356
+ self_fold_safe: body_scope.struct_fold_safe?(:self),
4357
+ positionals_unknown: positionals_unknown
3526
4358
  )
3527
4359
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
3528
4360
  end
3529
4361
 
4362
+ # ADR-55 — the thread's in-flight fixpoint summaries, created on first use. Taken only by a compute, never on a
4363
+ # return-memo hit (ADR-129 WD3), and cleared when the guard stack drains.
4364
+ def return_summaries = typing_state.acquire(INFERENCE_SUMMARY) { {} }
4365
+
3530
4366
  # Issue #1125 — the argument list a `def m(...)` body's `...` re-supplies, or nil for a def without the
3531
4367
  # forwarding parameter (whose body cannot contain a `...` call at all) and for a call that does not even
3532
4368
  # satisfy `m`'s own required positionals (that call raises at runtime; the body is not worth re-typing).
@@ -3545,8 +4381,9 @@ module Rigor
3545
4381
  kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
3546
4382
  return nil if positional.size < params.requireds.size
3547
4383
 
4384
+ # Frozen: a Frame value does not change while it is installed (ADR-129 WD1).
3548
4385
  tail = positional[(params.requireds.size + params.optionals.size)..] || []
3549
- kw_shape ? tail + [kw_shape] : tail
4386
+ (kw_shape ? tail + [kw_shape] : tail).freeze
3550
4387
  end
3551
4388
 
3552
4389
  # True when this frame's result is a candidate for the return memo: the one structural precondition,
@@ -3567,13 +4404,14 @@ module Rigor
3567
4404
  # `def_nodes` table identity for runner-less scopes (single-file probes) — the pre-WD2 per-file
3568
4405
  # scope. A generation mismatch drops the previous bucket whole, so a re-run in one process (LSP,
3569
4406
  # ADR-62 warm loop) can neither hit stale entries nor accumulate them. The bucket maps `def_node`
3570
- # (object identity, held strongly — see RETURN_MEMO_KEY) to its per-descriptor entries.
4407
+ # (object identity, held strongly — see RETURN_MEMO) to its per-descriptor entries.
3571
4408
  def return_memo_bucket
3572
4409
  generation = scope.run_generation || scope.discovered_def_nodes
3573
- slot = Thread.current[RETURN_MEMO_KEY]
4410
+ state = typing_state
4411
+ slot = state[RETURN_MEMO]
3574
4412
  unless slot && slot[0].equal?(generation)
3575
4413
  slot = [generation, {}.compare_by_identity]
3576
- Thread.current[RETURN_MEMO_KEY] = slot
4414
+ state[RETURN_MEMO] = slot
3577
4415
  end
3578
4416
  slot[1]
3579
4417
  end
@@ -3623,10 +4461,10 @@ module Rigor
3623
4461
  # referenced frame's stack position to the load-bearing thread-local event log (regardless of
3624
4462
  # RIGOR_BUDGET_TRACE) and forwards the category to `BudgetTrace`. The taint spec pins that no
3625
4463
  # recursion-machinery `BudgetTrace.hit` exists outside this helper, so a future fallback cannot
3626
- # silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS_KEY; `context_depth` 0 is
4464
+ # silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS; `context_depth` 0 is
3627
4465
  # always a sound conservative choice).
3628
4466
  def note_transient_fallback(category, context_depth)
3629
- (Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] ||= []) << context_depth
4467
+ typing_state.append(TRANSIENT_EVENT_DEPTHS, context_depth)
3630
4468
  BudgetTrace.hit(category)
3631
4469
  end
3632
4470
 
@@ -3638,25 +4476,53 @@ module Rigor
3638
4476
  # The outermost frame for this plain signature owns the summary entry and runs the fixpoint loop.
3639
4477
  # ADR-55 WD2.
3640
4478
  outermost = stack.none? { |frame| plain_part(frame) == context.plain_signature }
3641
- stack.push(signature)
4479
+ first_unpinned = first_unpinned_frame?(stack, signature, context)
4480
+ state = typing_state
4481
+ state.push(INFERENCE_GUARD, signature)
3642
4482
  begin
3643
4483
  if outermost
3644
4484
  fixpoint_user_method_return(def_node, body_scope, context)
4485
+ elsif first_unpinned
4486
+ nested_fixpoint_user_method_return(def_node, body_scope, context)
3645
4487
  else
3646
4488
  type, = evaluate_body_with_returns(body_scope, def_node.body)
3647
4489
  clamp_unroll_result(type, context.would_have_been_guarded)
3648
4490
  end
3649
4491
  ensure
3650
- stack.pop
4492
+ state.pop(INFERENCE_GUARD)
3651
4493
  if stack.empty?
3652
- Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
3653
- Thread.current[INFERENCE_SUMMARY_KEY] = nil
3654
- Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
3655
- Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] = nil
4494
+ state.write(INFERENCE_UNROLL_FUEL, nil)
4495
+ state.write(INFERENCE_SUMMARY, nil)
4496
+ state.write(SUMMARY_CONSULT_DEPTHS, nil)
4497
+ state.write(TRANSIENT_EVENT_DEPTHS, nil)
3656
4498
  end
3657
4499
  end
3658
4500
  end
3659
4501
 
4502
+ # Issue #3162 — whether this frame is the first of its signature with an unpinned argument: every
4503
+ # frame of the signature below it is a constant-argument unroll frame, and the recursion has now
4504
+ # passed something that is no constant (`rec(k - 1, *rest)` out of `rec(3, 1)`). The outermost
4505
+ # frame's summary is the return of a body typed for its constants, which does not cover this
4506
+ # frame's re-entries, so this frame owns a summary of its own. A frame that is all constants but
4507
+ # out of unroll fuel is not one: its re-entries keep reading the outermost frame's summary, whose
4508
+ # `bot` sends the outermost frame to the widened retry. Asked before the frame is pushed.
4509
+ def first_unpinned_frame?(stack, signature, context)
4510
+ plain_signature = context.plain_signature
4511
+ signature.equal?(plain_signature) && stack.none?(plain_signature) &&
4512
+ constant_argument_value_key(context.arg_types).nil?
4513
+ end
4514
+
4515
+ # The fixpoint of a {#first_unpinned_frame?} frame, run over its own summary entry; the outermost
4516
+ # frame's entry is put back afterwards.
4517
+ def nested_fixpoint_user_method_return(def_node, body_scope, context)
4518
+ summaries = context.summaries
4519
+ outer = summaries[context.plain_signature]
4520
+ fixpoint_user_method_return(def_node, body_scope, context)
4521
+ ensure
4522
+ summaries[context.plain_signature] = outer
4523
+ LoopValueRecord.advance_generation
4524
+ end
4525
+
3660
4526
  # Evaluates a method body and joins the value types of every explicit `return value` reached during
3661
4527
  # the walk with the body's tail type.
3662
4528
  #
@@ -3685,14 +4551,11 @@ module Rigor
3685
4551
  def fixpoint_user_method_return(def_node, body_scope, context, widened: false)
3686
4552
  plain_signature = context.plain_signature
3687
4553
  summaries = context.summaries
3688
- depth = seed_fixpoint_summary(summaries, plain_signature)
3689
- consult_depths = (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= [])
4554
+ depth = seed_fixpoint_summary(summaries, plain_signature, body_scope, context.receiver)
3690
4555
 
3691
- RECURSION_FIXPOINT_CAP.times do |iteration|
3692
- summaries[plain_signature][:consulted] = false
3693
- consult_mark = consult_depths.size
3694
- type, = evaluate_body_with_returns(body_scope, def_node.body)
3695
- computed = clamp_unroll_result(type, context.would_have_been_guarded)
4556
+ iteration = 0
4557
+ while iteration < RECURSION_FIXPOINT_CAP
4558
+ computed, entangled = evaluate_fixpoint_iterate(def_node, body_scope, context, depth)
3696
4559
 
3697
4560
  # Cross-signature mutual recursion (ADR-55 soundness fix, 2026-06-12): the evaluation consulted an
3698
4561
  # ANCESTOR signature's in-flight summary (seed depth shallower than this frame's), so `computed`
@@ -3700,9 +4563,7 @@ module Rigor
3700
4563
  # `Constant[false]`. The per-signature iteration below cannot converge such an entangled pair (each
3701
4564
  # side's iterate is conditioned on the other's unfinished assumption), so degrade this frame to the
3702
4565
  # sound `untyped` floor instead of surfacing a one-sided value.
3703
- if consult_depths[consult_mark..].any? { |d| d < depth }
3704
- return degrade_entangled_fixpoint(summaries, plain_signature)
3705
- end
4566
+ return degrade_entangled_fixpoint(summaries, plain_signature) if entangled
3706
4567
 
3707
4568
  # The summary was never consulted — the method did not recurse on this evaluation, so there is no
3708
4569
  # fixpoint to chase. Return the computed type directly (pre-fixpoint behaviour for non-recursive
@@ -3720,26 +4581,153 @@ module Rigor
3720
4581
  return resolved unless resolved.nil?
3721
4582
  end
3722
4583
 
4584
+ # Issue #3162 — a re-entry read the summary for arguments this frame's parameters do not cover
4585
+ # (`acc(n - 1, k + 1)` against `k = 1`), so `computed` holds this frame's base case as that
4586
+ # call's answer. The evaluation is discarded and the body typed again with such a re-entry
4587
+ # answering `Dynamic[top]`; it is not a Kleene step.
4588
+ next if floored_uncovered_reentries?(summaries[plain_signature])
4589
+
3723
4590
  converged = fixpoint_step(summaries, plain_signature, computed, iteration)
3724
4591
  return converged if converged
4592
+
4593
+ iteration += 1
3725
4594
  end
3726
4595
 
3727
4596
  # Out of iterations and still unstable. The collapse is the loop's own tail rather than a branch
3728
- # inside `fixpoint_step`: `Integer#times` yields its receiver when no iteration returns, so a
3729
- # tail-less loop reads as a path that returns an Integer summary (the `def.return-type-mismatch`
3730
- # on `return_type_for` this shape retired), and the loop, not the step, owns its cap.
4597
+ # inside `fixpoint_step`: the loop, not the step, owns its cap.
3731
4598
  collapse_fixpoint_cap(summaries, plain_signature)
3732
4599
  end
3733
4600
 
4601
+ # One body evaluation of the fixpoint loop, as `[computed, entangled]`: the clamped return, and
4602
+ # whether the evaluation consulted an ancestor signature's in-flight summary (one seeded shallower
4603
+ # than `depth`). Clears the summary's per-evaluation record first.
4604
+ def evaluate_fixpoint_iterate(def_node, body_scope, context, depth)
4605
+ entry = context.summaries[context.plain_signature]
4606
+ entry[:consulted] = false
4607
+ entry[:uncovered] = false
4608
+ consult_depths = typing_state.acquire(SUMMARY_CONSULT_DEPTHS) { [] }
4609
+ consult_mark = consult_depths.size
4610
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
4611
+ computed = clamp_unroll_result(type, context.would_have_been_guarded)
4612
+ [computed, consult_depths[consult_mark..].any? { |consulted| consulted < depth }]
4613
+ end
4614
+
4615
+ # Switches the summary to its floor after an evaluation in which an uncovered re-entry read it, and
4616
+ # says whether it did. Once on the floor such a re-entry never reads the summary again, so the
4617
+ # switch happens at most once per fixpoint.
4618
+ #
4619
+ # A `bot` iterate has been to the widened retry first. When that retry found no exit either
4620
+ # (`spin`), the entry here is the retry's own, which noted nothing, and the answer stays `bot`.
4621
+ def floored_uncovered_reentries?(entry)
4622
+ return false unless entry[:uncovered] && !entry[:floor]
4623
+
4624
+ entry[:floor] = true
4625
+ LoopValueRecord.advance_generation
4626
+ true
4627
+ end
4628
+
4629
+ # Issue #3162 — the answer of a re-entry the summary's frame does not cover, or nil for one that
4630
+ # reads the summary. The uncovered re-entry is noted on the entry; until the fixpoint owner has
4631
+ # switched the summary to its floor ({#floored_uncovered_reentries?}) it still reads the summary,
4632
+ # so a body whose iterate is `bot` reaches the widened retry as before. On the floor it answers
4633
+ # `untyped`, the pre-slice-2 observable for an in-cycle call.
4634
+ def uncovered_reentry_floor(entry, def_node, body_scope, positionals_unknown)
4635
+ return nil if entry.nil?
4636
+ return nil if summary_covers_reentry?(entry, def_node, body_scope.locals, positionals_unknown)
4637
+
4638
+ entry[:uncovered] = true
4639
+ return nil unless entry[:floor]
4640
+
4641
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
4642
+ Type::Combinator.untyped
4643
+ end
4644
+
4645
+ # Issue #3162 — whether the summary's frame covers a re-entry's parameter bindings (`locals`, as the
4646
+ # guard site built them): every parameter of the frame holds a type that includes what the re-entry
4647
+ # bound it to. The summary is the return of the frame's body under the frame's bindings, so it is
4648
+ # the re-entry's return only then; a parameter passed through unchanged is the same type object.
4649
+ #
4650
+ # A re-entry through a call-site `*splat` has no positional correspondence, and its binder holds
4651
+ # `Dynamic[top]` for every positional. There the def's own calls are read instead
4652
+ # ({RecursionRebinding}): a positional every one of them passes through unchanged (`*rest` as
4653
+ # `*rest`) is covered, and any other is not.
4654
+ #
4655
+ # The block is part of the frame too: `yield` answers the frame's block ({#reentry_block_covered?}).
4656
+ def summary_covers_reentry?(entry, def_node, locals, positionals_unknown)
4657
+ return false unless reentry_block_covered?(entry, def_node)
4658
+
4659
+ kept = (entry[:kept] ||= kept_positional_parameters(def_node, entry[:receiver])) if positionals_unknown
4660
+ frame = entry[:locals]
4661
+ locals.all? do |name, argument|
4662
+ bound = frame[name]
4663
+ bound.nil? || kept&.include?(name) || binding_covers?(bound, argument)
4664
+ end
4665
+ end
4666
+
4667
+ # Whether the re-entry runs with a block the summary's frame covers. A re-entry with no block, or
4668
+ # one that passes the def's own block parameter on (`&blk`, or the anonymous `&`), does; `&blk` is
4669
+ # the frame's block only while the body never assigns `blk` and no block or lambda in it declares
4670
+ # the name ({RecursionRebinding}'s write and block-local scan, as for a positional). One that
4671
+ # passes another `&expression` does not. A block literal does when the value it yields is covered
4672
+ # by the value the frame's block yields, which is nil on both sides for a def that never `yield`s.
4673
+ # The re-entry's call is the node being typed; with none to read, only the yield values are compared.
4674
+ def reentry_block_covered?(entry, def_node)
4675
+ block = @typing_node.block if @typing_node.is_a?(Prism::CallNode)
4676
+ return true if @typing_node.is_a?(Prism::CallNode) && block.nil?
4677
+ return own_block_passed?(def_node, block) if block.is_a?(Prism::BlockArgumentNode)
4678
+
4679
+ yielded = ExpressionTyper.current_yield_value_type
4680
+ return true if yielded.nil?
4681
+
4682
+ frame_yield = entry[:yield]
4683
+ !frame_yield.nil? && binding_covers?(frame_yield, yielded)
4684
+ end
4685
+
4686
+ def own_block_passed?(def_node, block)
4687
+ params = def_node.parameters
4688
+ own = params.block if params.is_a?(Prism::ParametersNode)
4689
+ return false if own.nil?
4690
+
4691
+ expression = block.expression
4692
+ return own.name.nil? if expression.nil?
4693
+ return false unless expression.is_a?(Prism::LocalVariableReadNode) && expression.name == own.name
4694
+
4695
+ written = RecursionRebinding.rebound_parameters(def_node, Set.new)
4696
+ written != RecursionRebinding::ALL && !written.include?(own.name)
4697
+ end
4698
+
4699
+ # `2` is covered by `Integer` though their union is spelled `2 | Integer`, so the argument is also
4700
+ # tried with its value pins widened.
4701
+ def binding_covers?(bound, argument)
4702
+ return true if argument.equal?(bound) || Type::Combinator.untyped?(bound) || argument == bound
4703
+ return false if Type::Combinator.untyped?(argument)
4704
+ return true if Type::Combinator.union(bound, argument) == bound
4705
+
4706
+ Type::Combinator.union(bound, widen_bound_argument(argument)) == bound
4707
+ end
4708
+
4709
+ def kept_positional_parameters(def_node, receiver)
4710
+ params = def_node.parameters
4711
+ rebound = recursion_rebound_parameters(def_node, receiver)
4712
+ return [] if rebound == RecursionRebinding::ALL || !params.is_a?(Prism::ParametersNode)
4713
+
4714
+ positionals = params.requireds + params.optionals + params.posts + [params.rest]
4715
+ positionals.filter_map { |param| param.name if param.respond_to?(:name) && param.name } - rebound.to_a
4716
+ end
4717
+
3734
4718
  # Seeds the thread-local summary entry for a fixpoint owner: the `bot` Kleene seed plus the
3735
4719
  # guard-stack depth at seed time (the frame for this signature is already pushed), which
3736
4720
  # `consult_summary` logs so nested fixpoints can detect a foreign in-flight (ancestor) consult.
3737
4721
  # Returns the seed depth. ADR-55 slice 2.
3738
- def seed_fixpoint_summary(summaries, plain_signature)
3739
- depth = (Thread.current[INFERENCE_GUARD_KEY] || []).size
4722
+ def seed_fixpoint_summary(summaries, plain_signature, body_scope, receiver)
4723
+ depth = typing_state[INFERENCE_GUARD]&.size || 0
3740
4724
  summaries[plain_signature] = {
3741
- assumption: Type::Combinator.bot, consulted: false, depth: depth
4725
+ assumption: Type::Combinator.bot, consulted: false, depth: depth,
4726
+ # Issue #3162 — the frame the summary is the return of, for {#summary_covers_reentry?}.
4727
+ locals: body_scope.locals, yield: ExpressionTyper.current_yield_value_type, receiver: receiver,
4728
+ uncovered: false, floor: false
3742
4729
  }
4730
+ LoopValueRecord.advance_generation
3743
4731
  depth
3744
4732
  end
3745
4733
 
@@ -3752,13 +4740,14 @@ module Rigor
3752
4740
  note_transient_fallback(BudgetTrace::RECURSION_GUARD, own_guard_frame_position)
3753
4741
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
3754
4742
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
4743
+ LoopValueRecord.advance_generation
3755
4744
  Type::Combinator.untyped
3756
4745
  end
3757
4746
 
3758
4747
  # The stack position of the currently-evaluating owner frame (the guard stack's top) — the
3759
4748
  # self-referential `context_depth` for events that are deterministic per owner (ADR-84 WD3).
3760
4749
  def own_guard_frame_position
3761
- size = Thread.current[INFERENCE_GUARD_KEY]&.size || 0
4750
+ size = typing_state[INFERENCE_GUARD]&.size || 0
3762
4751
  size.positive? ? size - 1 : 0
3763
4752
  end
3764
4753
 
@@ -3779,6 +4768,7 @@ module Rigor
3779
4768
  return candidate if joined == assumption
3780
4769
 
3781
4770
  summaries[plain_signature][:assumption] = joined
4771
+ LoopValueRecord.advance_generation
3782
4772
  nil
3783
4773
  end
3784
4774
 
@@ -3790,17 +4780,67 @@ module Rigor
3790
4780
  note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
3791
4781
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
3792
4782
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
4783
+ LoopValueRecord.advance_generation
3793
4784
  Type::Combinator.untyped
3794
4785
  end
3795
4786
 
3796
4787
  # Rebuilds the user-method body scope with every bound positional parameter widened to its nominal
3797
4788
  # base (`1 | 2 | 3` → `Integer`, `Constant[:x]` → `Symbol`). Used by the bot-collapse retry in
3798
4789
  # `fixpoint_user_method_return`: call-site argument narrowing can prune a recursive method's base
3799
- # case, and widening restores the declared-type view under which the base case is reachable. Returns
3800
- # `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
3801
- def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
3802
- widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
3803
- build_user_method_body_scope(def_node, receiver, widened_args, self_fold_safe: self_fold_safe)
4790
+ # case, and widening restores the declared-type view under which the base case is reachable. A
4791
+ # parameter the def's own recursive calls rebind is bound `Dynamic[top]` instead ({RecursionRebinding}).
4792
+ # Returns `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
4793
+ def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false,
4794
+ positionals_unknown: false)
4795
+ widened_args = arg_types.map { |arg_type| widen_bound_argument(arg_type) }
4796
+ build_user_method_body_scope(def_node, receiver, widened_args,
4797
+ self_fold_safe: self_fold_safe, positionals_unknown: positionals_unknown,
4798
+ rebound: recursion_rebound_parameters(def_node, receiver))
4799
+ end
4800
+
4801
+ # Issue #3115 — {RecursionRebinding.rebound_parameters} for `def_node`, told the def's other names: a name
4802
+ # the body calls is an alias when `receiver`'s class resolves it to this same def (`alias al2 al1` makes
4803
+ # `al2(n - 1, !f)` a recursive call). A receiver whose class cannot be asked rebinds every parameter.
4804
+ def recursion_rebound_parameters(def_node, receiver)
4805
+ names = Set[def_node.name]
4806
+ RecursionRebinding.called_names(def_node.body).each do |name|
4807
+ next if name == def_node.name
4808
+
4809
+ resolved = recursion_def_for(receiver, name)
4810
+ return RecursionRebinding::ALL if resolved == :unknown
4811
+
4812
+ names << name if resolved.equal?(def_node)
4813
+ end
4814
+ RecursionRebinding.rebound_parameters(def_node, names)
4815
+ end
4816
+
4817
+ def recursion_def_for(receiver, method_name)
4818
+ return scope.singleton_def_for(receiver.class_name, method_name) if receiver.is_a?(Type::Singleton)
4819
+ return :unknown unless user_inference_receiver?(receiver)
4820
+
4821
+ resolve_user_def_through_ancestors(receiver.class_name, method_name)
4822
+ end
4823
+
4824
+ # Issue #3115 — one argument of the widened retry. A keyword argument reaches the binder inside the call's
4825
+ # trailing `HashShape`, which {#widen_value_pinned} leaves as it is (a shape is not a value pin), so a
4826
+ # keyword parameter kept its call-site pin through the retry and the base case stayed pruned: the body
4827
+ # then converged on the `bot` seed. A shape carrier keeps its keys, policy and arity, and the values it
4828
+ # holds widen at every depth: a `HashShape`'s values, a `Tuple`'s elements and a `Union`'s members.
4829
+ def widen_bound_argument(arg_type)
4830
+ case arg_type
4831
+ when Type::HashShape
4832
+ Type::HashShape.new(
4833
+ arg_type.pairs.transform_values { |value| widen_bound_argument(value) },
4834
+ required_keys: arg_type.required_keys, optional_keys: arg_type.optional_keys,
4835
+ read_only_keys: arg_type.read_only_keys, extra_keys: arg_type.extra_keys
4836
+ )
4837
+ when Type::Tuple
4838
+ Type::Combinator.tuple_of(*arg_type.elements.map { |element| widen_bound_argument(element) })
4839
+ when Type::Union
4840
+ Type::Combinator.union(*arg_type.members.map { |member| widen_bound_argument(member) })
4841
+ else
4842
+ widen_value_pinned(arg_type)
4843
+ end
3804
4844
  end
3805
4845
 
3806
4846
  # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a fixpoint iteration computed `bot`
@@ -3821,7 +4861,8 @@ module Rigor
3821
4861
  def resolve_bot_collapse(def_node, context, widened:)
3822
4862
  unless widened
3823
4863
  widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types,
3824
- self_fold_safe: context.self_fold_safe)
4864
+ self_fold_safe: context.self_fold_safe,
4865
+ positionals_unknown: context.positionals_unknown)
3825
4866
  return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
3826
4867
  end
3827
4868
 
@@ -3863,7 +4904,7 @@ module Rigor
3863
4904
  return Type::Combinator.untyped if entry.nil?
3864
4905
 
3865
4906
  entry[:consulted] = true
3866
- (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= []) << entry[:depth]
4907
+ typing_state.append(SUMMARY_CONSULT_DEPTHS, entry[:depth])
3867
4908
  entry[:assumption]
3868
4909
  end
3869
4910
 
@@ -3898,14 +4939,15 @@ module Rigor
3898
4939
  # Consumes one unit from the thread-local unroll-fuel counter and returns the units that were
3899
4940
  # available *before* this consumption (so a positive return means the extended value-key may be used).
3900
4941
  # Fuel is per-outermost inference entry: at the top level (empty guard stack) it seeds to
3901
- # `RECURSION_UNROLL_FUEL`, and the `ensure` in `infer_user_method_return` clears it once the stack
4942
+ # `RECURSION_UNROLL_FUEL`, and the `ensure` in `evaluate_guarded_user_method_body` clears it once the stack
3902
4943
  # drains back to empty. On exhaustion (return 0) it records a `RECURSION_UNROLL_FUEL` hit so the caller
3903
4944
  # keeps the plain `(receiver, method)` signature — today's behaviour.
3904
4945
  def unroll_fuel_remaining(stack)
3905
- remaining = Thread.current[INFERENCE_UNROLL_FUEL_KEY]
4946
+ state = typing_state
4947
+ remaining = state.read(INFERENCE_UNROLL_FUEL)
3906
4948
  remaining = RECURSION_UNROLL_FUEL if remaining.nil? || stack.empty?
3907
4949
  if remaining.positive?
3908
- Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
4950
+ state.write(INFERENCE_UNROLL_FUEL, remaining - 1)
3909
4951
  else
3910
4952
  # ADR-84 WD3 — position 0: fuel is one shared resource seeded at the stack bottom, so its
3911
4953
  # exhaustion is context-dependent for every nested bracket.
@@ -3954,14 +4996,33 @@ module Rigor
3954
4996
  total += descriptor.last
3955
4997
  end
3956
4998
  [["t", parts], total]
4999
+ when Type::HashShape then pinned_shape_descriptor(arg)
5000
+ end
5001
+ end
5002
+
5003
+ # Issue #3115 — a call's keyword arguments arrive as one closed `HashShape`, so `m(n: 2)` is as
5004
+ # value-pinned as `m(2)` when every entry is. An open shape, or one with an optional key, does not name
5005
+ # one argument list and stays on the type-keyed path.
5006
+ def pinned_shape_descriptor(shape)
5007
+ return nil unless shape.closed? && shape.optional_keys.empty?
5008
+
5009
+ parts = []
5010
+ total = 1
5011
+ shape.pairs.each do |key, value|
5012
+ descriptor = pinned_value_descriptor(value)
5013
+ return nil if descriptor.nil?
5014
+
5015
+ parts << [key.inspect, descriptor.first]
5016
+ total += descriptor.last
3957
5017
  end
5018
+ [["h", parts], total]
3958
5019
  end
3959
5020
 
3960
5021
  # Builds the body scope for a user-defined instance method call: a fresh `Scope` with `self_type` set
3961
5022
  # to the receiver's nominal type, the project-wide accumulators inherited (so the body sees the same
3962
5023
  # `discovered_classes` / `class_ivars` / etc. the caller does), and the call's `arg_types` bound to
3963
5024
  # the parameters. Returns nil when the binding is truly ambiguous (arity mismatch, trailing `post`
3964
- # params, `...` forwarding).
5025
+ # params).
3965
5026
  #
3966
5027
  # Issue #524 — the binder is per-PARAMETER, not per-signature. The first iteration declined any def
3967
5028
  # with an optional / rest / keyword / block parameter outright, so ONE `options = {}` default zeroed
@@ -3978,11 +5039,25 @@ module Rigor
3978
5039
  # through it. Record a per-file write kind in the census instead, as {ScopeIndexer::CONSTANT_MEMO} does
3979
5040
  # (#1276). Setting `source_path` here was rejected: plugins read it, and the return memo would become
3980
5041
  # caller-dependent.
3981
- def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
5042
+ #
5043
+ # Issue #3115 — `rebound:` is the widened retry's set ({#widened_user_method_body_scope}): the parameters
5044
+ # the def's own recursive calls rebind ({RecursionRebinding}), each bound `Dynamic[top]` whether the call
5045
+ # wrote the slot or a default filled it. The retry types the body for every call the recursion makes,
5046
+ # and such a slot holds whatever a recursive call passes (`tog(n: n - 1, f: !f)` against `f: false`).
5047
+ def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false,
5048
+ positionals_unknown: false, rebound: nil)
3982
5049
  params = def_node.parameters
3983
- locals = bind_params_from_call_types(params, arg_types)
5050
+ locals =
5051
+ if positionals_unknown
5052
+ bind_params_without_correspondence(params, arg_types.last)
5053
+ else
5054
+ bind_params_from_call_types(params, arg_types)
5055
+ end
3984
5056
  return nil if locals.nil?
3985
5057
 
5058
+ rebound = locals.keys if rebound == RecursionRebinding::ALL
5059
+ rebound&.each { |name| locals[name] = dynamic_top if locals.key?(name) }
5060
+
3986
5061
  # Construct the body scope in a SINGLE Scope allocation (plus the issue #1358 frame it carries, whose
3987
5062
  # scans run only on demand) — the previous `Scope.empty.with_*.with_*…` chain
3988
5063
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
@@ -4009,7 +5084,7 @@ module Rigor
4009
5084
  body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(def_node.body, body_scope))
4010
5085
  end
4011
5086
 
4012
- # Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
5087
+ # Issue #681 / #707 — the typer's answer to "what `Module.nesting` was recorded for this
4013
5088
  # def node". `Scope::DiscoveryIndex#discovered_def_nestings` is the owner; the resolver memo is that
4014
5089
  # table's REHYDRATION for the one population it cannot hold — a def served from an ADR-85 seed bundle,
4015
5090
  # whose node {Inference::DefNodeResolver} mints from its own parse, so no table keyed by the identities
@@ -4024,7 +5099,7 @@ module Rigor
4024
5099
  # run and an `--incremental` recheck answering the same constant for the same body; the rehydration half
4025
5100
  # is populated only inside `DefNodeResolver.with_run`, and outside it this falls back to the peel.
4026
5101
  def recorded_def_nesting(def_node)
4027
- scope.discovery.discovered_def_nestings[def_node] || DefNodeResolver.rehydrated_nesting(def_node)
5102
+ scope.discovery.recorded_def_nesting(def_node)
4028
5103
  end
4029
5104
 
4030
5105
  # The body scope's fold-safe set: the body's own struct locals, plus issue #525's `:self` sentinel
@@ -4047,9 +5122,15 @@ module Rigor
4047
5122
  def self_fold_safe_grant?(def_node, receiver)
4048
5123
  per_def = (class_graph_buckets[:self_pure][def_node] ||= {})
4049
5124
  key = [receiver.class_name, receiver.member_names]
4050
- return per_def[key] if per_def.key?(key)
5125
+ if per_def.key?(key)
5126
+ return MemoAudit.check("class graph self pure", per_def[key], def_node.name, key) do
5127
+ self_fold_safe_body?(def_node.body, receiver, [def_node])
5128
+ end
5129
+ end
4051
5130
 
4052
- per_def[key] = self_fold_safe_body?(def_node.body, receiver, [def_node])
5131
+ per_def[key] = MemoAudit.key_audit("class graph self pure") do
5132
+ self_fold_safe_body?(def_node.body, receiver, [def_node])
5133
+ end
4053
5134
  end
4054
5135
 
4055
5136
  # Whether `body`'s every use of `self` is a pure read, resolving each unrecognised self-call against
@@ -4084,6 +5165,11 @@ module Rigor
4084
5165
  locals = bind_positional_params(params, positional)
4085
5166
  return nil if locals.nil?
4086
5167
 
5168
+ bind_keyword_and_block_params(params, kw_shape, locals)
5169
+ end
5170
+
5171
+ # The keyword, `**rest` and block parameters' bindings, added to `locals`, which it returns.
5172
+ def bind_keyword_and_block_params(params, kw_shape, locals)
4087
5173
  bind_keyword_params(params, kw_shape, locals)
4088
5174
  rest_name = keyword_rest_name(params)
4089
5175
  locals[rest_name.to_sym] = keyword_rest_type(params, kw_shape) if rest_name
@@ -4091,6 +5177,25 @@ module Rigor
4091
5177
  locals
4092
5178
  end
4093
5179
 
5180
+ # Issue #1237 — the locals table for a call whose positional correspondence is unknown (a `*splat` at the
5181
+ # call site). Every positional parameter (required, optional, trailing) binds `Dynamic[top]` and the rest
5182
+ # parameter an `Array[untyped]`; the keyword parameters bind from `keywords` as {#bind_keyword_params} does
5183
+ # for any call, so an open shape leaves them `Dynamic[top]`. An optional's default is not used: the splat
5184
+ # may supply the slot.
5185
+ def bind_params_without_correspondence(params, keywords)
5186
+ return {} if params.nil?
5187
+ return nil unless params.is_a?(Prism::ParametersNode)
5188
+
5189
+ locals = {}
5190
+ (params.requireds + params.posts).each { |param| bind_positional_param(locals, param, dynamic_top) }
5191
+ params.optionals.each { |param| locals[param.name.to_sym] = dynamic_top }
5192
+ rest = params.rest
5193
+ if rest.respond_to?(:name) && rest.name
5194
+ locals[rest.name.to_sym] = Type::Combinator.nominal_of("Array", type_args: [dynamic_top])
5195
+ end
5196
+ bind_keyword_and_block_params(params, keywords, locals)
5197
+ end
5198
+
4094
5199
  # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence and stay
4095
5200
  # declined — correspondence, not width, is the issue. Issue #1125: `def m(...)`'s forwarding slot is no
4096
5201
  # longer a decline. Its NAMED parameters (a leading `def m(a, ...)` required) bind exactly as before;
@@ -4236,7 +5341,7 @@ module Rigor
4236
5341
  # now carry what the callee needs:
4237
5342
  #
4238
5343
  # - `f(...)` inside `def m(...)` re-supplies the arguments `m` was called with, read from the frame
4239
- # {#infer_user_method_return} installed ({FORWARDED_ARGS_KEY}) — so it expands to a whole LIST, not
5344
+ # {#infer_user_method_return} installed ({FORWARDED_ARGS}) — so it expands to a whole LIST, not
4240
5345
  # one type, which is why the map became a flat_map.
4241
5346
  # - a keyword hash built ENTIRELY from a double splat (`f(**h)`) is the shape of `h`, so `f(**h)`
4242
5347
  # binds the same parameters the literal form `f(a: 1, b: 2)` does.
@@ -4332,7 +5437,7 @@ module Rigor
4332
5437
  )
4333
5438
  block_return_for(
4334
5439
  block_arg, expected,
4335
- call_node: call_node,
5440
+ call_node: call_node, receiver_type: receiver_type,
4336
5441
  narrowing: block_body_self_narrowing(call_node, receiver_type),
4337
5442
  repeats: !BLOCK_VALUE_DISCARDING.include?(call_node.name) && block_may_repeat?(call_node, receiver_type)
4338
5443
  )
@@ -4353,13 +5458,15 @@ module Rigor
4353
5458
 
4354
5459
  # Whether the call may run its block more than once, so a later run reads a captured binding an earlier
4355
5460
  # run rebound — the premise of laying the #587 (b) binding ({#captured_block_bindings}) under the block.
4356
- # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412). Issue #2301 — a
4357
- # project callee that hands the block on ({ProjectYielder.hands_block_on?}) also counts: the block's value is what
4358
- # the forward's target `yield`s, and that target may run it many times, so a captured local the block rebinds must
4359
- # not read its entry binding.
5461
+ # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412). A project callee
5462
+ # whose body may run the block more than once on one path ({ProjectYielder.block_value_may_repeat?}) also
5463
+ # counts: a `yield` in a loop or a core iterator's block (`xs.each { |x| v = yield(x) }`, issue #2303), or a
5464
+ # hand-off (`g(x, &)`, issue #2301), whose target may run it many times, so a captured local the block rebinds
5465
+ # must not read its entry binding. A callee that runs it once per path (`f ? yield(1) : yield(2)`,
5466
+ # `then { yield }`) keeps the single-run value.
4360
5467
  def block_may_repeat?(call_node, receiver_type)
4361
5468
  BlockRepetition.may_repeat?(method_name: call_node.name, receiver_type: receiver_type, scope: scope) ||
4362
- ProjectYielder.hands_block_on?(call_node, receiver_type, scope)
5469
+ ProjectYielder.block_value_may_repeat?(call_node, receiver_type, scope)
4363
5470
  end
4364
5471
 
4365
5472
  EMPTY_BREAK_ARMS = [].freeze
@@ -4403,7 +5510,7 @@ module Rigor
4403
5510
  param_types = break_arm_param_types(call_node, receiver)
4404
5511
  block_scope = block_entry_scope(
4405
5512
  block_node, param_types,
4406
- call_node: call_node,
5513
+ call_node: call_node, receiver_type: receiver,
4407
5514
  narrowing: block_body_self_narrowing(call_node, receiver),
4408
5515
  captured: repeating_captured_bindings(block_node, param_types, block_may_repeat?(call_node, receiver))
4409
5516
  )
@@ -4446,12 +5553,13 @@ module Rigor
4446
5553
  )
4447
5554
  end
4448
5555
 
4449
- def block_return_for(block_arg, expected, call_node: nil, narrowing: nil, repeats: false)
5556
+ def block_return_for(block_arg, expected, call_node: nil, narrowing: nil, repeats: false, receiver_type: nil)
4450
5557
  case block_arg
4451
5558
  when Prism::BlockNode
4452
5559
  captured = repeating_captured_bindings(block_arg, expected, repeats)
4453
5560
  entry = block_entry_scope(
4454
- block_arg, expected, call_node: call_node, narrowing: narrowing, captured: captured
5561
+ block_arg, expected,
5562
+ call_node: call_node, narrowing: narrowing, captured: captured, receiver_type: receiver_type
4455
5563
  )
4456
5564
  type_block_body(block_arg, entry, captured: captured)
4457
5565
  when Prism::BlockArgumentNode
@@ -4479,8 +5587,16 @@ module Rigor
4479
5587
  # Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
4480
5588
  # yielding method's business, so the return-typing pass must see the same unmodelled-self mark. Issue #1358
4481
5589
  # — and the same match-global view ({MatchRebinding.block_entry}), which reads the owning `call_node`.
4482
- def block_entry_scope(block_node, expected, call_node: nil, narrowing: nil, captured: nil)
4483
- entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
5590
+ #
5591
+ # Issue #2861 — and the same self-preserving entry ({SelfPreservingBlock}), read off `receiver_type`.
5592
+ def block_entry_scope(block_node, expected, call_node: nil, narrowing: nil, captured: nil, receiver_type: nil)
5593
+ self_entry = if SelfPreservingBlock.call?(call_node, receiver_type, scope)
5594
+ scope.entering_self_preserving_block
5595
+ else
5596
+ scope.entering_opaque_block
5597
+ end
5598
+ entry = MatchRebinding.block_entry(self_entry, block_node, call_node)
5599
+ entry = entry.entering_block_without_gradual_marks(block_node)
4484
5600
  entry = captured.lay(entry) if captured
4485
5601
  block_scope = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
4486
5602
  return block_scope unless narrowing
@@ -4750,23 +5866,20 @@ module Rigor
4750
5866
  # Suppression flag for {#threaded_block_body_type} — set while a fold is running (so a fold never nests)
4751
5867
  # and while the per-element Tuple fold is over its arity cap. Thread-local because block typing is
4752
5868
  # re-entrant within one thread and the fork-pool workers each own their own.
4753
- THREADED_BLOCK_BODY_KEY = :__rigor_threaded_block_body__
4754
- private_constant :THREADED_BLOCK_BODY_KEY
5869
+ THREADED_BLOCK_BODY = TypingState.frame(
5870
+ :threaded_block_body, reason: "a suppression flag installed per fold",
5871
+ witness: "spec/fixtures/typing_state_witnesses/threaded_block_body.rb"
5872
+ )
5873
+ private_constant :THREADED_BLOCK_BODY
4755
5874
 
4756
5875
  def block_body_threading_suppressed?
4757
- Thread.current[THREADED_BLOCK_BODY_KEY] ? true : false
5876
+ typing_state[THREADED_BLOCK_BODY] ? true : false
4758
5877
  end
4759
5878
 
4760
5879
  # Runs the block with the scope-threading fold suppressed, restoring the previous state (not clearing
4761
5880
  # it) on the way out, so nesting a suppressed region inside another cannot re-enable threading.
4762
- def without_block_body_threading
4763
- previous = Thread.current[THREADED_BLOCK_BODY_KEY]
4764
- Thread.current[THREADED_BLOCK_BODY_KEY] = true
4765
- begin
4766
- yield
4767
- ensure
4768
- Thread.current[THREADED_BLOCK_BODY_KEY] = previous
4769
- end
5881
+ def without_block_body_threading(&)
5882
+ TypingState.current.with(THREADED_BLOCK_BODY, true, &)
4770
5883
  end
4771
5884
 
4772
5885
  # Every variable-write form whose name `StatementEvaluator` threads into the following statement's
@@ -4961,11 +6074,13 @@ module Rigor
4961
6074
  # position matches. `r = [1, 2].find(-> { 0 }) { |e| e > 5 }` is `0` at runtime; the walk answered `nil`,
4962
6075
  # and `r + 1` then reported a nil receiver on correct code. The other supported methods take no argument.
4963
6076
  PER_ELEMENT_TUPLE_METHODS = Set[
4964
- :map, :collect, :filter_map, :flat_map,
4965
- :select, :filter, :reject,
4966
- :find, :detect, :find_index, :index
6077
+ :map, :collect, :filter_map, :flat_map, :collect_concat,
6078
+ :select, :filter, :find_all, :reject, :take_while, :drop_while,
6079
+ :partition, :count, :find, :detect, :rfind, :find_index, :index, :rindex
4967
6080
  ].freeze
4968
- private_constant :PER_ELEMENT_TUPLE_METHODS
6081
+ ARRAY_ONLY_PER_ELEMENT_METHODS = Set[:index, :rindex, :rfind].freeze
6082
+ REVERSE_PER_ELEMENT_METHODS = Set[:rfind, :rindex].freeze
6083
+ private_constant :PER_ELEMENT_TUPLE_METHODS, :ARRAY_ONLY_PER_ELEMENT_METHODS, :REVERSE_PER_ELEMENT_METHODS
4969
6084
 
4970
6085
  HASH_SHAPE_TRANSFORM_METHODS = Set[
4971
6086
  :transform_keys, :transform_keys!,
@@ -4984,23 +6099,35 @@ module Rigor
4984
6099
  private_constant :PER_ELEMENT_RANGE_LIMIT
4985
6100
 
4986
6101
  def try_per_element_block_fold(call_node, receiver_type)
4987
- return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
6102
+ name = call_node.name
6103
+ return nil unless PER_ELEMENT_TUPLE_METHODS.include?(name)
6104
+ return nil if ARRAY_ONLY_PER_ELEMENT_METHODS.include?(name) && !receiver_type.is_a?(Type::Tuple)
4988
6105
  return nil unless call_node.arguments.nil?
4989
6106
 
4990
6107
  element_types = per_element_elements_of(receiver_type)
4991
6108
  return nil if element_types.nil? || element_types.empty?
4992
6109
 
4993
- per_position = per_element_block_results(call_node.block, element_types)
6110
+ per_position = evaluate_per_element_positions(call_node.block, element_types, name)
6111
+ return nil if per_position.nil?
6112
+
6113
+ assemble_per_element_result(name, per_position, element_types) ||
6114
+ undecided_fold_floor(name, element_types, receiver_type)
6115
+ end
6116
+
6117
+ def evaluate_per_element_positions(block, element_types, method_name)
6118
+ reverse = REVERSE_PER_ELEMENT_METHODS.include?(method_name)
6119
+ eval_elements = reverse ? element_types.reverse : element_types
6120
+ per_position = per_element_block_results(block, eval_elements)
4994
6121
  return nil if per_position.nil? || per_position.any?(&:nil?)
4995
6122
 
4996
- assemble_per_element_result(call_node.name, per_position, element_types) ||
4997
- undecided_fold_floor(call_node.name, element_types, receiver_type)
6123
+ reverse ? per_position.reverse : per_position
4998
6124
  end
4999
6125
 
5000
- # The honest answer for the find family (`find` / `detect` / `find_index` / `index`) and the filter family
5001
- # (`select` / `filter` / `reject`) when this fold walked every position and the assembler still could not
5002
- # decide — which happens for exactly one reason: some position's predicate is not a `Constant`, so "the
5003
- # first matching one" or "the ones kept" is not a static fact.
6126
+ # The honest answer for the find family (`find` / `detect` / `rfind` / `find_index` / `index` / `rindex`),
6127
+ # the filter family (`select` / `filter` / `find_all` / `reject` / `take_while` / `drop_while`), and `count`
6128
+ # when this fold walked every position and the assembler still could not decide — which happens for
6129
+ # exactly one reason: some position's predicate is not a `Constant`, so "the matching one" or "the ones
6130
+ # kept" is not a static fact.
5004
6131
  #
5005
6132
  # Falling through to the dispatcher was WRONG for this family, and issue #617 residue (1) is the bill:
5006
6133
  # `seen = 0; [1, 2].find do |e| seen += 1; seen == 2 end` answered `nil` where the runtime answers `2`.
@@ -5010,30 +6137,34 @@ module Rigor
5010
6137
  # typed the predicate per position and saw that it does not fold. Answering here is what keeps that
5011
6138
  # knowledge from being thrown away in favour of a worse-informed tier.
5012
6139
  #
5013
- # The floor is what `find` can return and no tighter: one of the receiver's own elements, or `nil` when
5014
- # no element matches. Value pinning survives because it is still true of every candidate — `find` hands
5015
- # back an element, it does not compute one. `find_index` / `index` answer a position in the receiver, so
5016
- # `Integer?` is their floor.
5017
- #
5018
- # `select` / `filter` / `reject` take a floor for the same reason. Their fall-through is not merely
5019
- # wider: `BlockFolding`'s filter folds DO answer on a `Constant` block, and the entry-scope pin hands
5020
- # them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2 end` answered a provably-empty `[]`
5021
- # where Ruby answers `[2]`. Nested inside a threaded body, where a position can be typed tail-only
5022
- # ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end` did the same. Their floor is an
5023
- # Array of the receiver's own elements: which of them survive is undecided, and what they are is not.
5024
- # A range receiver's elements are widened first ({#filter_family_floor}).
6140
+ # The floor is what `find` / `rfind` can return and no tighter: one of the receiver's own elements, or
6141
+ # `nil` when no element matches. Value pinning survives because it is still true of every candidate —
6142
+ # `find` hands back an element, it does not compute one. `find_index` / `index` / `rindex` answer a
6143
+ # position in the receiver, so `Integer?` is their floor.
6144
+ #
6145
+ # `select` / `filter` / `find_all` / `reject` / `take_while` / `drop_while` take a floor for the same
6146
+ # reason. Their fall-through is not merely wider: `BlockFolding`'s filter folds DO answer on a `Constant`
6147
+ # block, and the entry-scope pin hands them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2
6148
+ # end` answered a provably-empty `[]` where Ruby answers `[2]`. Nested inside a threaded body, where a
6149
+ # position can be typed tail-only ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end`
6150
+ # did the same. Their floor is an Array of the receiver's own elements: which of them survive is
6151
+ # undecided, and what they are is not. A range receiver's elements are widened first
6152
+ # ({#filter_family_floor}). `count` likewise floors to `Integer` so an undecided block never falls through
6153
+ # to `BlockFolding`'s entry-scope `0` or `size`.
5025
6154
  #
5026
6155
  # Nothing else in {PER_ELEMENT_TUPLE_METHODS} takes a floor. `map`'s assembler cannot decline, and
5027
- # `BlockFolding` never folds `filter_map` / `flat_map`, so their fall-through is the RBS `Array[U]`
6156
+ # `BlockFolding` never folds `filter_map` / `flat_map` / `partition`, so their fall-through is the RBS
5028
6157
  # projection.
5029
6158
  def undecided_fold_floor(method_name, element_types, receiver_type)
5030
6159
  case method_name
5031
- when :find, :detect
6160
+ when :find, :detect, :rfind
5032
6161
  Type::Combinator.union(*element_types, Type::Combinator.constant_of(nil))
5033
- when :select, :filter, :reject
6162
+ when :select, :filter, :find_all, :reject, :take_while, :drop_while
5034
6163
  filter_family_floor(element_types, receiver_type)
5035
- when :find_index, :index
6164
+ when :find_index, :index, :rindex
5036
6165
  Type::Combinator.union(Type::Combinator.nominal_of("Integer"), Type::Combinator.constant_of(nil))
6166
+ when :count
6167
+ Type::Combinator.nominal_of("Integer")
5037
6168
  end
5038
6169
  end
5039
6170
 
@@ -5569,10 +6700,15 @@ module Rigor
5569
6700
  end
5570
6701
 
5571
6702
  # Records `type` as the accumulator `block` receives when typed from this typer's scope
5572
- # ({.recorded_inject_accumulator}). The keys are weak, so a finished file's nodes do not stay reachable, and
5573
- # the values strong, so a record outlives a collection while its node does. Prism nodes compare by identity.
6703
+ # ({.recorded_inject_accumulator}). Prism nodes compare by identity. The keys are weak but the values strong,
6704
+ # and a recorded scope reaches the parse tree that holds the key, so a record never goes on its own (issue
6705
+ # #2412): the map is a run-scoped slot ({TypingState.release_run_scoped}), emptied when the run that filled it
6706
+ # ends.
5574
6707
  def record_inject_accumulator(block, type)
5575
- (Thread.current[INJECT_ACCUMULATOR_KEY] ||= ObjectSpace::WeakKeyMap.new)[block] = [scope, type].freeze
6708
+ record = [scope, type, LoopValueRecord.generation].freeze
6709
+ state = typing_state
6710
+ state.log_write(INJECT_ACCUMULATOR, block, record)
6711
+ state.weak_key_map(INJECT_ACCUMULATOR)[block] = record
5576
6712
  end
5577
6713
 
5578
6714
  # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
@@ -5687,18 +6823,44 @@ module Rigor
5687
6823
  result
5688
6824
  end
5689
6825
 
6826
+ PER_ELEMENT_ASSEMBLERS = {
6827
+ map: :assemble_map_result,
6828
+ collect: :assemble_map_result,
6829
+ filter_map: :assemble_filter_map_result,
6830
+ flat_map: :assemble_flat_map_result,
6831
+ collect_concat: :assemble_flat_map_result,
6832
+ select: :assemble_select_result,
6833
+ filter: :assemble_select_result,
6834
+ find_all: :assemble_select_result,
6835
+ reject: :assemble_reject_result,
6836
+ take_while: :assemble_take_while_result,
6837
+ drop_while: :assemble_drop_while_result,
6838
+ partition: :assemble_partition_result,
6839
+ count: :assemble_count_result,
6840
+ find: :assemble_find_result,
6841
+ detect: :assemble_find_result,
6842
+ rfind: :assemble_rfind_result,
6843
+ find_index: :assemble_find_index_result,
6844
+ index: :assemble_find_index_result,
6845
+ rindex: :assemble_rindex_result
6846
+ }.freeze
6847
+ private_constant :PER_ELEMENT_ASSEMBLERS
6848
+
5690
6849
  def assemble_per_element_result(method_name, per_position, element_types)
5691
- case method_name
5692
- when :map, :collect then Type::Combinator.tuple_of(*per_position)
5693
- when :filter_map then assemble_filter_map_result(per_position)
5694
- when :flat_map then assemble_flat_map_result(per_position)
5695
- when :select, :filter
5696
- assemble_filter_result(per_position, element_types, keep_on_truthy: true)
5697
- when :reject
5698
- assemble_filter_result(per_position, element_types, keep_on_truthy: false)
5699
- when :find, :detect then assemble_find_result(per_position, element_types)
5700
- when :find_index, :index then assemble_find_index_result(per_position)
5701
- end
6850
+ handler = PER_ELEMENT_ASSEMBLERS[method_name]
6851
+ send(handler, per_position, element_types) if handler
6852
+ end
6853
+
6854
+ def assemble_map_result(per_position, _element_types)
6855
+ Type::Combinator.tuple_of(*per_position)
6856
+ end
6857
+
6858
+ def assemble_select_result(per_position, element_types)
6859
+ assemble_filter_result(per_position, element_types, keep_on_truthy: true)
6860
+ end
6861
+
6862
+ def assemble_reject_result(per_position, element_types)
6863
+ assemble_filter_result(per_position, element_types, keep_on_truthy: false)
5702
6864
  end
5703
6865
 
5704
6866
  # `select` / `filter` / `reject`: keeps each receiver element whose per-position predicate result
@@ -5718,10 +6880,51 @@ module Rigor
5718
6880
  Type::Combinator.tuple_of(*kept)
5719
6881
  end
5720
6882
 
6883
+ # `take_while` / `drop_while`: stops at the first falsey `Constant` position when every preceding
6884
+ # position is a truthy `Constant` (or takes/drops all elements when every position is a truthy
6885
+ # `Constant`). Declines when an undecided position precedes the stopping point.
6886
+ def assemble_take_while_result(per_position, element_types)
6887
+ cutoff = leading_truthy_cutoff(per_position)
6888
+ return nil if cutoff.nil?
6889
+
6890
+ Type::Combinator.tuple_of(*element_types[0...cutoff])
6891
+ end
6892
+
6893
+ def assemble_drop_while_result(per_position, element_types)
6894
+ cutoff = leading_truthy_cutoff(per_position)
6895
+ return nil if cutoff.nil?
6896
+
6897
+ Type::Combinator.tuple_of(*element_types[cutoff..])
6898
+ end
6899
+
6900
+ def leading_truthy_cutoff(per_position)
6901
+ first_falsey_index = per_position.index { |type| falsey_constant?(type) }
6902
+ return (per_position.all?(Type::Constant) ? per_position.size : nil) if first_falsey_index.nil?
6903
+ return nil unless per_position[0...first_falsey_index].all?(Type::Constant)
6904
+
6905
+ first_falsey_index
6906
+ end
6907
+
6908
+ # `partition`: returns `Tuple[truthy_tuple, falsey_tuple]` when every position folds to a `Constant`.
6909
+ def assemble_partition_result(per_position, element_types)
6910
+ return nil unless per_position.all?(Type::Constant)
6911
+
6912
+ truthy = assemble_filter_result(per_position, element_types, keep_on_truthy: true)
6913
+ falsey = assemble_filter_result(per_position, element_types, keep_on_truthy: false)
6914
+ Type::Combinator.tuple_of(truthy, falsey)
6915
+ end
6916
+
6917
+ # `count`: returns `Constant[Integer]` of the truthy positions when every position folds to a `Constant`.
6918
+ def assemble_count_result(per_position, _element_types)
6919
+ return nil unless per_position.all?(Type::Constant)
6920
+
6921
+ Type::Combinator.constant_of(per_position.count { |type| truthy_constant?(type) })
6922
+ end
6923
+
5721
6924
  # `filter_map` folds tightly only when every per-position result is a `Constant`: positions whose
5722
6925
  # value is `nil` or `false` drop, the rest survive in declaration order. When any position is
5723
6926
  # non-Constant the dispatcher declines (returns nil) so the RBS tier widens to `Array[U]`.
5724
- def assemble_filter_map_result(per_position)
6927
+ def assemble_filter_map_result(per_position, _element_types = nil)
5725
6928
  return nil unless per_position.all?(Type::Constant)
5726
6929
 
5727
6930
  kept = per_position.reject { |type| type.value.nil? || type.value == false }
@@ -5736,7 +6939,7 @@ module Rigor
5736
6939
  #
5737
6940
  # `Type::Constant` only ever holds non-Array scalars (the carrier rejects Array literals), so a single
5738
6941
  # `Constant` safely contributes itself as a single Tuple element.
5739
- def assemble_flat_map_result(per_position)
6942
+ def assemble_flat_map_result(per_position, _element_types = nil)
5740
6943
  flattened = per_position.flat_map { |type| flat_map_contribution(type) }
5741
6944
  return nil if flattened.nil? || flattened.any?(&:nil?)
5742
6945
 
@@ -5766,15 +6969,32 @@ module Rigor
5766
6969
  Type::Combinator.union(*candidates.map { |index| element_types[index] })
5767
6970
  end
5768
6971
 
6972
+ # `rfind`: reverse of `find` — returns the last receiver element whose block result is Ruby-truthy,
6973
+ # joined with any undecided positions to its right.
6974
+ def assemble_rfind_result(per_position, element_types)
6975
+ candidates = last_match_candidates(per_position)
6976
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
6977
+
6978
+ Type::Combinator.union(*candidates.map { |index| element_types[index] })
6979
+ end
6980
+
5769
6981
  # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
5770
6982
  # nothing matches, on {#assemble_find_result}'s terms.
5771
- def assemble_find_index_result(per_position)
6983
+ def assemble_find_index_result(per_position, _element_types = nil)
5772
6984
  candidates = first_match_candidates(per_position)
5773
6985
  return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
5774
6986
 
5775
6987
  Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
5776
6988
  end
5777
6989
 
6990
+ # `rindex`: returns the index of the last truthy position, or `Constant[nil]` when nothing matches.
6991
+ def assemble_rindex_result(per_position, _element_types = nil)
6992
+ candidates = last_match_candidates(per_position)
6993
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
6994
+
6995
+ Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
6996
+ end
6997
+
5778
6998
  NO_MATCH = Type::Combinator.constant_of(nil)
5779
6999
  private_constant :NO_MATCH
5780
7000
 
@@ -5790,8 +7010,27 @@ module Rigor
5790
7010
  undecided + [first_truthy_index]
5791
7011
  end
5792
7012
 
7013
+ # The positions `rfind` / `rindex` may stop at — the last decisive truthy one and every undecided one
7014
+ # after it — `NO_MATCH` when every position folds falsey, or nil when an undecided position has no
7015
+ # truthy one before it.
7016
+ def last_match_candidates(per_position)
7017
+ last_truthy_index = per_position.rindex { |type| truthy_constant?(type) }
7018
+ if last_truthy_index.nil?
7019
+ return per_position.all?(Type::Constant) ? NO_MATCH : nil
7020
+ end
7021
+
7022
+ undecided = ((last_truthy_index + 1)...per_position.size).reject do |index|
7023
+ per_position[index].is_a?(Type::Constant)
7024
+ end
7025
+ [last_truthy_index] + undecided
7026
+ end
7027
+
5793
7028
  def truthy_constant?(type)
5794
- type.is_a?(Type::Constant) && type.value && type.value != false
7029
+ type.is_a?(Type::Constant) && !type.value.nil? && type.value != false
7030
+ end
7031
+
7032
+ def falsey_constant?(type)
7033
+ type.is_a?(Type::Constant) && (type.value.nil? || type.value == false)
5795
7034
  end
5796
7035
 
5797
7036
  # Per-pair block fold for `HashShape#transform_keys` and `HashShape#transform_values` (and their bang