rigortype 0.3.9 → 0.4.1

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 (407) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -4
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/08-understanding-errors.md +3 -2
  14. data/docs/handbook/10-sorbet.md +9 -10
  15. data/docs/handbook/11-sig-gen.md +454 -13
  16. data/docs/install.md +87 -15
  17. data/docs/manual/02-cli-reference.md +65 -4
  18. data/docs/manual/03-configuration.md +12 -0
  19. data/docs/manual/04-diagnostics.md +4 -0
  20. data/docs/manual/07-plugins.md +1 -1
  21. data/docs/manual/08-skills.md +4 -3
  22. data/docs/manual/10-mcp-server.md +3 -2
  23. data/docs/manual/12-caching.md +43 -4
  24. data/docs/manual/14-rails-quickstart.md +19 -11
  25. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  26. data/docs/manual/19-effect-labels.md +10 -0
  27. data/docs/manual/plugins/README.md +7 -0
  28. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  29. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  30. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  31. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  32. data/docs/manual/plugins/rigor-activejob.md +7 -0
  33. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  34. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  35. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  36. data/docs/manual/plugins/rigor-grape.md +106 -0
  37. data/docs/manual/plugins/rigor-graphql.md +23 -2
  38. data/docs/manual/plugins/rigor-pundit.md +8 -1
  39. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  40. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  41. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  42. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  43. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  44. data/exe/rigor +19 -4
  45. data/lib/rigor/analysis/baseline.rb +1 -1
  46. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  47. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  48. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  49. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  50. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/source_arity.rb +427 -0
  52. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  53. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  54. data/lib/rigor/analysis/check_rules.rb +638 -136
  55. data/lib/rigor/analysis/dependency_recorder.rb +222 -17
  56. data/lib/rigor/analysis/fact_store.rb +9 -0
  57. data/lib/rigor/analysis/incremental.rb +26 -0
  58. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  59. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  60. data/lib/rigor/analysis/incremental_session.rb +309 -53
  61. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  62. data/lib/rigor/analysis/project_scan.rb +11 -1
  63. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  64. data/lib/rigor/analysis/reachability/scan_cache.rb +3 -1
  65. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  66. data/lib/rigor/analysis/run_cache_key.rb +46 -18
  67. data/lib/rigor/analysis/run_stats.rb +2 -2
  68. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +168 -12
  69. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  70. data/lib/rigor/analysis/runner/pool_coordinator.rb +109 -17
  71. data/lib/rigor/analysis/runner/project_pre_passes.rb +44 -17
  72. data/lib/rigor/analysis/runner.rb +603 -54
  73. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  74. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  75. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  76. data/lib/rigor/analysis/template_unit_paths.rb +93 -0
  77. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  78. data/lib/rigor/analysis/template_units.rb +409 -0
  79. data/lib/rigor/analysis/worker_session.rb +57 -13
  80. data/lib/rigor/bleeding_edge.rb +0 -2
  81. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  82. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  83. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  84. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  85. data/lib/rigor/cache/descriptor.rb +45 -1
  86. data/lib/rigor/cache/engine_source.rb +29 -1
  87. data/lib/rigor/cache/file_digest.rb +28 -0
  88. data/lib/rigor/cache/incremental_snapshot.rb +102 -8
  89. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  90. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  91. data/lib/rigor/cache/store.rb +63 -4
  92. data/lib/rigor/cache/value_digest.rb +325 -0
  93. data/lib/rigor/ci_detector.rb +1 -0
  94. data/lib/rigor/cli/check_command.rb +83 -13
  95. data/lib/rigor/cli/doc_links.rb +1 -1
  96. data/lib/rigor/cli/docs_command.rb +4 -4
  97. data/lib/rigor/cli/doctor_command.rb +1 -1
  98. data/lib/rigor/cli/effects_report.rb +2 -2
  99. data/lib/rigor/cli/plugin_command.rb +3 -3
  100. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  101. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  102. data/lib/rigor/cli/skill_command.rb +1 -1
  103. data/lib/rigor/cli/skill_describe.rb +0 -1
  104. data/lib/rigor/cli/type_of_command.rb +19 -5
  105. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  106. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  107. data/lib/rigor/cli/unused_command.rb +3 -3
  108. data/lib/rigor/cli.rb +21 -3
  109. data/lib/rigor/config_audit.rb +3 -2
  110. data/lib/rigor/config_path.rb +29 -0
  111. data/lib/rigor/configuration/severity_profile.rb +24 -3
  112. data/lib/rigor/configuration.rb +71 -7
  113. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  114. data/lib/rigor/effects/attribution.rb +11 -2
  115. data/lib/rigor/effects/callee_rule.rb +368 -0
  116. data/lib/rigor/effects/catalog.rb +7 -4
  117. data/lib/rigor/effects/collector.rb +11 -5
  118. data/lib/rigor/effects/config_envelopes.rb +9 -2
  119. data/lib/rigor/effects/definition_context.rb +179 -0
  120. data/lib/rigor/effects/effect_table.rb +11 -3
  121. data/lib/rigor/effects/envelope_check.rb +1 -1
  122. data/lib/rigor/effects/envelope_index.rb +15 -0
  123. data/lib/rigor/effects/file_collection.rb +59 -5
  124. data/lib/rigor/effects/framework_units.rb +1 -1
  125. data/lib/rigor/effects/identity.rb +16 -0
  126. data/lib/rigor/effects/local_ownership.rb +38 -12
  127. data/lib/rigor/effects/method_key.rb +21 -0
  128. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  129. data/lib/rigor/effects/plugin_facts.rb +49 -37
  130. data/lib/rigor/effects/propagator.rb +295 -16
  131. data/lib/rigor/effects/registry.rb +1 -1
  132. data/lib/rigor/effects/scanner.rb +121 -72
  133. data/lib/rigor/effects/signature_sources.rb +1 -1
  134. data/lib/rigor/effects/snapshot.rb +2 -1
  135. data/lib/rigor/effects/summary.rb +27 -4
  136. data/lib/rigor/effects/unit_scan.rb +385 -30
  137. data/lib/rigor/effects/visibility.rb +101 -0
  138. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  139. data/lib/rigor/environment/lockfile_resolver.rb +27 -1
  140. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  141. data/lib/rigor/environment/member_consistency.rb +298 -0
  142. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  143. data/lib/rigor/environment/rbs_loader.rb +400 -134
  144. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  145. data/lib/rigor/environment.rb +104 -96
  146. data/lib/rigor/hashing/xxh3.rb +264 -0
  147. data/lib/rigor/inference/acceptance.rb +139 -9
  148. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  149. data/lib/rigor/inference/block_call_timing.rb +338 -0
  150. data/lib/rigor/inference/block_parameter_binder.rb +88 -26
  151. data/lib/rigor/inference/block_repetition.rb +71 -0
  152. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  153. data/lib/rigor/inference/budget_trace.rb +2 -1
  154. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  155. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  156. data/lib/rigor/inference/captured_locals.rb +393 -15
  157. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  158. data/lib/rigor/inference/content_join.rb +200 -27
  159. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  160. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  161. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  162. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  163. data/lib/rigor/inference/declaration_walk.rb +7 -0
  164. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  165. data/lib/rigor/inference/def_return_typer.rb +11 -7
  166. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  167. data/lib/rigor/inference/definer_resolution.rb +266 -0
  168. data/lib/rigor/inference/element_read_widening.rb +22 -10
  169. data/lib/rigor/inference/error_info.rb +196 -0
  170. data/lib/rigor/inference/expression_typer.rb +1665 -538
  171. data/lib/rigor/inference/external_ancestor_resolution.rb +264 -0
  172. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  173. data/lib/rigor/inference/global_write_census.rb +239 -0
  174. data/lib/rigor/inference/guard_rebinding.rb +488 -0
  175. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  176. data/lib/rigor/inference/index_write_widening.rb +16 -3
  177. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  178. data/lib/rigor/inference/jump_targets.rb +82 -0
  179. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  180. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  181. data/lib/rigor/inference/last_line.rb +340 -0
  182. data/lib/rigor/inference/last_status.rb +144 -0
  183. data/lib/rigor/inference/macro_block_self_type.rb +179 -17
  184. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  185. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  186. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  187. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  188. data/lib/rigor/inference/match_rebinding.rb +392 -0
  189. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  190. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  191. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  192. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  193. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  194. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  195. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  196. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  197. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  198. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +901 -38
  199. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  200. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  201. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  202. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  203. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  204. data/lib/rigor/inference/module_function_state.rb +213 -0
  205. data/lib/rigor/inference/multi_target_binder.rb +345 -52
  206. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  207. data/lib/rigor/inference/mutation_widening.rb +70 -48
  208. data/lib/rigor/inference/narrowing.rb +674 -124
  209. data/lib/rigor/inference/operand_effects.rb +167 -0
  210. data/lib/rigor/inference/operand_walk.rb +88 -0
  211. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  212. data/lib/rigor/inference/parameter_inference_collector.rb +13 -11
  213. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  214. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  215. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  216. data/lib/rigor/inference/receiver_alias.rb +90 -1
  217. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  218. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  219. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  220. data/lib/rigor/inference/return_barrier.rb +54 -0
  221. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  222. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  223. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  224. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  225. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  226. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  227. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  228. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  229. data/lib/rigor/inference/scope_indexer.rb +5913 -701
  230. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  231. data/lib/rigor/inference/statement_evaluator.rb +3249 -497
  232. data/lib/rigor/inference/stored_block_call.rb +54 -0
  233. data/lib/rigor/inference/string_mutation.rb +44 -7
  234. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  235. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  236. data/lib/rigor/language_server/debouncer.rb +0 -1
  237. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  238. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  239. data/lib/rigor/language_server/project_context.rb +5 -3
  240. data/lib/rigor/mcp/server.rb +2 -1
  241. data/lib/rigor/plugin/base.rb +196 -13
  242. data/lib/rigor/plugin/box_probe.rb +91 -0
  243. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  244. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  245. data/lib/rigor/plugin/io_boundary.rb +82 -4
  246. data/lib/rigor/plugin/isolation.rb +1 -1
  247. data/lib/rigor/plugin/loader.rb +2 -1
  248. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  249. data/lib/rigor/plugin/manifest.rb +71 -10
  250. data/lib/rigor/plugin/registry.rb +35 -1
  251. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  252. data/lib/rigor/plugin/template_unit.rb +196 -0
  253. data/lib/rigor/plugin/trust_policy.rb +3 -3
  254. data/lib/rigor/plugin.rb +1 -0
  255. data/lib/rigor/protection/discovery_seed.rb +20 -9
  256. data/lib/rigor/protection/kill_signature.rb +0 -1
  257. data/lib/rigor/protection/mutation_cache.rb +4 -5
  258. data/lib/rigor/rbs_extended.rb +27 -0
  259. data/lib/rigor/reflection/constant_ancestors.rb +146 -0
  260. data/lib/rigor/reflection/constant_path.rb +41 -13
  261. data/lib/rigor/reflection.rb +73 -107
  262. data/lib/rigor/scope/discovery_index.rb +351 -2
  263. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  264. data/lib/rigor/scope/resolution_chain.rb +884 -0
  265. data/lib/rigor/scope.rb +981 -198
  266. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  267. data/lib/rigor/sig_gen/classification.rb +22 -5
  268. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  269. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  270. data/lib/rigor/sig_gen/generator.rb +580 -47
  271. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  272. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  273. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  274. data/lib/rigor/sig_gen/renderer.rb +124 -9
  275. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  276. data/lib/rigor/sig_gen/write_result.rb +19 -3
  277. data/lib/rigor/sig_gen/writer.rb +166 -35
  278. data/lib/rigor/sig_gen.rb +3 -0
  279. data/lib/rigor/signature_path_audit.rb +1 -1
  280. data/lib/rigor/source/node_walker.rb +0 -3
  281. data/lib/rigor/source/parameter_envelope.rb +72 -0
  282. data/lib/rigor/source.rb +1 -0
  283. data/lib/rigor/type/combinator.rb +112 -12
  284. data/lib/rigor/type/difference.rb +1 -0
  285. data/lib/rigor/type/hash_shape.rb +1 -1
  286. data/lib/rigor/type/refined.rb +1 -0
  287. data/lib/rigor/version.rb +1 -1
  288. data/lib/rigor.rb +1 -0
  289. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  290. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  291. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  292. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  293. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  294. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  295. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  296. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  297. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  298. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  299. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  300. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  301. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  302. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  303. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  304. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  305. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  306. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  307. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  308. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  309. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  310. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  311. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  312. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  313. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  314. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  315. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  316. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  317. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  318. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  319. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  320. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  321. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  322. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  323. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  324. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  325. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  326. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  327. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  328. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  329. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  330. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  331. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  332. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  333. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  334. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  335. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  336. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  337. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  338. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  339. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  340. data/sig/rigor/analysis/baseline.rbs +69 -7
  341. data/sig/rigor/analysis/fact_store.rbs +1 -1
  342. data/sig/rigor/analysis/project_scan.rbs +74 -0
  343. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  344. data/sig/rigor/effects/effect_table.rbs +60 -0
  345. data/sig/rigor/effects/envelope.rbs +97 -0
  346. data/sig/rigor/effects/envelope_index.rbs +33 -0
  347. data/sig/rigor/effects/file_collection.rbs +81 -0
  348. data/sig/rigor/effects/label.rbs +26 -0
  349. data/sig/rigor/effects/label_set.rbs +46 -0
  350. data/sig/rigor/effects/method_key.rbs +24 -0
  351. data/sig/rigor/effects/origin.rbs +50 -0
  352. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  353. data/sig/rigor/effects/registry.rbs +68 -0
  354. data/sig/rigor/effects/summary.rbs +50 -0
  355. data/sig/rigor/effects/taint_cause.rbs +12 -0
  356. data/sig/rigor/environment.rbs +14 -10
  357. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  358. data/sig/rigor/inference.rbs +6 -4
  359. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  360. data/sig/rigor/plugin/base.rbs +30 -7
  361. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  362. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  363. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  364. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  365. data/sig/rigor/plugin/io_boundary.rbs +2 -1
  366. data/sig/rigor/plugin/loader.rbs +3 -3
  367. data/sig/rigor/plugin/manifest.rbs +50 -11
  368. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  369. data/sig/rigor/plugin/registry.rbs +62 -1
  370. data/sig/rigor/plugin.rbs +1 -1
  371. data/sig/rigor/rbs_extended.rbs +1 -1
  372. data/sig/rigor/reflection.rbs +7 -6
  373. data/sig/rigor/scope.rbs +177 -20
  374. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  375. data/sig/rigor/source.rbs +4 -4
  376. data/sig/rigor/testing.rbs +10 -4
  377. data/sig/rigor/type.rbs +6 -0
  378. data/sig/rigor.rbs +38 -20
  379. data/skills/rigor-ask/SKILL.md +8 -5
  380. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  381. data/skills/rigor-ci-setup/SKILL.md +17 -21
  382. data/skills/rigor-doctor/SKILL.md +24 -22
  383. data/skills/rigor-doctor/references/01-checks.md +97 -33
  384. data/skills/rigor-editor-setup/SKILL.md +6 -4
  385. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  386. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  387. data/skills/rigor-next-steps/SKILL.md +4 -2
  388. data/skills/rigor-plugin-author/SKILL.md +19 -23
  389. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  390. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  391. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  392. data/skills/rigor-plugin-review/SKILL.md +6 -4
  393. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  394. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  395. data/skills/rigor-project-init/SKILL.md +46 -18
  396. data/skills/rigor-project-init/references/01-detect.md +13 -9
  397. data/skills/rigor-project-init/references/02-configure.md +40 -10
  398. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  399. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  400. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  401. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  402. data/skills/rigor-type-oracle/SKILL.md +4 -6
  403. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  404. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  405. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  406. data/skills/rigor-upgrade/SKILL.md +13 -8
  407. metadata +132 -1
@@ -9,26 +9,40 @@ require_relative "../source/constant_path"
9
9
  require_relative "../source/node_children"
10
10
  require_relative "../source/node_walker"
11
11
  require_relative "../analysis/self_call_resolution_recorder"
12
+ require_relative "block_call_timing"
13
+ require_relative "block_repetition"
12
14
  require_relative "block_parameter_binder"
13
15
  require_relative "method_parameter_binder"
14
16
  require_relative "body_fixpoint"
15
17
  require_relative "budget_trace"
16
18
  require_relative "captured_locals"
19
+ require_relative "closure_escape_analyzer"
20
+ require_relative "receiver_blind_block"
17
21
  require_relative "def_node_resolver"
18
22
  require_relative "dynamic_origin"
23
+ require_relative "error_info"
24
+ require_relative "external_ancestor_resolution"
19
25
  require_relative "origin_lookup"
20
26
  require_relative "../effects/collector"
21
27
  require_relative "fallback"
22
28
  require_relative "flow_tracer"
29
+ require_relative "index_write_widening"
23
30
  require_relative "indexed_narrowing"
31
+ require_relative "jump_targets"
32
+ require_relative "define_method_block_self"
24
33
  require_relative "macro_block_self_type"
34
+ require_relative "match_rebinding"
25
35
  require_relative "method_dispatcher"
26
36
  require_relative "mutation_widening"
27
37
  require_relative "narrowing"
38
+ require_relative "optimistic_origin"
28
39
  require_relative "receiver_alias"
40
+ require_relative "repeated_or_writes"
29
41
  require_relative "singleton_object_constant"
30
- require_relative "optimistic_origin"
42
+ require_relative "stored_block_call"
31
43
  require_relative "struct_fold_safety"
44
+ require_relative "unknown_store_widening"
45
+ require_relative "unthreaded_rebinds"
32
46
  require_relative "version_guard"
33
47
 
34
48
  module Rigor
@@ -99,12 +113,12 @@ module Rigor
99
113
  Prism::ConstantPathNode => :type_of_constant_path,
100
114
  Prism::ConstantWriteNode => :type_of_assignment_write,
101
115
  Prism::ConstantPathWriteNode => :type_of_assignment_write,
102
- Prism::ConstantOperatorWriteNode => :type_of_assignment_write,
103
- Prism::ConstantOrWriteNode => :type_of_assignment_write,
104
- Prism::ConstantAndWriteNode => :type_of_assignment_write,
105
- Prism::ConstantPathOperatorWriteNode => :type_of_assignment_write,
106
- Prism::ConstantPathOrWriteNode => :type_of_assignment_write,
107
- Prism::ConstantPathAndWriteNode => :type_of_assignment_write,
116
+ Prism::ConstantOperatorWriteNode => :type_of_compound_constant_write,
117
+ Prism::ConstantOrWriteNode => :type_of_compound_constant_write,
118
+ Prism::ConstantAndWriteNode => :type_of_compound_constant_write,
119
+ Prism::ConstantPathOperatorWriteNode => :type_of_compound_constant_write,
120
+ Prism::ConstantPathOrWriteNode => :type_of_compound_constant_write,
121
+ Prism::ConstantPathAndWriteNode => :type_of_compound_constant_write,
108
122
  # Self and instance/class/global variables
109
123
  Prism::SelfNode => :type_of_self_node,
110
124
  Prism::InstanceVariableReadNode => :type_of_instance_variable_read,
@@ -126,9 +140,9 @@ module Rigor
126
140
  Prism::LocalVariableOperatorWriteNode => :type_of_compound_variable_write,
127
141
  Prism::LocalVariableOrWriteNode => :type_of_compound_variable_write,
128
142
  Prism::LocalVariableAndWriteNode => :type_of_compound_variable_write,
129
- Prism::IndexOperatorWriteNode => :type_of_assignment_write,
130
- Prism::IndexOrWriteNode => :type_of_assignment_write,
131
- Prism::IndexAndWriteNode => :type_of_assignment_write,
143
+ Prism::IndexOperatorWriteNode => :type_of_index_compound_write,
144
+ Prism::IndexOrWriteNode => :type_of_index_compound_write,
145
+ Prism::IndexAndWriteNode => :type_of_index_compound_write,
132
146
  Prism::MultiWriteNode => :type_of_assignment_write,
133
147
  # LHS-only target nodes (destructuring assignment, pattern matching, `for x in xs`, block parameter
134
148
  # `|a, (b, c)|`). They have no value to extract — the type-of pass acknowledges the node class so the
@@ -183,8 +197,8 @@ module Rigor
183
197
  Prism::SplatNode => :type_of_non_value,
184
198
  # Control flow (Slice 3 phase 1): branch types are unioned, jumps
185
199
  # type as Bot, loops type as Constant[nil].
186
- Prism::IfNode => :type_of_if,
187
- Prism::UnlessNode => :type_of_unless,
200
+ Prism::IfNode => :type_of_conditional,
201
+ Prism::UnlessNode => :type_of_conditional,
188
202
  Prism::ElseNode => :type_of_else,
189
203
  Prism::AndNode => :type_of_and_or,
190
204
  Prism::OrNode => :type_of_and_or,
@@ -229,10 +243,15 @@ module Rigor
229
243
  NO_STATIC_HASH_KEY = Object.new.freeze
230
244
  private_constant :NO_STATIC_HASH_KEY
231
245
 
232
- def initialize(scope:, tracer: nil)
246
+ # `operand_types` is the identity-comparing `Prism::Node => Rigor::Type` table of the later operands a
247
+ # threading `StatementEvaluator` root typed from the scope the earlier operands left (issue #1256), or nil.
248
+ # It is consulted only by this typer's own descent: a node it holds is the same node typed from its own
249
+ # entry scope, so answering it here is what typing it there would answer.
250
+ def initialize(scope:, tracer: nil, operand_types: nil, typing_node: nil)
233
251
  @scope = scope
234
252
  @tracer = tracer
235
- @typing_node = nil
253
+ @operand_types = operand_types
254
+ @typing_node = typing_node
236
255
  end
237
256
 
238
257
  def type_of(node)
@@ -256,6 +275,9 @@ module Rigor
256
275
  declared = scope.declared_types[node]
257
276
  return declared if declared
258
277
 
278
+ threaded = @operand_types&.[](node)
279
+ return threaded if threaded
280
+
259
281
  return type_of_virtual(node) if node.is_a?(AST::Node)
260
282
 
261
283
  handler = PRISM_DISPATCH[node.class]
@@ -275,6 +297,28 @@ module Rigor
275
297
  infer_user_method_return(def_node, receiver, arg_types)
276
298
  end
277
299
 
300
+ # The `receiver[args]` read a compound index write performs before it stores (`c[k] += v` reads `c[k]`),
301
+ # or nil when no tier answers. No `Prism::CallNode` for that read exists in the tree, so the write node
302
+ # itself stands in as the call context: it carries the same `receiver` / `arguments` / `block` a
303
+ # plain `c[k]` call does, which is what the context-reading tiers consult. With it, the read reaches
304
+ # the tiers a plain `c[k]` takes, in the same order — the own-`def` override check, the dispatcher's
305
+ # node- and scope-gated tiers, then the project `def` inference a dispatch miss falls to — so a project
306
+ # `[]` with no signature answers from its body instead of the whole write degrading to `Dynamic[top]`.
307
+ #
308
+ # A plugin `dynamic_return` rule written for `[]` still does not answer this read. Its `methods:` gate
309
+ # matches `call_node.name`, which an index write does not have, and the shipped rules decline any node
310
+ # that is not a `Prism::CallNode`, by an explicit check or through the rescue around a rule that reads
311
+ # `name` anyway. Minting a `Prism::CallNode` for the read would hand plugins a node that is not in the
312
+ # tree, through a constructor whose field list changed inside the `prism` range the gemspec accepts.
313
+ def implicit_index_read_type(node, receiver, arg_types)
314
+ return splat_index_read_type(node, receiver, arg_types) if node.arguments&.arguments&.any?(Prism::SplatNode)
315
+
316
+ try_overriding_def_dispatch(node, receiver, arg_types, method_name: :[]) ||
317
+ index_read_dispatch(node, receiver, arg_types) ||
318
+ try_user_method_inference(receiver, node, arg_types, method_name: :[]) ||
319
+ try_project_singleton_inference(receiver, node, arg_types, method_name: :[])
320
+ end
321
+
278
322
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
279
323
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
280
324
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
@@ -313,6 +357,44 @@ module Rigor
313
357
  end
314
358
  end
315
359
 
360
+ # Issue #1125 — the argument types of the call currently being re-typed into the body being walked,
361
+ # or nil outside such a frame. A `f(...)` call inside `def m(...)` re-supplies exactly the arguments
362
+ # `m` itself was called with, so {#call_arg_types} expands the forwarding node to THIS list instead of
363
+ # the `Dynamic[top]` a bare `Prism::ForwardingArgumentsNode` types as.
364
+ #
365
+ # A thread-local for the same reason the yield value is (see {YIELD_VALUE_KEY}): `...` names the
366
+ # frame's caller, not a binding, and the body is walked through scopes the inference rebuilds freely.
367
+ # It is installed unconditionally — with nil — at every user-method inference frame, so a body reached
368
+ # without `...` cannot read its caller's argument list. Because each frame installs its OWN list, a
369
+ # chain `a(...) -> b(...) -> c(...)` threads through: `b`'s frame holds the types `a` expanded and
370
+ # `c`'s holds `b`'s. The list is the callee's call-site `arg_types`, which is what keeps the ADR-84
371
+ # return memo's `(def_node, receiver, arg_types)` key complete for a forwarding def.
372
+ FORWARDED_ARGS_KEY = :__rigor_forwarded_call_arg_types__
373
+ private_constant :FORWARDED_ARGS_KEY
374
+
375
+ def self.current_forwarded_arg_types
376
+ Thread.current[FORWARDED_ARGS_KEY]
377
+ end
378
+
379
+ # Runs `block` with `types` installed as the frame's forwarded argument list, restoring the previous
380
+ # frame on exit.
381
+ def self.with_forwarded_arg_types(types, &block)
382
+ previous = Thread.current[FORWARDED_ARGS_KEY]
383
+ Thread.current[FORWARDED_ARGS_KEY] = types
384
+ begin
385
+ block.call
386
+ ensure
387
+ Thread.current[FORWARDED_ARGS_KEY] = previous
388
+ end
389
+ end
390
+
391
+ # The two call-site channels a user-method body may read from its caller — the block a `yield`
392
+ # reaches ({#with_yield_value_type}) and the argument list `...` re-supplies
393
+ # ({#with_forwarded_arg_types}) — installed together, both even when nil, for exactly one frame.
394
+ def self.with_call_site_frame(yield_type, forwarded_arg_types, &)
395
+ with_yield_value_type(yield_type) { with_forwarded_arg_types(forwarded_arg_types, &) }
396
+ end
397
+
316
398
  private
317
399
 
318
400
  attr_reader :scope, :tracer
@@ -361,33 +443,86 @@ module Rigor
361
443
  # evaluator takes — and an operator the receiver does not answer widens to `Dynamic[top]` rather than
362
444
  # inventing the rvalue.
363
445
  #
364
- # Constant and index targets keep {#type_of_assignment_write}: a constant is not rebound in a loop body,
365
- # and `IndexOperatorWriteNode` is typed through `Scope#type_of`'s own indexed path by
366
- # `StatementEvaluator#eval_index_write`.
446
+ # Constant targets have {#type_of_compound_constant_write}, over the same algebra. Index targets have their
447
+ # own handler, {#type_of_index_compound_write}.
367
448
  def type_of_compound_variable_write(node)
368
- current = compound_write_current_binding(node)
449
+ compound_write_value(node, compound_write_current_binding(node), type_of(node.value))
450
+ end
451
+
452
+ # `H ||= 0` / `Conf::LIMIT += 1` as an EXPRESSION: the value it stores, by
453
+ # {#type_of_compound_variable_write}'s algebra. Typed as the rvalue alone, `H = { x: 1 }; v = (H ||= 0);
454
+ # v[:x]` reported `Integer#[]` on a program whose `v` is `H`, and `F.transform_values { |e| F ||= 0 }`
455
+ # folded every value to `0`.
456
+ #
457
+ # A constant has no scope binding, so its current binding is what a plain read of the same spelling
458
+ # resolves to at the write site — the lexical ladder, the in-source table of plain writes, then RBS —
459
+ # less the caller-derived rungs a top-level body keeps for reads Ruby raises on. The memoization idiom
460
+ # (`def registry = REGISTRY ||= {}`, legal where a plain `REGISTRY = {}` is a dynamic constant
461
+ # assignment) writes a constant nothing else binds, so an unresolved target keeps the rvalue reading
462
+ # exactly as an unbound variable does — unless a write other than a memo binds it
463
+ # ({#written_constant_binding}). A path whose base renders no static name (`klass::X`, `self::X`) names
464
+ # no binding the resolver can look up, and reads as unbound too, on the same condition.
465
+ def type_of_compound_constant_write(node)
369
466
  rhs = type_of(node.value)
467
+ compound_write_value(node, compound_write_constant_binding(node), rhs)
468
+ end
370
469
 
470
+ def compound_write_value(node, current, rhs)
371
471
  case node
372
472
  when Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
373
- Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode
473
+ Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
474
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode
374
475
  # An UNBOUND target is the memoization idiom (`def self.default = @default ||= new`): nothing
375
476
  # has written the variable on any path the analyzer saw, so the stored value is the rvalue.
376
477
  # Reading it as `Dynamic[top] | rhs` would skip every memoized singleton in `sig-gen`
377
478
  # (ADR-5 optimism; three `.default` readers went `sig.skipped.untyped-return` without this).
378
- return rhs if current.nil?
479
+ #
480
+ # The index rule's exception applies: an rvalue with no truthy part makes the write a guard, not a
481
+ # memo. `@settings ||= raise "boot first"` returns only when something the analyzer did not see set
482
+ # `@settings`, and `@verbose ||= false` answers `true` once one did, so the value is that unseen
483
+ # binding — `Dynamic[top]`, the statement evaluator's reading of an unbound target — never the
484
+ # rvalue's `bot` or `false` alone. A local is no exception: the scope a loop body is typed from does
485
+ # not carry what the previous iteration stored.
486
+ if current.nil?
487
+ return rhs unless Narrowing.narrow_truthy(rhs).is_a?(Type::Bot)
488
+
489
+ current = dynamic_top
490
+ end
379
491
 
380
492
  Type::Combinator.union(Narrowing.narrow_truthy(current), rhs)
381
493
  when Prism::LocalVariableAndWriteNode, Prism::InstanceVariableAndWriteNode,
382
- Prism::ClassVariableAndWriteNode, Prism::GlobalVariableAndWriteNode
383
- return rhs if current.nil?
384
-
385
- Type::Combinator.union(Narrowing.narrow_falsey(current), rhs)
494
+ Prism::ClassVariableAndWriteNode, Prism::GlobalVariableAndWriteNode,
495
+ Prism::ConstantAndWriteNode, Prism::ConstantPathAndWriteNode
496
+ # `&&=` is no memo: an unset target returns its own `nil` without evaluating the rvalue (a constant or
497
+ # class variable raises instead). Beside the rvalue, an UNBOUND target's value is its own falsey value —
498
+ # `nil` when unset, or a falsey value a write the analyzer did not see stored — which
499
+ # `narrow_falsey(Dynamic[top])` carries as `Dynamic[top]`, the statement evaluator's reading. The index
500
+ # rule says the same of `h[k] &&= v`. Read as the rvalue, `if (@x &&= 1)` folded always-truthy on a
501
+ # program that takes the else arm.
502
+ Type::Combinator.union(Narrowing.narrow_falsey(current || dynamic_top), rhs)
386
503
  else
387
504
  compound_operator_result(current || dynamic_top, rhs, node.binary_operator)
388
505
  end
389
506
  end
390
507
 
508
+ # `h[k] += v` / `h[k] ||= v` / `h[k] &&= v` as an EXPRESSION. Like a variable compound write, its value is
509
+ # what it stores through `[]=` — the dispatched `h[k] + v`, `truthy(h[k]) | v`, `falsey(h[k]) | v` — which
510
+ # reads the slot's current type, recorded indexed narrowing included. Typed as the rvalue alone it answered
511
+ # `1` for `counts[w] += 1`, so `r = words.map { |w| counts[w] += 1 }` pinned every position to `1` and
512
+ # `r.last == 1` drew a false `flow.always-truthy-condition`.
513
+ #
514
+ # The statement evaluator already owned that algebra for the straight-line write and the `[]=` widening
515
+ # join, so this reads its answer rather than keeping a second copy. It asks for the value alone, not a
516
+ # whole `evaluate`: the widening and the narrowing record are scope effects a value position discards,
517
+ # and the memoizing `@cache[k] ||= build(k)` tail is common enough not to pay for them.
518
+ #
519
+ # One exception carries over, narrowed, from {#type_of_compound_variable_write}: a memoizing `||=` whose
520
+ # slot the analyzer has no evidence about reads as the rvalue. The evaluator's value method owns it,
521
+ # because it is decided on the `[]` read the evaluator performs.
522
+ def type_of_index_compound_write(node)
523
+ StatementEvaluator.new(scope: scope, tracer: tracer).index_compound_write_value(node)
524
+ end
525
+
391
526
  def compound_write_current_binding(node)
392
527
  case node
393
528
  when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
@@ -400,6 +535,64 @@ module Rigor
400
535
  end
401
536
  end
402
537
 
538
+ def compound_write_constant_binding(node)
539
+ case node
540
+ when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
541
+ full_name = node.name.to_s
542
+ rooted = false
543
+ else
544
+ full_name = Source::ConstantPath.qualified_name_or_nil(node.target)
545
+ return unnamed_path_binding(node.target) if full_name.nil?
546
+
547
+ rooted = Source::ConstantPath.rooted?(node.target)
548
+ end
549
+ resolve_constant_name(full_name, rooted: rooted, caller_derived: false) ||
550
+ written_constant_binding(full_name, rooted)
551
+ end
552
+
553
+ # A constant the plain read cannot resolve may still be bound: another file writes a value that never
554
+ # published (`H = { x: 1 }`), the compound write withdrew one by being the second writer the census
555
+ # counts, or this file writes it in a form its own table does not carry (`A, B = …`, `A = B = …`). The
556
+ # value is one the analyzer does not have, so the binding is `Dynamic[top]` rather than the memo's
557
+ # unbound reading. The one write that does not bind is a memo `||=` no other file shares, so the
558
+ # memoization idiom keeps that reading however often its file repeats it; memos of the name in two files
559
+ # bind, since either may load first and set what the other reads.
560
+ #
561
+ # Which name the write reads is the ladder's to decide, not the spelling's: the same resolution runs
562
+ # again with only the census's binding names in the in-source table, so `Other::REGISTRY` elsewhere
563
+ # leaves a top-level `REGISTRY ||= {}` its memo reading. Two census spellings the ladder cannot reach
564
+ # still bind: a write through a base nothing names (`k::X = 1`), which may have written any of them,
565
+ # and a path the census keeps as written ([#690](https://github.com/rigortype/rigor/issues/690)), so
566
+ # `Foo::BAR = …` inside `module M` binds `M::Foo::BAR ||= 0`.
567
+ def written_constant_binding(full_name, rooted)
568
+ names = scope.bound_constant_names(full_name)
569
+ return nil if names.empty?
570
+ return dynamic_top if names.any? { |name| unladdered_census_name?(name, full_name) }
571
+
572
+ written = names.to_h { |name| [name, dynamic_top] }
573
+ probe = scope.with_discovery(scope.discovery.with(in_source_constants: written))
574
+ Reflection.resolve_constant_type(full_name, scope: probe, rooted: rooted, caller_derived: false)
575
+ end
576
+
577
+ def unladdered_census_name?(name, full_name)
578
+ return true if name.start_with?(ScopeIndexer::DYNAMIC_TARGET_PREFIX)
579
+
580
+ name.include?("::") && full_name.end_with?("::#{name}")
581
+ end
582
+
583
+ # `self::X ||= v` / `klass::X ||= v` name no constant the resolver can look up, so any binding write
584
+ # that shares the last segment may be the one the base reaches. The answer now depends on other files'
585
+ # writes, and this path never reaches `Reflection.resolve_constant_type`, which records the
586
+ # `constant:<segment>` edge for every other form — so it records the edge itself, or an incremental
587
+ # run keeps serving the reading from before another file's write of the segment appeared.
588
+ def unnamed_path_binding(target)
589
+ segment = target.name&.to_s
590
+ return nil if segment.nil?
591
+
592
+ Analysis::DependencyRecorder.read_name(:constant, segment) if Analysis::DependencyRecorder.active?
593
+ dynamic_top unless scope.bound_constant_names(segment).empty?
594
+ end
595
+
403
596
  def compound_operator_result(current, rhs, operator)
404
597
  MethodDispatcher.dispatch(
405
598
  receiver_type: current,
@@ -532,7 +725,17 @@ module Rigor
532
725
  # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
533
726
  # `Rigor::Inference::FallbackTracer`.
534
727
  def type_of_constant_read(node)
535
- resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
728
+ guard_narrowed_constant(node) ||
729
+ resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
730
+ end
731
+
732
+ # Issue #1429 — the type a guard narrowed this constant reference to on the edge being typed
733
+ # (`STDOUT.is_a?(StringIO) ? STDOUT.string : nil`), or nil. Keyed by spelling ({Narrowing.constant_key}).
734
+ def guard_narrowed_constant(node)
735
+ return nil if scope.constant_narrowings.empty?
736
+
737
+ key = Narrowing.constant_key(node)
738
+ key && scope.constant_narrowing(key)
536
739
  end
537
740
 
538
741
  # A leading `::` (`::Rails`, `::Rails::Application`) is Ruby's escape hatch out of the lexical ladder:
@@ -540,6 +743,9 @@ module Rigor
540
743
  # deliberately un-rooted (the discovery tables are keyed that way), so the marker rides alongside it
541
744
  # into the resolver (#614).
542
745
  def type_of_constant_path(node)
746
+ narrowed = guard_narrowed_constant(node)
747
+ return narrowed if narrowed
748
+
543
749
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
544
750
  return fallback_for(node, family: :prism) if full_name.nil?
545
751
 
@@ -608,16 +814,17 @@ module Rigor
608
814
  # in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
609
815
  # `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
610
816
  # `Rigor::Type` or nil; the caller decides whether to fall back.
611
- def resolve_constant_name(name, rooted: false)
612
- Reflection.resolve_constant_type(name, scope: scope, rooted: rooted)
817
+ def resolve_constant_name(name, rooted: false, caller_derived: true)
818
+ Reflection.resolve_constant_type(name, scope: scope, rooted: rooted, caller_derived: caller_derived)
613
819
  end
614
820
 
615
821
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
616
822
  # whose key is a value-pinned scalar literal — Symbol, plain String, Integer, Float, `true`, `false`,
617
823
  # or `nil` (covering `{ a: 1, "b" => 2 }` and `{ 1 => 2, 1.0 => 4 }` alike) — falling back to the
618
824
  # generic `Hash[K, V]` form otherwise. Splatted entries (`{ **other }`) and dynamic keys widen to the
619
- # underlying `Hash[K, V]` form by unioning the types each entry exposes; when no concrete pair
620
- # survives we fall back to the raw `Hash` so callers stay backward compatible.
825
+ # underlying `Hash[K, V]` form by unioning the types each entry exposes — a splat exposes the `[K, V]`
826
+ # of the hash it copies plus a `Dynamic[top]` arm (see {#hash_splat_pair}), so every entry contributes a
827
+ # pair. A splat never keeps a shape, even over an exact closed one.
621
828
  def type_of_hash(node)
622
829
  elements = node.respond_to?(:elements) ? node.elements : []
623
830
  # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
@@ -629,8 +836,6 @@ module Rigor
629
836
  return shape if shape
630
837
 
631
838
  keys, values = generic_hash_pairs_for(elements)
632
- return Type::Combinator.nominal_of(Hash) if keys.empty? || values.empty?
633
-
634
839
  Type::Combinator.nominal_of(
635
840
  Hash,
636
841
  type_args: [Type::Combinator.union(*keys), Type::Combinator.union(*values)]
@@ -684,14 +889,64 @@ module Rigor
684
889
  keys = []
685
890
  values = []
686
891
  elements.each do |entry|
687
- next unless entry.is_a?(Prism::AssocNode)
688
-
689
- keys << type_of(entry.key)
690
- values << type_of(entry.value)
892
+ if entry.is_a?(Prism::AssocNode)
893
+ keys << type_of(entry.key)
894
+ values << type_of(entry.value)
895
+ else
896
+ key, value = hash_splat_pair(entry)
897
+ keys << key
898
+ values << value
899
+ end
691
900
  end
692
901
  [keys, values]
693
902
  end
694
903
 
904
+ # The `[K, V]` a `**splat` entry adds to the literal: what the analysis can read of the hash it copies, each
905
+ # side joined with `Dynamic[top]`. Ruby inserts every pair of that hash, so leaving the entry out typed
906
+ # `o = { a: :z }; { **o, b: :y }` as `Hash[:b, :y]` and folded `h[:a] == :z` on correct code.
907
+ #
908
+ # The `Dynamic[top]` arm is there even when the copy is read exactly. The literal builds a new hash that no
909
+ # declaration describes, while `MutationRejoin` regrows a `Hash[K, V]` after `[]=` / `merge!` only when it
910
+ # already carries a gradual arm, because it reads a precise one as a declared claim. Without the arm
911
+ # `h = { **o }; h[:b] = 2; h[:b] == 2` would fold always-falsey and `h[:e] = "s"; h[:e].upcase` would fire
912
+ # `call.undefined-method`, where the `Hash` a splat-only literal used to type as had stayed quiet. The same
913
+ # arm stands in for what the analysis cannot read: an anonymous `**`, an untyped value, a nominal other than
914
+ # `Hash`, an open shape's unlisted entries, and a hash filled through an alias the engine does not track,
915
+ # which still reads `{}`.
916
+ def hash_splat_pair(entry)
917
+ untyped = Type::Combinator.untyped
918
+ copied = entry.value && splatted_hash_pair(type_of(entry.value))
919
+ return [untyped, untyped] if copied.nil?
920
+
921
+ copied.map { |side| Type::Combinator.union(side, untyped) }
922
+ end
923
+
924
+ # The `[K, V]` the analysis can read of a splatted value, or nil when it reads nothing: a shape's keys and
925
+ # values, a `Hash[K, V]`'s type arguments (through a difference over one, `non-empty-hash[K, V]`), and the
926
+ # join over a union's members. `nil` reads as nothing because `**nil` splats nothing (Ruby 3.4+; earlier
927
+ # Rubies raise). A `Hash` subclass is not read: its own type arguments, if any, are not `Hash`'s `K` and `V`.
928
+ def splatted_hash_pair(type)
929
+ case type
930
+ when Type::HashShape then splatted_shape_pair(type)
931
+ when Type::Nominal then type.class_name == "Hash" && type.type_args.size == 2 ? type.type_args : nil
932
+ when Type::Difference then splatted_hash_pair(type.base)
933
+ when Type::Union then joined_hash_pair(type.members.filter_map { |member| splatted_hash_pair(member) })
934
+ end
935
+ end
936
+
937
+ def splatted_shape_pair(shape)
938
+ return nil if shape.pairs.empty?
939
+
940
+ [Type::Combinator.union(*shape.pairs.keys.map { |k| Type::Combinator.constant_of(k) }),
941
+ Type::Combinator.union(*shape.pairs.values)]
942
+ end
943
+
944
+ def joined_hash_pair(pairs)
945
+ return nil if pairs.empty?
946
+
947
+ [Type::Combinator.union(*pairs.map(&:first)), Type::Combinator.union(*pairs.map(&:last))]
948
+ end
949
+
695
950
  # An interpolated string `"#{a}b#{c}"` is `literal-string` when every part contributes literal-bearing
696
951
  # material: plain text segments are literal by construction, embedded expressions count when their type
697
952
  # is itself literal-string-compatible (a `Constant<String>`, the `literal-string` carrier, an
@@ -746,132 +1001,29 @@ module Rigor
746
1001
  Type::Combinator.constant_of(nil)
747
1002
  end
748
1003
 
749
- # `if c; t; (elsif c2; ...; )* else; e; end`. Prism nests `elsif` branches as `IfNode#subsequent`. Slice
750
- # 3 phase 1 types both branches in the receiver scope and returns their union; scope rebinding is the
751
- # StatementEvaluator's job (Slice 3 phase 2). Without an else clause the branch's implicit value is nil,
752
- # which is included in the union.
753
- #
754
- # v0.0.6 — when the predicate folds to a `Type::Constant` whose value is Ruby-truthy (resp.
755
- # Ruby-falsey), the unreachable branch is elided so the if-expression's type is the live branch alone.
756
- # Statement-level branch elision lives in `StatementEvaluator#eval_if`; this handler covers the
757
- # expression-position ternary form (`a ? b : c`) and any `if`/`unless` reached through `type_of`.
758
- def type_of_if(node)
759
- then_type = statements_or_nil(node.statements)
760
- else_type = if_else_type(node.subsequent)
761
- elide_or_union(node.predicate, then_type, else_type)
762
- end
763
-
764
- # `unless c; t; else; e; end`. Prism uses `else_clause` here (no `elsif` chain). Branch-elision logic
765
- # mirrors `type_of_if`, inverted: a truthy predicate selects the else branch.
766
- def type_of_unless(node)
767
- then_type = statements_or_nil(node.statements)
768
- else_type = if_else_type(node.else_clause)
769
- elide_or_union(node.predicate, else_type, then_type)
770
- end
771
-
772
- # Issue #286 — the effective optimistic-nil-free cause of an expression. {OptimisticOrigin.resolve} owns
773
- # the judgment, shared verbatim with `StatementEvaluator#optimistic_origin_for` and the
774
- # `flow.always-truthy-condition` collector.
775
- def optimistic_origin_for(node)
776
- OptimisticOrigin.resolve(node, scope)
777
- end
778
-
779
- def if_else_type(subsequent)
780
- return Type::Combinator.constant_of(nil) if subsequent.nil?
781
-
782
- type_of(subsequent)
783
- end
784
-
785
- # Routes the predicate's typed value through branch elision. `live_when_truthy` and `live_when_falsey`
786
- # are the branch types selected by the predicate's polarity; the names match `IfNode` semantics
787
- # directly and invert at the `type_of_unless` call site.
788
- def elide_or_union(predicate, live_when_truthy, live_when_falsey)
789
- case constant_predicate_polarity(predicate)
790
- when :truthy then live_when_truthy
791
- when :falsey then live_when_falsey
792
- else Type::Combinator.union(live_when_truthy, live_when_falsey)
793
- end
794
- end
795
-
796
- # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary predicate expression under three-valued logic.
797
- # {Narrowing.predicate_certainty} owns the judgment (the same one `StatementEvaluator#live_branch_for_if`
798
- # reads on the scope side): `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`, and
799
- # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
800
- def constant_predicate_polarity(predicate)
801
- return nil if predicate.nil?
802
-
803
- # ADR-47 WD5 — a decidable version guard (#627) answers first, exactly as it does on the scope side
804
- # in `StatementEvaluator#branch_certainty`. Both readers ask the same pure function of the AST, so
805
- # the expression form (`RUBY_VERSION >= "3.1" ? a : b`) and the statement form cannot disagree about
806
- # which arm survives. The verdict rests on literals, so the ADR-101 optimistic-carrier decline below
807
- # — which guards an RBS-derived judgment — does not apply to it.
808
- guard = VersionGuard.verdict(predicate)
809
- return guard if guard
810
- # ADR-101 — decline on an optimistically nil-free carrier; see
811
- # `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
812
- return nil unless optimistic_origin_for(predicate).nil?
813
-
814
- Narrowing.predicate_certainty(type_of(predicate))
1004
+ # `if` / `unless` in value position — the ternary `c ? a : b`, a modifier `(a unless c)`, or a
1005
+ # conditional written as an argument, a receiver, or a literal element. Issue #1003: this handler used
1006
+ # to type both arms in the receiver scope and union them, so the predicate's narrowing reached a
1007
+ # statement-position conditional (through `StatementEvaluator#eval_if`) and never a value-position
1008
+ # one: `f.finite? ? f : 0.0` read `0.0 | Float` while the `if` statement bound `0.0 | finite-float`.
1009
+ # It delegates to the statement evaluator instead of re-deriving the edges, so the two positions share
1010
+ # one narrowing application, one branch elision (ADR-47 WD5 version guards, the ADR-101
1011
+ # optimistic-carrier decline) and one terminating-arm rule, and cannot drift apart again. Only the
1012
+ # value is read; the post-scope belongs to whichever walk owns the statement.
1013
+ def type_of_conditional(node)
1014
+ scope.evaluate(node, tracer: tracer).first
815
1015
  end
816
1016
 
817
1017
  def type_of_else(node)
818
1018
  statements_or_nil(node.statements)
819
1019
  end
820
1020
 
821
- # `a && b` and `a || b` short-circuit at the value level: `a && b` returns `a` when `a` is falsey, else
822
- # `b`. `a || b` returns `a` when `a` is truthy, else `b`.
823
- #
824
- # v0.0.6 — when the left operand folds to a `Type::Constant`, we know which side actually flows
825
- # through, so the result is one operand's type instead of a union. Otherwise the union-of-both-operands
826
- # fallback is preserved.
1021
+ # `a && b` / `a || b` in value position. Issue #1016: this handler used to type both operands in the
1022
+ # receiver scope, so `x.finite? && x` read `Float | false` while the statement form bound
1023
+ # `false | finite-float`. Like `type_of_conditional`, it delegates to the statement evaluator, which owns
1024
+ # the RHS narrowing, the constant short-circuit and its issue #313 optimistic-carrier decline.
827
1025
  def type_of_and_or(node)
828
- left_type = type_of(node.left)
829
- polarity = left_operand_polarity(node.left, left_type)
830
- return short_circuit_for(node, left_type, polarity) if polarity
831
-
832
- # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
833
- # when `a` is truthy, so its falsey constituents (`nil` / `false`) can never be the value of the
834
- # OrNode (they hand off to `b`); `a && b` yields `a` solely when `a` is falsey. Narrow the surviving
835
- # left edge before the union so `s || full` (with `s : String?`) types `String | <full>` rather than
836
- # re-admitting the stripped `nil`. Mirrors `StatementEvaluator#eval_and_or`'s `skipped_type`.
837
- surviving_left =
838
- if node.is_a?(Prism::AndNode)
839
- Narrowing.narrow_falsey(left_type)
840
- else
841
- Narrowing.narrow_truthy(left_type)
842
- end
843
- Type::Combinator.union(surviving_left, type_of(node.right))
844
- end
845
-
846
- def short_circuit_for(node, left_type, polarity)
847
- and_node = node.is_a?(Prism::AndNode)
848
- if polarity == :truthy
849
- and_node ? type_of(node.right) : left_type
850
- else
851
- and_node ? left_type : type_of(node.right)
852
- end
853
- end
854
-
855
- # Issue #313 — the node-aware wrapper the `&&` / `||` short-circuit reads. The spec's exclusion binds
856
- # this gate as much as it binds `flow.always-truthy-condition`, and a `Constant`-only gate is not by
857
- # itself enough to honour it: a literal hash whose values share one type reads as a lone `Constant`
858
- # (`UNIFORM[key]` → `Constant[1]`), so the gate would judge the left operand of `UNIFORM[key] || key`
859
- # provably truthy and discard the author's fallback — the counter-example the spec names verbatim.
860
- # Declining returns the union of both operands, which is what `StatementEvaluator#eval_and_or` produces
861
- # anyway, so the two `&&` / `||` typers stay in agreement.
862
- def left_operand_polarity(left_node, left_type)
863
- return nil unless optimistic_origin_for(left_node).nil?
864
-
865
- constant_value_polarity(left_type)
866
- end
867
-
868
- # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
869
- # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
870
- # node, so the same predicate analysis can be reused in both contexts.
871
- def constant_value_polarity(type)
872
- return nil unless type.is_a?(Type::Constant)
873
-
874
- type.value ? :truthy : :falsey
1026
+ scope.evaluate(node, tracer: tracer).first
875
1027
  end
876
1028
 
877
1029
  # Three-valued evaluation of `case predicate when pattern` dispatch. For each `when` clause we ask:
@@ -889,29 +1041,41 @@ module Rigor
889
1041
  # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
890
1042
  # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
891
1043
  # `case` reduces to a `if c1; ...; elsif c2` chain that statement-level narrowing already handles.
1044
+ # Issue #1429 — each arm is typed under the subject's clause narrowing (`Narrowing.case_when_scopes`), the
1045
+ # scope the statement evaluator runs the arm in: `when Symbol then n` on `n: Integer | Symbol` answers
1046
+ # `Symbol`, and the `else` arm reads the subject every earlier clause has ruled out.
892
1047
  def type_of_case(node)
893
1048
  return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
894
1049
 
895
1050
  subject_type = type_of(node.predicate)
896
1051
  candidates = []
897
1052
  reached_yes = false
1053
+ clause_scope = scope
898
1054
 
899
1055
  node.conditions.each do |when_node|
900
- case case_when_branch_certainty(subject_type, when_node)
901
- when :yes
902
- candidates << type_of(when_node)
1056
+ conditions = when_node.respond_to?(:conditions) ? when_node.conditions : []
1057
+ body_scope, next_scope = Narrowing.case_when_scopes(node.predicate, conditions, clause_scope)
1058
+ certainty = case_when_branch_certainty(subject_type, when_node)
1059
+ # :no — drop the branch
1060
+ candidates << case_arm_type(when_node, body_scope) unless certainty == :no
1061
+ if certainty == :yes
903
1062
  reached_yes = true
904
1063
  break
905
- when :maybe
906
- candidates << type_of(when_node)
907
- # :no — drop the branch
908
1064
  end
1065
+ clause_scope = next_scope
909
1066
  end
910
1067
 
911
- candidates << type_of_case_else(node) unless reached_yes
1068
+ candidates << case_arm_type(node.else_clause, clause_scope) unless reached_yes
912
1069
  Type::Combinator.union(*candidates)
913
1070
  end
914
1071
 
1072
+ # The value of one `case` arm (`nil` for an absent `else`), typed under `arm_scope`.
1073
+ def case_arm_type(arm, arm_scope)
1074
+ return Type::Combinator.constant_of(nil) if arm.nil?
1075
+
1076
+ arm_scope.equal?(scope) ? type_of(arm) : arm_scope.type_of(arm, tracer: tracer)
1077
+ end
1078
+
915
1079
  def type_of_case_simple_union(node)
916
1080
  branch_types = node.conditions.map { |branch| type_of(branch) }
917
1081
  Type::Combinator.union(*branch_types, type_of_case_else(node))
@@ -1007,24 +1171,46 @@ module Rigor
1007
1171
  Type::Combinator.union(primary_type, *rescue_types)
1008
1172
  end
1009
1173
 
1174
+ # Issue #1360 — the evaluator binds `$!` in a rescue clause it enters; a clause typed here, such as one in a
1175
+ # `begin` passed as an argument, reads `$!`, `$@` and `$?` unbound rather than an enclosing clause's.
1010
1176
  def rescue_chain_types(rescue_node)
1177
+ arm_typer = typer_under(rescue_arm_scope)
1011
1178
  types = []
1012
1179
  current = rescue_node
1013
1180
  while current
1014
- types << statements_or_nil(current.statements)
1181
+ types << arm_typer.send(:statements_or_nil, current.statements)
1015
1182
  current = current.subsequent
1016
1183
  end
1017
1184
  types
1018
1185
  end
1019
1186
 
1020
1187
  def type_of_rescue(node)
1021
- statements_or_nil(node.statements)
1188
+ typer_under(rescue_arm_scope).send(:statements_or_nil, node.statements)
1022
1189
  end
1023
1190
 
1191
+ def rescue_arm_scope = scope.forget_error_info.forget_last_status
1192
+
1024
1193
  # `expr rescue fallback` is RescueModifierNode in Prism. The result is `expr`'s type when no exception
1025
- # is raised and `fallback`'s type otherwise; both paths are reachable, so the result is their union.
1194
+ # is raised and `fallback`'s type otherwise; both paths are reachable, so the result is their union. Issue #1360
1195
+ # — `fallback` runs with `$!` and `$@` bound to the `StandardError` it rescued ({ErrorInfo.modifier_entry}).
1026
1196
  def type_of_rescue_modifier(node)
1027
- Type::Combinator.union(type_of(node.expression), type_of(node.rescue_expression))
1197
+ fallback = node.rescue_expression
1198
+ fallback_type =
1199
+ if ErrorInfo.read_in?(fallback)
1200
+ typer_under(ErrorInfo.modifier_entry(scope, fallback)).type_of(fallback)
1201
+ else
1202
+ type_of(fallback)
1203
+ end
1204
+ Type::Combinator.union(type_of(node.expression), fallback_type)
1205
+ end
1206
+
1207
+ # A typer that shares this one's tracer and operand types but reads `other_scope`.
1208
+ def typer_under(other_scope)
1209
+ return self if other_scope.equal?(scope)
1210
+
1211
+ ExpressionTyper.new(
1212
+ scope: other_scope, tracer: tracer, operand_types: @operand_types, typing_node: @typing_node
1213
+ )
1028
1214
  end
1029
1215
 
1030
1216
  def type_of_ensure(node)
@@ -1295,10 +1481,16 @@ module Rigor
1295
1481
  # and fired `undefined method 'upcase' for nil` on correct code. The candidate is still looked up
1296
1482
  # first — that lookup is a hash probe and owns the ADR-46 cross-file dependency edge — and
1297
1483
  # {#self_type_answers?} then vetoes the bind for a name the enclosing class answers itself.
1484
+ #
1485
+ # Issue #963 item 2 — {#plugin_member_answers?} is the second veto, for the members no source the
1486
+ # engine walks can enumerate: a plugin's `dynamic_return` answer and the ADR-16 synthetic-method
1487
+ # index. They are members of `self` exactly as an `attr_reader` is, so a top-level `def` must not
1488
+ # bind ahead of them either.
1298
1489
  def try_local_def_dispatch(node, receiver, arg_types, block_type = nil)
1299
1490
  local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1300
1491
  return nil unless local_def
1301
1492
  return nil if self_type_answers?(node.name)
1493
+ return nil if plugin_member_answers?(node, receiver)
1302
1494
 
1303
1495
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types, block_type)
1304
1496
  return local_inference if local_inference
@@ -1334,21 +1526,48 @@ module Rigor
1334
1526
  # describes the source under analysis, while a bundled signature describes a class the project does
1335
1527
  # not own, where a project `def` is a monkey-patch and [ADR-17] owns the question.
1336
1528
  # `project_declared_class?` fail-softs to false, so an unattributable environment changes nothing.
1337
- def try_overriding_def_dispatch(node, receiver, arg_types)
1338
- return nil unless user_inference_receiver?(receiver)
1529
+ def try_overriding_def_dispatch(node, receiver, arg_types, method_name: node.name)
1530
+ return nil unless overriding_own_def?(receiver, method_name)
1531
+
1532
+ try_user_method_inference(receiver, node, arg_types, method_name: method_name) || dynamic_top
1533
+ end
1534
+
1535
+ # The three conditions above: an own source `def` of `method_name` on the receiver's class, which has no
1536
+ # declaration of its own but inherits one a project-declared ancestor wrote.
1537
+ def overriding_own_def?(receiver, method_name)
1538
+ return false unless user_inference_receiver?(receiver)
1339
1539
 
1340
1540
  class_name = receiver.class_name
1341
- return nil if class_name.nil?
1541
+ return false if class_name.nil?
1342
1542
  # `Scope#user_def_for`, not `discovered_method?`: the cross-file table deliberately withholds a
1343
1543
  # plain instance `def` under the ADR-17 monkey-patch contract, and this gate must see one.
1344
- return nil if scope.user_def_for(class_name, node.name).nil?
1544
+ return false if scope.user_def_for(class_name, method_name).nil?
1345
1545
 
1346
- definition = safe_rbs_method_definition(class_name, node.name, :instance)
1347
- return nil if definition.nil?
1348
- return nil if rbs_declared_on_class?(definition, class_name)
1349
- return nil unless project_declared_owner?(definition)
1546
+ definition = safe_rbs_method_definition(class_name, method_name, :instance)
1547
+ return false if definition.nil?
1548
+ return false if rbs_declared_on_class?(definition, class_name)
1549
+
1550
+ project_declared_owner?(definition)
1551
+ end
1552
+
1553
+ # {#implicit_index_read_type}'s dispatcher tier, with the write node as the call context.
1554
+ def index_read_dispatch(node, receiver, arg_types)
1555
+ MethodDispatcher.dispatch(
1556
+ receiver_type: receiver, method_name: :[], arg_types: arg_types,
1557
+ environment: scope.environment, call_node: node, scope: scope
1558
+ )
1559
+ end
1350
1560
 
1351
- try_user_method_inference(receiver, node, arg_types) || dynamic_top
1561
+ # A splat index (`c[*keys] += v`) leaves the read's arity to the splat's expansion, and the one untyped
1562
+ # argument standing in for it would bind positionally to a body written for several: `def [](*keys) =
1563
+ # keys.size` folded the read of `r[*[0, 1]] += 1` to `1` and drew an always-falsey `n == 3` on a program
1564
+ # that takes that branch. So the body tiers decline under a splat, as `try_literal_send` does, and only
1565
+ # the signature-driven dispatch answers. An own `def` overriding an inherited declaration still outranks
1566
+ # that declaration; with its body unreadable, it answers `Dynamic[top]`.
1567
+ def splat_index_read_type(node, receiver, arg_types)
1568
+ return dynamic_top if overriding_own_def?(receiver, :[])
1569
+
1570
+ index_read_dispatch(node, receiver, arg_types)
1352
1571
  end
1353
1572
 
1354
1573
  # Whether the class an inherited declaration was written about is one the project declares itself.
@@ -1379,6 +1598,24 @@ module Rigor
1379
1598
  end
1380
1599
  end
1381
1600
 
1601
+ # Issue #963 item 2 — the plugin arm of the veto: whether a plugin answers `node.name` on the call's
1602
+ # own `self`. Asked of `MethodDispatcher.plugin_member_answers?`, which puts the question to the same
1603
+ # two tiers dispatch would consult (the gated `dynamic_return` walk and the ADR-16 synthetic-method
1604
+ # index), so the veto carries no plugin knowledge of its own.
1605
+ #
1606
+ # The confidence gate is #618's, unchanged and re-stated here rather than inherited: only a `self`
1607
+ # whose class is KNOWN participates. At genuine top level, and inside a block whose `self` is
1608
+ # unmodelled, `scope.self_type` is nil, `receiver` is the synthetic `Object` / `Dynamic[Top]` stand-in
1609
+ # that {#call_receiver_type_for} substitutes, and asking a plugin about THAT receiver would be asking
1610
+ # about a `self` the engine has not modelled — #316's / #319's territory, which this stays out of.
1611
+ def plugin_member_answers?(node, receiver)
1612
+ return false if scope.self_type.nil?
1613
+
1614
+ MethodDispatcher.plugin_member_answers?(
1615
+ call_node: node, scope: scope, receiver_type: receiver, method_name: node.name
1616
+ )
1617
+ end
1618
+
1382
1619
  # The instance side of {#self_type_answers?}: the discovered methods (`def`, `attr_*`,
1383
1620
  # `define_method`, `alias`) of the class or any project ancestor, its `Struct.new` / `Data.define`
1384
1621
  # member accessors, a `def` reached through its project ancestors (superclass chain and included
@@ -1407,25 +1644,13 @@ module Rigor
1407
1644
  # environment, so the declaration that decides the question is written about an ancestor the project
1408
1645
  # does not declare (`StandardError`, `Array`, `Comparable`) — each is asked on its own terms, and
1409
1646
  # each of them is itself before `::Object` in the reader's MRO by construction.
1647
+ #
1648
+ # Issue #527 slice 0 — the walk itself lives in {ExternalAncestorResolution}, which reports WHICH
1649
+ # declaration answered because the dispatch side needs to dispatch there. This tier only needs to
1650
+ # know that one did, so it reads the presence of an answer. Dependency recording stays ON: this
1651
+ # arm genuinely read the ancestor's declaration to decide the binding.
1410
1652
  def rbs_ancestor_answers?(class_name, method_name)
1411
- definition = safe_rbs_method_definition(class_name, method_name, :instance)
1412
- return true if rbs_declared_before_object?(definition, class_name)
1413
-
1414
- scope.external_ancestor_name_candidates(class_name, name_memo: class_graph_buckets[:name])
1415
- .any? { |candidates| external_ancestor_answers?(candidates, method_name) }
1416
- end
1417
-
1418
- # The first candidate spelling the RBS environment knows is the ancestor Ruby resolves; a name it
1419
- # knows nothing about contributes no evidence either way.
1420
- def external_ancestor_answers?(candidates, method_name)
1421
- candidates.each do |candidate|
1422
- next if instance_ancestor_names(candidate).empty?
1423
-
1424
- return rbs_declared_before_object?(
1425
- safe_rbs_method_definition(candidate, method_name, :instance), candidate
1426
- )
1427
- end
1428
- false
1653
+ !ExternalAncestorResolution.resolve(class_name, method_name, :instance, scope: scope).nil?
1429
1654
  end
1430
1655
 
1431
1656
  # The singleton side: a class-body `self` is `Singleton[Foo]`, where an implicit-self call reaches
@@ -1452,56 +1677,16 @@ module Rigor
1452
1677
  !members.nil? && members.include?(method_name.to_sym)
1453
1678
  end
1454
1679
 
1680
+ # Issue #527 slice 0 — the RBS lookup, its `rescue`, and the `::Object` MRO cut-off moved to
1681
+ # {ExternalAncestorResolution}, which the dispatch side reads too. These stay as this class's
1682
+ # spelling of them, because three other tiers here (`try_overriding_def_dispatch`,
1683
+ # {#singleton_self_answers?}) ask the own-class question without the ancestor walk.
1455
1684
  def safe_rbs_method_definition(class_name, method_name, kind)
1456
- if kind == :singleton
1457
- Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
1458
- else
1459
- Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
1460
- end
1461
- rescue StandardError
1462
- nil
1685
+ ExternalAncestorResolution.method_definition(class_name, method_name, kind, scope: scope)
1463
1686
  end
1464
1687
 
1465
- # True when the RBS declaration found for the name sits on `class_name` itself rather than on an
1466
- # ancestor; mirrors `CheckRules#defined_on?` and `SigGen::Generator#declared_on_class_itself?`.
1467
1688
  def rbs_declared_on_class?(definition, class_name)
1468
- return false if definition.nil?
1469
- return false unless definition.respond_to?(:defined_in)
1470
-
1471
- defined_in = definition.defined_in
1472
- return false if defined_in.nil?
1473
-
1474
- defined_in.to_s.delete_prefix("::") == class_name.to_s.delete_prefix("::")
1475
- end
1476
-
1477
- # Issue #633 — true when the declaration's owner sits strictly before `::Object` in `class_name`'s
1478
- # instance MRO, i.e. Ruby dispatches to it ahead of a top-level `def` (which is `Object`'s own
1479
- # private instance method). The own class trivially qualifies. An owner absent from the ancestor
1480
- # list, an unbuildable class, and an ancestry that does not reach `Object` (a `BasicObject`
1481
- # descendant) all answer false, leaving the historical top-level binding untouched.
1482
- def rbs_declared_before_object?(definition, class_name)
1483
- return true if rbs_declared_on_class?(definition, class_name)
1484
- return false if definition.nil? || !definition.respond_to?(:defined_in)
1485
-
1486
- owner = definition.defined_in
1487
- return false if owner.nil?
1488
-
1489
- ancestors = instance_ancestor_names(class_name)
1490
- object_index = ancestors.index("Object")
1491
- owner_index = ancestors.index(owner.to_s.delete_prefix("::"))
1492
- !object_index.nil? && !owner_index.nil? && owner_index < object_index
1493
- end
1494
-
1495
- # The class's instance-side ancestors in MRO order, `::`-stripped, or `[]` for a class the RBS
1496
- # environment does not know or cannot build. Read through the loader's accessor rather than
1497
- # `instance_definition(...).ancestors` because that is the one wired to the ancestor-name cache and
1498
- # marked as RIGOR'S OWN demand — ordering two ancestors is not the analysis asking whether either
1499
- # one's methods resolve, and the `rbs.coverage` bookkeeping must not record it as such.
1500
- def instance_ancestor_names(class_name)
1501
- loader = scope.environment&.rbs_loader
1502
- loader ? loader.ancestor_names_for(class_name.to_s) : []
1503
- rescue StandardError
1504
- []
1689
+ ExternalAncestorResolution.declared_on_class?(definition, class_name)
1505
1690
  end
1506
1691
 
1507
1692
  # Issue #520 — Ruby defines the value of an attribute / index assignment (`x.attr = v`, `h[k] = v`)
@@ -1615,12 +1800,87 @@ module Rigor
1615
1800
  # `ops.all? { |o| break false unless o; true }` still folds the no-break path to `Constant[true]`, and
1616
1801
  # the union with the `false` arm makes the call `bool` — no `flow.always-truthy-condition` on a program
1617
1802
  # that really can answer false.
1803
+ #
1804
+ # Issue #1095: the callee's result is kept because an arbitrary callee may return without ever running
1805
+ # the block. A catalogued exactly-once yielder ({BlockCallTiming}) cannot, so when its block's normal
1806
+ # completion is unreachable the callee's result is too, and the call is its `break` arms alone — `bot`
1807
+ # when every path raises or returns instead.
1808
+ #
1809
+ # Issue #1107: `Kernel#loop` declares `-> bot` but returns normally, with `StopIteration#result`, when its
1810
+ # block raises `StopIteration`. Unless the body provably cannot ({BlockCallTiming.loop_may_complete?}),
1811
+ # that normal return is `untyped` — the result's declared type — so `loop { e.next }` is not `bot` and
1812
+ # `loop { x = e.next; break x if x }` keeps its arm beside it.
1618
1813
  def call_dispatch_type_for(node, receiver_override: nil)
1619
- result = call_result_type_for(node, receiver_override: receiver_override)
1814
+ result = loop_completion_type(node, call_result_type_for(node, receiver_override: receiver_override))
1620
1815
  arms = call_break_arm_types(node, receiver_override: receiver_override)
1621
- return result if arms.empty?
1816
+ if exactly_once_block_never_completes?(node, receiver_override)
1817
+ return arms.empty? ? Type::Combinator.bot : Type::Combinator.union(*arms)
1818
+ end
1819
+
1820
+ combined = arms.empty? ? result : Type::Combinator.union(result, *arms)
1821
+ widen_optimistic_predicate_constant(node, combined)
1822
+ end
1823
+
1824
+ # Issue #1172 — the nil-collapsing predicates (`nil?`, `!`, `x == nil`, …) answer a `Constant` that
1825
+ # encodes the receiver's nil-freeness. When that nil-freeness is only *optimistic* — an
1826
+ # `%a{implicitly-returns-nil}` read `RbsDispatch` deliberately reads past — the constant is a bet,
1827
+ # not a fact, and unlike the in-scope `OptimisticOrigin` mark a folded `Constant[false]` survives
1828
+ # into the enclosing method's return summary. A caller's `helper(...) ? a : b` then sees a
1829
+ # proof-shaped constant and reports `flow.always-truthy-condition` on a live guard (the mark cannot
1830
+ # follow a value across the method boundary, so {OptimisticOrigin.resolve} at the call site finds
1831
+ # nothing). Widen the predicate's constant answer to `bool` when the call derives a mark: the three
1832
+ # certainty consumers already decline on the mark inside this scope, and the widened return keeps
1833
+ # the same judgment from leaking out as a folded return type. The carrier's own type is unchanged —
1834
+ # `h[k]` still reads nil-free, per the spec's MUST NOT.
1835
+ def widen_optimistic_predicate_constant(node, type)
1836
+ return type unless type.is_a?(Type::Constant) && [true, false].include?(type.value)
1837
+ # The carrier read itself keeps its declared answer even when that is a literal `true` /
1838
+ # `false` — the mark says its nil-freeness is a bet, not that the value's class widened.
1839
+ return type if scope.optimistic_origins[node]
1840
+ return type if OptimisticOrigin.resolve(node, scope).nil?
1841
+
1842
+ # Widen only toward what a miss really answers: a safe-navigation call answers `nil`, and
1843
+ # `!recv&.empty?` answers `true` either way, so widening either would invent the other boolean.
1844
+ miss = OptimisticOrigin.miss_answer(node, scope)
1845
+ return type unless miss.equal?(OptimisticOrigin::UNKNOWN_MISS) || miss == !type.value
1846
+
1847
+ Type::Combinator.union(Type::Combinator.constant_of(true), Type::Combinator.constant_of(false))
1848
+ end
1849
+
1850
+ def loop_completion_type(node, result)
1851
+ return result unless result.is_a?(Type::Bot) && BlockCallTiming.loop_may_complete?(node)
1852
+
1853
+ scope.record_dynamic_origin(node, DynamicOrigin::EXPLICIT_UNTYPED)
1854
+ Type::Combinator.untyped
1855
+ end
1856
+
1857
+ # Whether `node` calls a catalogued exactly-once yielder ({BlockCallTiming}) with a literal block that can
1858
+ # never complete normally. Two proofs must BOTH hold. The syntactic one
1859
+ # ({BlockCallTiming.never_completes_normally?}) says every path ends in a jump or a non-returning Kernel
1860
+ # call. The block-return pass must also answer exactly `bot`: a reachable `next` joins its value instead
1861
+ # (#841), so `tap { next "s" }` completes and keeps the receiver, and a nil-bearing or `Dynamic` value or a
1862
+ # failed pass (`nil`) keeps the #853 union. The pass alone is not enough, because it also answers `bot`
1863
+ # for a body whose last call merely DECLARES `-> bot` — a project method's signature, or `loop` before
1864
+ # #1107 widened it ({#loop_completion_type}); `loop { e.next }` returns normally once `e` is drained, and
1865
+ # trusting it made correct code report an always-falsey condition.
1866
+ #
1867
+ # The pre-gates run cheapest-first because the block is re-typed here: the name, a `Prism::BlockNode` (a
1868
+ # `&blk` / `&:sym` block-pass carries no body to prove anything about), no arguments (none of the three
1869
+ # takes one), the syntactic walk, then the resolved-owner check.
1870
+ def exactly_once_block_never_completes?(node, receiver_override)
1871
+ return false unless BlockCallTiming.candidate_name?(node.name)
1622
1872
 
1623
- Type::Combinator.union(result, *arms)
1873
+ block_node = node.block
1874
+ return false unless block_node.is_a?(Prism::BlockNode) && block_node.body
1875
+ return false if node.arguments
1876
+ return false unless BlockCallTiming.never_completes_normally?(block_node.body, scope)
1877
+
1878
+ receiver = receiver_override || call_receiver_type_for(node)
1879
+ return false unless BlockCallTiming.exactly_once_call?(
1880
+ receiver_type: receiver, method_name: node.name, scope: scope
1881
+ )
1882
+
1883
+ block_return_type_for(node, receiver, []).is_a?(Type::Bot)
1624
1884
  end
1625
1885
 
1626
1886
  def call_result_type_for(node, receiver_override: nil)
@@ -1956,6 +2216,9 @@ module Rigor
1956
2216
  # The three receiver-shaped block folds, in their historical order (extracted whole from
1957
2217
  # `call_dispatch_type_for` for method-length budget).
1958
2218
  def try_receiver_block_folds(node, receiver, arg_types)
2219
+ rebound = rebound_operand_typer(node)
2220
+ return rebound.send(:try_receiver_block_folds, node, receiver, arg_types) if rebound
2221
+
1959
2222
  per_element = try_per_element_block_fold(node, receiver)
1960
2223
  return per_element if per_element
1961
2224
 
@@ -1965,6 +2228,25 @@ module Rigor
1965
2228
  try_hash_shape_block_fold(node, receiver)
1966
2229
  end
1967
2230
 
2231
+ # Issue #1365 — Ruby runs a call's receiver chain and arguments before the method yields, so when they may
2232
+ # rebind the match globals ({MatchRebinding.operands_may_rebind?}: `[u.index(/(q)/)].map { $1 }`) every
2233
+ # pass that types the call's block — the block-return pass, its captured-local fixpoint and the receiver
2234
+ # folds — types it under a typer whose scope has forgotten them, as {MatchRebinding.block_entry} does for
2235
+ # the call's block and `StatementEvaluator#forget_operand_specials` for the statement. `$_` is forgotten on
2236
+ # the same terms when they may set it ({LastLine.operands_may_set?}, issue #1359). nil otherwise.
2237
+ def rebound_operand_typer(call_node)
2238
+ return nil if call_node.block.nil?
2239
+
2240
+ rebound = scope
2241
+ if rebound.match_globals_bound? && MatchRebinding.operands_may_rebind?(call_node, scope)
2242
+ rebound = rebound.forget_match_globals
2243
+ end
2244
+ rebound = rebound.forget_last_line if rebound.last_line_bound? && LastLine.operands_may_set?(call_node, scope)
2245
+ return nil if rebound.equal?(scope)
2246
+
2247
+ ExpressionTyper.new(scope: rebound, tracer: tracer, operand_types: @operand_types, typing_node: @typing_node)
2248
+ end
2249
+
1968
2250
  # Issue #533 — `x.send(:selector, args)` with a LITERAL symbol is statically `x.selector(args)`:
1969
2251
  # the private-boundary idiom (protobuf's `send(:get_file_descriptor)` at 84 sites) resolves through
1970
2252
  # the same dispatch + project-inference tiers as the direct call would. `send` legitimately crosses
@@ -1998,8 +2280,8 @@ module Rigor
1998
2280
  def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name, block_type: nil)
1999
2281
  return nil unless user_inference_receiver?(receiver)
2000
2282
 
2001
- def_node, owner = resolve_user_def_with_owner(receiver.class_name, method_name)
2002
- return nil if def_node.nil?
2283
+ def_node, owner, raising = resolve_typing_def_with_owner(receiver.class_name, method_name)
2284
+ return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2003
2285
 
2004
2286
  result = infer_user_method_return(def_node, receiver, arg_types,
2005
2287
  self_fold_safe: fold_safe_call_receiver?(call_node, receiver),
@@ -2101,8 +2383,7 @@ module Rigor
2101
2383
  key = method_name.to_sym
2102
2384
  return table[key] if table.key?(key)
2103
2385
 
2104
- table[key] = scope.singleton_def_through_ancestors(class_name, method_name,
2105
- name_memo: class_graph_buckets[:name])
2386
+ table[key] = scope.singleton_def_through_ancestors(class_name, method_name)
2106
2387
  end
2107
2388
 
2108
2389
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
@@ -2133,37 +2414,160 @@ module Rigor
2133
2414
  nil
2134
2415
  end
2135
2416
 
2136
- # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
2137
- # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
2138
- # ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
2139
- # `try_user_method_inference` and already covers them; an ancestor name that resolves to no
2140
- # project-discovered class/module ends that branch. Cross-file: the chain is followed through
2141
- # `Scope#discovered_superclasses` / `#discovered_includes` / `#discovered_def_nodes`, which the runner
2142
- # seeds from the project-wide pre-pass. The walk is breadth-first, cycle-guarded, and node-count-capped.
2417
+ # ADR-24 slice 2 — resolves `method_name` against the project definers on `class_name`'s
2418
+ # {Scope::ResolutionChain}, in Ruby's order: prepended modules, the class, its included modules, then the
2419
+ # superclass's chain likewise. RBS-known ancestors are NOT answered here — the `MethodDispatcher` RBS
2420
+ # tier runs before `try_user_method_inference` and already covers them; they sit on the chain as
2421
+ # external entries this lookup passes over. Cross-file: the chain is built from the tables the runner
2422
+ # seeds from the project-wide pre-pass, cycle-guarded and capped at `Scope::ANCESTOR_WALK_LIMIT`.
2143
2423
 
2144
2424
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
2145
2425
  private_constant :CLASS_GRAPH_CACHE_KEY
2146
2426
 
2147
- # Run-scoped memo for the static class-graph resolvers below. They are pure functions of the *frozen*
2148
- # project index trio (`discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`) —
2149
- # `user_def_for` / `superclass_of` / `includes_of` read nothing else, and never touch the current
2150
- # scope's locals or narrowings — so a result computed for one `(class, method)` is valid for every
2151
- # `Scope` that shares those tables. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the memo lives
2152
- # on `Thread.current` rather than on `self`. It is keyed by the *identity* of the three frozen tables
2153
- # (nested `compare_by_identity` stores): a new analysis generation, or any `Scope` that swaps an index
2154
- # via `with_discovered_*`, transparently lands in a fresh bucket while everything sharing the tables
2155
- # shares the memo. Steady-state cost is three identity-keyed hash reads and zero allocation — the `||=`
2156
- # chains only allocate on the first miss of a generation. (Pool mode forks per worker, so the
2157
- # `Thread.current` store is process-local and never crosses a project boundary.)
2427
+ # Memo for the static class-graph resolvers below. They are pure functions of the scope's *frozen*
2428
+ # discovery tables — `user_def_for` / `superclass_of` / `includes_of` and the ancestor-name resolver
2429
+ # read nothing else, and never touch the current scope's locals or narrowings — so a result computed
2430
+ # for one `(class, method)` is valid for every `Scope` carrying the same {DiscoveryIndex}.
2431
+ #
2432
+ # The key is that index's identity, NOT the three tables the walk is usually described by. Five
2433
+ # tables are in play: `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`,
2434
+ # plus `discovered_header_nestings` and `discovered_methods`, which {Scope#ancestor_name_candidates}
2435
+ # and {Scope#known_user_class?} read when resolving an ancestor name to a project class. Keying on
2436
+ # the trio alone would let a `discovery.with(discovered_methods: …)` that happens to share the trio
2437
+ # serve a name resolution computed against the OLD table. The index that owns all five is one
2438
+ # object, so keying on it is both cheaper to check and correct by construction — and stays correct
2439
+ # when a sixth table joins the walk.
2440
+ #
2441
+ # ONE slot, replaced rather than accumulated — the same shape, and now the same key, as
2442
+ # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. `ExpressionTyper` is rebuilt per
2443
+ # `Scope#type_of`, so the slot lives on `Thread.current` rather than on `self`. A `Scope` merges its
2444
+ # file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
2445
+ # identity-keyed *store* grew one bucket per file and, because the keys ARE the tables, pinned every
2446
+ # file's whole discovery index for the length of the run. What that bought, measured over
2447
+ # `lib`+`plugins`: 552 buckets holding 1,834 entries between them, and 58 of 80,106 calls answered
2448
+ # across files — 149 of the 701 slot switches returned to a bucket seen before, all of them in the
2449
+ # seed phases that revisit the project index. One slot gives those 58 answers up and allocates 706
2450
+ # buckets instead of 552, and still allocates FEWER objects over `lib` than the store did (−368),
2451
+ # because one `equal?` is cheaper than three identity-hash lookups.
2452
+ #
2453
+ # Steady-state cost is one `equal?` check and zero allocation; the array and the buckets are
2454
+ # allocated once per index. (Pool mode forks per worker, so the `Thread.current` slot is
2455
+ # process-local and never crosses a project boundary — and note that the parent's reported
2456
+ # `Memory peak` therefore does not see a worker's share of this at all.)
2158
2457
  def class_graph_buckets
2159
- store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
2160
- by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
2161
- by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
2162
- # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
2163
- # is a pure function of the same frozen index trio — the sibling resolver it walks reads nothing
2164
- # else.
2165
- by_super[scope.discovered_includes] ||=
2166
- { name: {}, user_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }
2458
+ discovery = scope.discovery
2459
+ slot = Thread.current[CLASS_GRAPH_CACHE_KEY]
2460
+ unless slot && slot[0].equal?(discovery)
2461
+ # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
2462
+ # is a pure function of the same frozen index — the sibling resolver it walks reads nothing else.
2463
+ # `singleton_def` is added lazily by {#singleton_def_through_ancestors}'s caller.
2464
+ slot = [discovery,
2465
+ { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }]
2466
+ Thread.current[CLASS_GRAPH_CACHE_KEY] = slot
2467
+ end
2468
+ slot[1]
2469
+ end
2470
+
2471
+ # ADR-119 C2-b1 — the TYPING read of {#resolve_user_def_with_owner}: the same `[def_node, owner]` pair, but
2472
+ # answered through {DefinerResolution} so a chain that does not stand for the name (a conditional definer,
2473
+ # a conditional include, a fork, an external ancestor that may answer first) is `[nil, nil]` and the call
2474
+ # types `Dynamic` instead of from a definer Ruby may not reach. ABSENT is the same `[nil, nil]`: at a typing
2475
+ # site "no definer" and "cannot say" both fall through to `dispatch_miss_result`. Existence reads
2476
+ # ({#resolve_user_def_through_ancestors}, the self-purity scan) stay on the union memo above: an existence
2477
+ # read turning false there could ADD a `call.undefined-method` firing.
2478
+ #
2479
+ # UNKNOWN carries a third element, the `bot` exception's definers ({#every_project_definer}): the def nodes
2480
+ # of every project entry on the chain that defines the name, or nil where the exception cannot apply.
2481
+ def resolve_typing_def_with_owner(class_name, method_name)
2482
+ cache = class_graph_buckets[:typing_def]
2483
+ table = (cache[class_name.to_s] ||= {})
2484
+ key = method_name.to_sym
2485
+ return table[key] if table.key?(key)
2486
+
2487
+ table[key] =
2488
+ case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
2489
+ in DefinerResolution::Known(answer: [node, owner])
2490
+ [node, owner]
2491
+ in DefinerResolution::UNKNOWN
2492
+ [nil, nil, every_project_definer(class_name, method_name)]
2493
+ in DefinerResolution::ABSENT
2494
+ [nil, nil]
2495
+ end
2496
+ end
2497
+
2498
+ # ADR-119 C2-b1 errata (the `bot` exception) — an UNKNOWN chain does not say WHICH definer Ruby calls, but
2499
+ # when every project definer of the name on the chain types `bot` (each raises or exits), the call cannot
2500
+ # return whichever of them runs, so it types `bot` and a guard such as `fail_with 'no' unless count` still
2501
+ # narrows. `defs` is {#every_project_definer}'s list; each is inferred with this call's receiver and
2502
+ # arguments, stopping at the first that returns (the answer is then `Dynamic` whatever the rest say).
2503
+ # Accepted cost: an RBS-unknown external ahead of them that really defines the name and returns is a
2504
+ # narrowing false negative; `bot` reports nothing about a value, and that exposure is master's.
2505
+ def every_definer_raises_type(defs, receiver, call_node, arg_types, block_type)
2506
+ return nil if defs.nil?
2507
+
2508
+ self_fold_safe = fold_safe_call_receiver?(call_node, receiver)
2509
+ defs.each do |def_node|
2510
+ result = infer_user_method_return(def_node, receiver, arg_types,
2511
+ self_fold_safe: self_fold_safe, yield_type: block_type)
2512
+ return nil unless result.is_a?(Type::Bot)
2513
+ end
2514
+ Type::Combinator.bot
2515
+ end
2516
+
2517
+ # The def node of every project entry on `class_name`'s instance chain that defines `method_name`, in any
2518
+ # position (the order is what UNKNOWN does not know), or nil where the `bot` exception cannot apply: a cut
2519
+ # chain, no project definer, an entry whose only record of the name has no body (an `attr_*` or a
2520
+ # `define_method` with no `def`), a definer resting on a `possible` or contested fact (another body may be the
2521
+ # one Ruby holds), or an external RBS knows that declares the name. An external RBS does not know is passed
2522
+ # over. Only what the tables hold is read: a `define_method` beside an entry's own `def` of the name, and a
2523
+ # receiver-form `X.include(M)` outside `X`'s body, are not seen (as on the Known path).
2524
+ #
2525
+ # ADR-46: the answer depends on every entry, so each files its class edge and its negative class edge (a new
2526
+ # file reopening it may add a definer or a mixin), and each project entry's {Scope#user_def_for} files the
2527
+ # method edge, positive for a definer and `read_missing(:method, "Owner#name")` for one that lacks the
2528
+ # `def`. `DefinerResolution`'s decline filed none of this past the external that stopped it.
2529
+ def every_project_definer(class_name, method_name)
2530
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :instance, :methods)
2531
+ chain.record(scope)
2532
+ return nil if chain.truncated?
2533
+
2534
+ defs = []
2535
+ declined = false
2536
+ chain.entries.uniq.each do |entry|
2537
+ if entry.external?
2538
+ note_missing_class(entry.raw.to_s.split("::").last)
2539
+ declined = true if external_declares?(entry.candidates, method_name)
2540
+ next
2541
+ end
2542
+ note_missing_class(entry.last_segment)
2543
+ node = scope.user_def_for(entry.name, method_name)
2544
+ defs << node if node
2545
+ declined = true if definer_without_single_body?(entry.name, method_name, node)
2546
+ end
2547
+ declined || defs.empty? ? nil : defs.freeze
2548
+ end
2549
+
2550
+ def note_missing_class(segment)
2551
+ Analysis::DependencyRecorder.read_missing(:class, segment) if Analysis::DependencyRecorder.active?
2552
+ end
2553
+
2554
+ def definer_without_single_body?(owner, method_name, node)
2555
+ discovery = scope.discovery
2556
+ key = [owner, method_name.to_sym]
2557
+ (node.nil? && scope.discovered_method?(owner, method_name, :instance)) ||
2558
+ discovery.possible_method?(owner, method_name, :instance) ||
2559
+ discovery.contested?(:discovered_def_nodes, key)
2560
+ end
2561
+
2562
+ # True where RBS knows one of the names the external's spelling can denote and declares the instance method
2563
+ # in it; a lookup that fails counts as declaring (the exception then does not apply).
2564
+ def external_declares?(candidates, method_name)
2565
+ known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
2566
+ return false if known.nil?
2567
+
2568
+ !Rigor::Reflection.instance_method_definition(known, method_name, scope: scope).nil?
2569
+ rescue StandardError
2570
+ true
2167
2571
  end
2168
2572
 
2169
2573
  def resolve_user_def_through_ancestors(class_name, method_name)
@@ -2188,11 +2592,10 @@ module Rigor
2188
2592
  # nothing but the frozen discovery index, and the `.with` guard in
2189
2593
  # {MethodDispatcher::StructMaterialization} needs the SAME answer without threading dispatcher state
2190
2594
  # through the dispatcher (#598 review). What stays here is the caching: the run-scoped
2191
- # `(class_name, method_name)` memo above, plus the per-edge name bucket handed to the walk so a class
2192
- # whose many methods are resolved pays each ancestor edge once.
2595
+ # `(class_name, method_name)` memo above. The chain itself is memoised per class by
2596
+ # {Scope::ResolutionChain}, so a class whose many methods are resolved builds it once.
2193
2597
  def compute_user_def_with_owner(class_name, method_name)
2194
- scope.user_def_through_ancestors(class_name, method_name,
2195
- name_memo: class_graph_buckets[:name])
2598
+ scope.user_def_through_ancestors(class_name, method_name)
2196
2599
  end
2197
2600
 
2198
2601
  # ADR-57 N5 — overridable-method adoption gate. A self-call resolved to a project `def` whose owner has
@@ -2222,16 +2625,40 @@ module Rigor
2222
2625
  OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
2223
2626
  private_constant :OVERRIDE_GATE_CACHE_KEY
2224
2627
 
2225
- # Run-scoped memo for {#overridden_in_project?}, keyed (like `class_graph_buckets`) by the identity of
2226
- # the frozen discovery trio so a new analysis generation lands in a fresh bucket, then nested `kind →
2227
- # owner → method_name`. The predicate is a pure function of those tables. Nesting avoids allocating a
2228
- # composite cache key on the hot path (the gate runs on every adopted self-call return), so a
2229
- # steady-state hit is three identity hash reads + two string/symbol hash reads with zero allocation.
2628
+ # Memo for {#overridden_in_project?}, nested `kind → owner → method_name`. The predicate is a pure
2629
+ # function of the scope's frozen discovery tables, so that index's *identity* is what says whether a
2630
+ # bucket still applies. Nesting under `kind` and `owner` avoids allocating a composite cache key on
2631
+ # the hot path (the gate runs on every adopted self-call return), so a steady-state hit is one
2632
+ # `equal?` check and three keyed hash reads with zero allocation.
2633
+ #
2634
+ # The key is the whole {Scope::DiscoveryIndex}, NOT the `discovered_def_nodes` /
2635
+ # `discovered_superclasses` / `discovered_includes` trio the walk is usually described by — the same
2636
+ # key, for the same reason, as {#class_graph_buckets}. {#related_to_owner?} reaches
2637
+ # {Scope#ancestor_name_candidates} and {Scope#known_user_class?}, which read
2638
+ # `discovered_header_nestings` and `discovered_methods` as well, so a trio key would let an index
2639
+ # that swapped only one of those serve an answer computed against the old table. Over `lib`+`plugins`
2640
+ # the two keys switch the same 113 times, so correctness here is free.
2641
+ #
2642
+ # ONE slot, replaced rather than accumulated — the same shape as
2643
+ # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. A `Scope` merges its file's discovery with the
2644
+ # project pre-pass, so every analysed file gets a fresh index: an identity-keyed *store* grew one
2645
+ # bucket per file and, because the key IS the index, pinned every file's whole discovery index for the
2646
+ # length of the run — 109 live buckets over `lib`+`plugins`, one per distinct trio at every key level,
2647
+ # holding 418 answers between them (211 instance, 207 singleton).
2648
+ #
2649
+ # What the store bought for that was one bucket's worth of cross-file reuse, not 109: only the
2650
+ # project-seed scope recurs, because {Analysis::Runner} types each file's pre-passes under it before
2651
+ # {ScopeIndexer} merges the file's own discovery in. A bucket is two small hashes, so giving that up
2652
+ # is free — unlike {#method_definers_index} below, whose bucket costs a full table scan and which is
2653
+ # keyed and bounded differently for exactly that reason.
2230
2654
  def override_gate_buckets
2231
- store = (Thread.current[OVERRIDE_GATE_CACHE_KEY] ||= {}.compare_by_identity)
2232
- by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
2233
- by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
2234
- by_super[scope.discovered_includes] ||= { instance: {}, singleton: {} }
2655
+ discovery = scope.discovery
2656
+ slot = Thread.current[OVERRIDE_GATE_CACHE_KEY]
2657
+ unless slot && slot[0].equal?(discovery)
2658
+ slot = [discovery, { instance: {}, singleton: {} }]
2659
+ Thread.current[OVERRIDE_GATE_CACHE_KEY] = slot
2660
+ end
2661
+ slot[1]
2235
2662
  end
2236
2663
 
2237
2664
  # True when some discovered project class/module — distinct from `owner` — redefines `(method_name,
@@ -2271,14 +2698,65 @@ module Rigor
2271
2698
  METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
2272
2699
  private_constant :METHOD_DEFINERS_INDEX_KEY
2273
2700
 
2274
- # Per-generation `method_name (Symbol) → [owner names]` inverted index over the instance / singleton
2275
- # def tables, memoised by the identity of the def table it inverts (a new analysis generation lands in
2276
- # a fresh bucket). The toplevel sentinel is excluded — a toplevel `def` has no class ancestry and so
2277
- # can never be an override.
2701
+ # The second way. Two flat thread slots rather than one slot holding an array of ways: a
2702
+ # `Thread.current[KEY] ||= […]` seeds the store with an array LITERAL, which folds to a tuple whose
2703
+ # elements are pinned, and the engine then reads the `way[0].equal?(…)` guards below as always-falsey
2704
+ # (`make check` fires `flow.always-truthy-condition` on this very file). Two keys read as two plain
2705
+ # `Thread.current` reads and keep the guards analysable.
2706
+ METHOD_DEFINERS_INDEX_ALT_KEY = :__rigor_method_definers_index_alt__
2707
+ private_constant :METHOD_DEFINERS_INDEX_ALT_KEY
2708
+
2709
+ # `method_name (Symbol) → [owner names]` inverted index over the instance / singleton def tables,
2710
+ # valid for exactly as long as the table it inverts: a new analysis generation, or a `Scope` that swaps
2711
+ # its index through {Scope#with_discovery}, needs a fresh one. The toplevel sentinel is excluded — a
2712
+ # toplevel `def` has no class ancestry and so can never be an override.
2713
+ #
2714
+ # Bounded to TWO slots, replaced rather than accumulated. This memo is the costliest member of the
2715
+ # per-file-store family, because it does not hold a handful of resolved answers but a whole inverted
2716
+ # index built over a file's merged def table: an identity-keyed *store* held 135 of them over
2717
+ # `lib`+`plugins` — 744,431 index rows, 50.0 MB in the index hashes and their owner-name arrays alone,
2718
+ # on top of the def tables the keys pinned.
2719
+ #
2720
+ # Two, and not the one slot the cheaper memos in this family use, because the tables ALTERNATE.
2721
+ # {Analysis::Runner} types each file's pre-passes under the project-seed scope and its main pass under
2722
+ # the file's merged tables. Where a file reaches this gate from BOTH phases the request sequence is
2723
+ # `seed, file_1, seed, file_2, …`, and a single slot evicts the seed's index once per file and
2724
+ # rebuilds it on the next — a full scan of the seed's whole def table, every file. 200 synthetic
2725
+ # files shaped that way measured +22.6 % allocations against the store, which a bounded slot must not
2726
+ # cost. Two ways — a most-recently-used slot and one alternate, swapped on a hit in the alternate —
2727
+ # cover that period-2 shape exactly: the same 200 files rebuild 201 times, matching the store.
2728
+ #
2729
+ # Wider rotations are not covered, and do not need to be. On `lib`+`plugins` only 6 of the 109 gating
2730
+ # files consult the seed's table, so its uses are separated by many other files' tables and fall out
2731
+ # of both ways: five rebuilds of a 700-entry table remain, about 4 ms. Retention is bounded at two
2732
+ # generations either way, which is what the store failed to do.
2733
+ #
2734
+ # A way is `[def_nodes, singleton_def_nodes, instance_index, singleton_index]`, so it keys on BOTH def
2735
+ # tables and a gate call that alternates instance and singleton kinds within one file does not rebuild
2736
+ # either. Each index is still built lazily: a run that never asks a singleton question never pays for
2737
+ # the singleton index.
2738
+ #
2739
+ # DELIBERATELY not keyed on the whole {Scope::DiscoveryIndex}, unlike {#class_graph_buckets} and
2740
+ # {#override_gate_buckets} above. {#build_method_definers_index} reads the ONE table it is handed and
2741
+ # nothing else, so the def tables are already the complete key, and the index object is a strictly
2742
+ # narrower one: the project-seed scope carries a fresh index per file while its def tables stay the
2743
+ # same object. Over the 200 synthetic files above, an index-keyed memo misses all 400 times where a
2744
+ # def-table-keyed one misses 201 — it would rebuild the seed's 934-entry table once per file and hand
2745
+ # back the +22.6 % this bound exists to avoid. Check that number before widening this key to match
2746
+ # its siblings.
2278
2747
  def method_definers_index(kind)
2279
- table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
2280
- store = (Thread.current[METHOD_DEFINERS_INDEX_KEY] ||= {}.compare_by_identity)
2281
- store[table] ||= build_method_definers_index(table)
2748
+ def_nodes = scope.discovered_def_nodes
2749
+ singleton_def_nodes = scope.discovered_singleton_def_nodes
2750
+ way = Thread.current[METHOD_DEFINERS_INDEX_KEY]
2751
+ unless way && way[0].equal?(def_nodes) && way[1].equal?(singleton_def_nodes)
2752
+ alt = Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY]
2753
+ alt = nil unless alt && alt[0].equal?(def_nodes) && alt[1].equal?(singleton_def_nodes)
2754
+ Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY] = way
2755
+ way = alt || [def_nodes, singleton_def_nodes, nil, nil]
2756
+ Thread.current[METHOD_DEFINERS_INDEX_KEY] = way
2757
+ end
2758
+ singleton = kind == :singleton
2759
+ way[singleton ? 3 : 2] ||= build_method_definers_index(singleton ? singleton_def_nodes : def_nodes)
2282
2760
  end
2283
2761
 
2284
2762
  def build_method_definers_index(table)
@@ -2291,34 +2769,14 @@ module Rigor
2291
2769
  index
2292
2770
  end
2293
2771
 
2294
- # True when `candidate`'s transitive ancestor chain (superclasses + included/prepended modules) reaches
2295
- # `owner` — i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Reuses
2296
- # the same BFS resolver the method-resolution ancestor walk uses, so name resolution (lexical nesting,
2297
- # RBS-known-ancestor pruning) is identical.
2298
- # Delegates to the shared walk's edge step so this BFS and method resolution resolve ancestor names
2299
- # identically, sharing the per-edge memo bucket.
2300
- def enqueue_ancestors(current, queue)
2301
- scope.enqueue_ancestors(current, queue, class_graph_buckets[:name])
2302
- end
2303
-
2772
+ # True when `candidate`'s ancestor chain (superclasses + included/prepended modules) reaches `owner` —
2773
+ # i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Asked of the same
2774
+ # {Scope::ResolutionChain} method resolution reads, so name resolution (lexical nesting, RBS-known-ancestor
2775
+ # pruning) is identical; a chain cut at the budget that does not reach `owner` answers false, as the walk
2776
+ # it replaced did. Reachability is the same in both of the chain's worlds.
2304
2777
  def related_to_owner?(candidate, owner)
2305
- queue = []
2306
- enqueue_ancestors(candidate, queue)
2307
- seen = {}
2308
- visited = 0
2309
- until queue.empty?
2310
- current = queue.shift
2311
- next if current.nil? || seen[current]
2312
-
2313
- return true if current == owner
2314
-
2315
- seen[current] = true
2316
- visited += 1
2317
- return false if visited > Scope::ANCESTOR_WALK_LIMIT
2318
-
2319
- enqueue_ancestors(current, queue)
2320
- end
2321
- false
2778
+ chain = Scope::ResolutionChain.for(scope, candidate.to_s, :instance, :methods)
2779
+ !chain.search(scope) { |entry| entry.name == owner }.nil?
2322
2780
  end
2323
2781
 
2324
2782
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
@@ -2541,7 +2999,7 @@ module Rigor
2541
2999
  # below reads it back from here rather than taking a second parameter, so key and frame cannot
2542
3000
  # disagree). It is installed even when nil, which is what stops a blockless callee reached from
2543
3001
  # inside a yielding body from inheriting the outer caller's block.
2544
- ExpressionTyper.with_yield_value_type(yield_type) do
3002
+ ExpressionTyper.with_call_site_frame(yield_type, forwarded_frame_types(def_node, arg_types)) do
2545
3003
  unless memo_candidate?(stack, plain_signature)
2546
3004
  trace_memo_refusal(stack, plain_signature)
2547
3005
  next compute_user_method_return(def_node, body_scope, stack, summaries,
@@ -2770,6 +3228,28 @@ module Rigor
2770
3228
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2771
3229
  end
2772
3230
 
3231
+ # Issue #1125 — the argument list a `def m(...)` body's `...` re-supplies, or nil for a def without the
3232
+ # forwarding parameter (whose body cannot contain a `...` call at all) and for a call that does not even
3233
+ # satisfy `m`'s own required positionals (that call raises at runtime; the body is not worth re-typing).
3234
+ #
3235
+ # Ruby allows `...` only beside leading positional parameters (`def m(a, ...)`, `def m(a = 1, ...)`) — a
3236
+ # keyword / rest / block parameter next to it is a syntax error — so the tail is the call's own argument
3237
+ # list MINUS the leading positionals those named parameters consume, keeping a trailing keyword shape
3238
+ # (which `takes_keywords?` reads as the keyword tail rather than as a positional). The callee's own
3239
+ # call-site `arg_types` are what remains.
3240
+ def forwarded_frame_types(def_node, arg_types)
3241
+ params = def_node.parameters
3242
+ return nil unless params.is_a?(Prism::ParametersNode)
3243
+ return nil unless params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3244
+
3245
+ positional = arg_types.dup
3246
+ kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
3247
+ return nil if positional.size < params.requireds.size
3248
+
3249
+ tail = positional[(params.requireds.size + params.optionals.size)..] || []
3250
+ kw_shape ? tail + [kw_shape] : tail
3251
+ end
3252
+
2773
3253
  # True when this frame's result is a candidate for the return memo: the one structural precondition,
2774
3254
  # stable across the body walk, that is necessary (but not sufficient) for a FINAL result — this plain
2775
3255
  # signature must not itself be on the recursion guard stack (else we are inside its own cycle,
@@ -3198,7 +3678,8 @@ module Rigor
3198
3678
  locals = bind_params_from_call_types(params, arg_types)
3199
3679
  return nil if locals.nil?
3200
3680
 
3201
- # Construct the body scope in a SINGLE allocation — the previous `Scope.empty.with_*.with_*…` chain
3681
+ # Construct the body scope in a SINGLE Scope allocation (plus the issue #1358 frame it carries, whose
3682
+ # scans run only on demand) — the previous `Scope.empty.with_*.with_*…` chain
3202
3683
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
3203
3684
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
3204
3685
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
@@ -3216,7 +3697,9 @@ module Rigor
3216
3697
  lexical_nesting: recorded_def_nesting(def_node),
3217
3698
  discovery: scope.discovery,
3218
3699
  struct_fold_safe_locals: body_fold_safe_locals(def_node, receiver, self_fold_safe),
3219
- dynamic_origins: scope.dynamic_origins
3700
+ dynamic_origins: scope.dynamic_origins,
3701
+ # Issue #1358 — the callee runs in a frame of its own ({Scope#with_match_frame}).
3702
+ match_frame: MatchRebinding::Frame.new(def_node.body, def_node.parameters)
3220
3703
  )
3221
3704
  end
3222
3705
 
@@ -3296,17 +3779,35 @@ module Rigor
3296
3779
  return nil if locals.nil?
3297
3780
 
3298
3781
  bind_keyword_params(params, kw_shape, locals)
3299
- locals[params.keyword_rest.name.to_sym] = dynamic_top if params.keyword_rest&.name
3782
+ rest_name = keyword_rest_name(params)
3783
+ locals[rest_name.to_sym] = keyword_rest_type(params, kw_shape) if rest_name
3300
3784
  locals[params.block.name.to_sym] = dynamic_top if params.block&.name
3301
3785
  locals
3302
3786
  end
3303
3787
 
3304
- # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence; `...`
3305
- # arrives as the keyword_rest slot. Both stay declined — correspondence, not width, is the issue.
3788
+ # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence and stay
3789
+ # declined — correspondence, not width, is the issue. Issue #1125: `def m(...)`'s forwarding slot is no
3790
+ # longer a decline. Its NAMED parameters (a leading `def m(a, ...)` required) bind exactly as before;
3791
+ # the forwarded tail is not a binding, so nothing is bound for it — the body's own `f(...)` reads the
3792
+ # frame's argument list instead ({#forwarded_argument_types}).
3306
3793
  def bindable_param_shape?(params)
3307
- params.is_a?(Prism::ParametersNode) &&
3308
- params.posts.empty? &&
3309
- !params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3794
+ params.is_a?(Prism::ParametersNode) && params.posts.empty?
3795
+ end
3796
+
3797
+ # Whether the def's trailing slot is `...` rather than a named `*rest` / `**rest`. Such a def accepts
3798
+ # an unbounded positional tail it does not name, so it behaves as a rest for the positional
3799
+ # correspondence and binds no local.
3800
+ def forwarding_params?(params)
3801
+ params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3802
+ end
3803
+
3804
+ # A `**rest` parameter's name, or nil when the slot is `def m(...)`'s forwarding parameter —
3805
+ # `Prism::ForwardingParameterNode` carries no name at all.
3806
+ def keyword_rest_name(params)
3807
+ rest = params.keyword_rest
3808
+ return nil unless rest.respond_to?(:name)
3809
+
3810
+ rest.name
3310
3811
  end
3311
3812
 
3312
3813
  def takes_keywords?(params)
@@ -3317,7 +3818,9 @@ module Rigor
3317
3818
  requireds = params.requireds
3318
3819
  optionals = params.optionals
3319
3820
  return nil if positional.size < requireds.size
3320
- return nil if params.rest.nil? && positional.size > requireds.size + optionals.size
3821
+
3822
+ extra = positional.size - requireds.size - optionals.size
3823
+ return nil if extra.positive? && params.rest.nil? && !forwarding_params?(params)
3321
3824
 
3322
3825
  locals = {}
3323
3826
  requireds.each_with_index { |param, index| bind_positional_param(locals, param, positional[index]) }
@@ -3361,6 +3864,23 @@ module Rigor
3361
3864
  param.respond_to?(:value) && param.value ? literal_default_type(param.value) : dynamic_top
3362
3865
  end
3363
3866
 
3867
+ # Issue #1125 — `**rest` collects the keyword-shape entries no named keyword parameter consumed, as a
3868
+ # closed `HashShape` of their value types. The pre-#1125 `Dynamic[top]` is kept whenever there is
3869
+ # nothing left to collect: no keyword shape at all, an open shape (unknown extras — every remaining-key
3870
+ # answer would be a guess), or a shape whose every pair a named parameter consumed. That last case is
3871
+ # what leaves the literal form's existing binding untouched (`target(a: 1, b: 2)` against
3872
+ # `def target(a:, b:, **rest)` still binds `rest` to `Dynamic[top]`), while a caller that DOES leave
3873
+ # keys over — the only shape where the parameter holds something to report — gets them typed.
3874
+ def keyword_rest_type(params, kw_shape)
3875
+ return dynamic_top if kw_shape.nil? || kw_shape.open?
3876
+
3877
+ consumed = params.keywords.map { |param| param.name.to_s.delete_suffix(":").to_sym }
3878
+ leftovers = kw_shape.pairs.except(*consumed)
3879
+ return dynamic_top if leftovers.empty?
3880
+
3881
+ Type::Combinator.hash_shape_of(leftovers)
3882
+ end
3883
+
3364
3884
  # A default expression contributes its type only when it is lexically scope-free — a scalar literal
3365
3885
  # or an EMPTY collection literal (`options = {}` is the dominant Rails idiom). Anything that could
3366
3886
  # read the def's own lexical scope binds `Dynamic` instead of being mis-typed in the caller's scope.
@@ -3405,11 +3925,73 @@ module Rigor
3405
3925
  scope.environment.nominal_for_name("Object") || dynamic_top
3406
3926
  end
3407
3927
 
3928
+ # Issue #1125 — two call-site argument shapes that used to reach every consumer (the parameter
3929
+ # binder, RBS dispatch, the block-parameter reader) as an opaque `Dynamic[top]` / bare `Nominal[Hash]`
3930
+ # now carry what the callee needs:
3931
+ #
3932
+ # - `f(...)` inside `def m(...)` re-supplies the arguments `m` was called with, read from the frame
3933
+ # {#infer_user_method_return} installed ({FORWARDED_ARGS_KEY}) — so it expands to a whole LIST, not
3934
+ # one type, which is why the map became a flat_map.
3935
+ # - a keyword hash built ENTIRELY from a double splat (`f(**h)`) is the shape of `h`, so `f(**h)`
3936
+ # binds the same parameters the literal form `f(a: 1, b: 2)` does.
3937
+ #
3938
+ # Both are precision-only. A `...` outside a forwarding frame (unreachable in valid Ruby) and a double
3939
+ # splat of anything but a Symbol-keyed closed `HashShape` — a shapeless `Hash[Symbol, V]`, an opaque
3940
+ # value, a mixed hash — fall back to exactly the pre-#1125 answer.
3941
+ #
3942
+ # Only a list holding a `...` takes the flat_map. Every call is typed through here, and the flat_map's
3943
+ # one-element Array per argument cost `rigor check` about 315K allocations over the v0.3.9 `lib`
3944
+ # ([#1503](https://github.com/rigortype/rigor/issues/1503)).
3408
3945
  def call_arg_types(node)
3409
3946
  arguments_node = node.arguments
3410
3947
  return [] if arguments_node.nil?
3411
3948
 
3412
- arguments_node.arguments.map { |argument| type_of(argument) }
3949
+ arguments = arguments_node.arguments
3950
+ return arguments.map { |argument| call_arg_type(argument) } unless forwards_arguments?(arguments)
3951
+
3952
+ arguments.flat_map do |argument|
3953
+ forwarded_argument_types(argument) || [call_arg_type(argument)]
3954
+ end
3955
+ end
3956
+
3957
+ # Whether a call's argument list holds `...`. Ruby accepts it only last; the scan checks every argument
3958
+ # rather than rely on that.
3959
+ def forwards_arguments?(arguments)
3960
+ arguments.any?(Prism::ForwardingArgumentsNode)
3961
+ end
3962
+
3963
+ # The frame's argument list when `argument` is `...`, else nil so the caller types it normally.
3964
+ def forwarded_argument_types(argument)
3965
+ return nil unless argument.is_a?(Prism::ForwardingArgumentsNode)
3966
+
3967
+ ExpressionTyper.current_forwarded_arg_types
3968
+ end
3969
+
3970
+ # The type a single call argument contributes; a keyword-hash argument first offers the `HashShape` it
3971
+ # stands for, and otherwise keeps its own type.
3972
+ def call_arg_type(argument)
3973
+ return type_of(argument) unless argument.is_a?(Prism::KeywordHashNode)
3974
+
3975
+ double_splat_hash_shape(argument) || type_of(argument)
3976
+ end
3977
+
3978
+ # The `HashShape` a `f(**h)` keyword-hash argument stands for, or nil to keep the argument's own type.
3979
+ # Declines a MIXED hash (`f(a: 1, **h)` — merging the literal pairs with the splatted shape is a second
3980
+ # shape algebra this slice does not need), an OPEN shape (unknown extras make every missing-keyword
3981
+ # answer a guess rather than a read), a shape with a non-Symbol key (Ruby itself rejects those as
3982
+ # keywords), and a splat of anything that is not a shape at all.
3983
+ def double_splat_hash_shape(node)
3984
+ elements = node.elements
3985
+ return nil unless elements.size == 1
3986
+
3987
+ splat = elements.first
3988
+ return nil unless splat.is_a?(Prism::AssocSplatNode) && splat.value
3989
+
3990
+ type = type_of(splat.value)
3991
+ return nil unless type.is_a?(Type::HashShape)
3992
+ return nil unless type.closed? && type.pairs.each_key.all?(Symbol)
3993
+
3994
+ type
3413
3995
  end
3414
3996
 
3415
3997
  # When the call carries a `Prism::BlockNode`, build the block's entry scope (outer locals plus
@@ -3431,23 +4013,44 @@ module Rigor
3431
4013
  return nil if block_arg.nil?
3432
4014
  return nil if receiver_type.nil?
3433
4015
 
4016
+ rebound = rebound_operand_typer(call_node)
4017
+ return rebound.send(:block_return_type_for, call_node, receiver_type, arg_types) if rebound
4018
+
3434
4019
  expected = MethodDispatcher.expected_block_param_types(
3435
4020
  receiver_type: receiver_type,
3436
4021
  method_name: call_node.name,
3437
4022
  arg_types: arg_types,
3438
- environment: scope.environment
4023
+ environment: scope.environment,
4024
+ scope: scope
3439
4025
  )
3440
- # ADR-16 Tier A: when a registered plugin's `block_as_methods` entry matches `(receiver_type,
3441
- # call_node.name)`, narrow the block body's `self_type` to the receiver class's instance type. The
3442
- # narrowing is `nil` for unmatched calls, leaving the existing scope contract unchanged.
3443
- narrowed_self = MacroBlockSelfType.narrow_self_type_for(
3444
- scope: scope, call_node: call_node, receiver_type: receiver_type
4026
+ block_return_for(
4027
+ block_arg, expected,
4028
+ call_node: call_node,
4029
+ narrowed_self_type: block_body_self_narrowing(call_node, receiver_type),
4030
+ repeats: !BLOCK_VALUE_DISCARDING.include?(call_node.name) && block_may_repeat?(call_node, receiver_type)
3445
4031
  )
3446
- block_return_for(block_arg, expected, narrowed_self_type: narrowed_self)
3447
4032
  rescue StandardError
3448
4033
  nil
3449
4034
  end
3450
4035
 
4036
+ # The catalogued iterators that discard their block's value — it selects the block-bearing overload and
4037
+ # nothing more — so the value pass lays no captured binding under their block: the fixpoint would buy a
4038
+ # type nothing reads, and `sum = 0; xs.each { |x| sum += x }` would pay it on top of the ADR-56
4039
+ # write-back's own. The #853 break-arm scan still lays it, since a `break` value is the call's.
4040
+ BLOCK_VALUE_DISCARDING = Set[
4041
+ :each, :each_with_index, :each_with_object, :each_pair, :each_key, :each_value, :each_index,
4042
+ :reverse_each, :each_entry, :each_slice, :each_cons, :each_char, :each_byte, :each_line,
4043
+ :each_codepoint, :times, :upto, :downto, :step
4044
+ ].freeze
4045
+ private_constant :BLOCK_VALUE_DISCARDING
4046
+
4047
+ # Whether the call may run its block more than once, so a later run reads a captured binding an earlier
4048
+ # run rebound — the premise of laying the #587 (b) binding ({#captured_block_bindings}) under the block.
4049
+ # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412).
4050
+ def block_may_repeat?(call_node, receiver_type)
4051
+ BlockRepetition.may_repeat?(method_name: call_node.name, receiver_type: receiver_type, scope: scope)
4052
+ end
4053
+
3451
4054
  EMPTY_BREAK_ARMS = [].freeze
3452
4055
  private_constant :EMPTY_BREAK_ARMS
3453
4056
 
@@ -3459,13 +4062,15 @@ module Rigor
3459
4062
  # nested block, lambda, `def`, or loop targets THAT construct instead; {JUMP_BOUNDARY_NODES} stops the
3460
4063
  # scan there, and the identity filter drops the ones the sink still collects because the nested
3461
4064
  # construct is walked under the same installation. `break` with no argument carries nil, so the call
3462
- # becomes optional — which is what Ruby does.
4065
+ # becomes optional — which is what Ruby does. A call that only stores its block ({StoredBlockCall}: `lambda`,
4066
+ # `define_method`, `Thread.new`, …) never runs it, so no arm of that block is the call's value.
3463
4067
  #
3464
4068
  # A failure yields no arms rather than propagating, matching {#block_return_type_for}: a call typed
3465
4069
  # without its break arms is the pre-#853 answer, while a raise here would take out the whole call.
3466
4070
  def call_break_arm_types(node, receiver_override: nil)
3467
4071
  block_node = node.block
3468
4072
  return EMPTY_BREAK_ARMS unless block_node.is_a?(Prism::BlockNode)
4073
+ return EMPTY_BREAK_ARMS if StoredBlockCall.stores_block?(node)
3469
4074
 
3470
4075
  body = block_node.body
3471
4076
  return EMPTY_BREAK_ARMS if body.nil? || !block_level_jump?(body, Prism::BreakNode)
@@ -3484,11 +4089,12 @@ module Rigor
3484
4089
  receiver = receiver_override || call_receiver_type_for(call_node)
3485
4090
  return EMPTY_BREAK_ARMS if receiver.nil?
3486
4091
 
3487
- narrowed_self = MacroBlockSelfType.narrow_self_type_for(
3488
- scope: scope, call_node: call_node, receiver_type: receiver
3489
- )
4092
+ param_types = break_arm_param_types(call_node, receiver)
3490
4093
  block_scope = block_entry_scope(
3491
- block_node, break_arm_param_types(call_node, receiver), narrowed_self_type: narrowed_self
4094
+ block_node, param_types,
4095
+ call_node: call_node,
4096
+ narrowed_self_type: block_body_self_narrowing(call_node, receiver),
4097
+ captured: repeating_captured_bindings(block_node, param_types, block_may_repeat?(call_node, receiver))
3492
4098
  )
3493
4099
  _result, collected = StatementEvaluator.with_break_value_sink do
3494
4100
  without_block_body_threading { block_scope.evaluate(body) }
@@ -3496,39 +4102,78 @@ module Rigor
3496
4102
  collected.filter_map { |jump, type| type if targets.key?(jump) }
3497
4103
  end
3498
4104
 
4105
+ # The block body's narrowed `self_type`, or `nil` to leave the scope contract unchanged.
4106
+ #
4107
+ # ADR-16 Tier A: a registered plugin's `block_as_methods` entry matching `(receiver_type,
4108
+ # call_node.name)` narrows to the receiver class's instance type.
4109
+ #
4110
+ # Issue #963: `define_method(:name) { ... }` installs its block as an instance method and runs it with
4111
+ # `self` bound to the receiving instance, so the block body's `self` is the INSTANCE side of a class body's
4112
+ # `Singleton[X]`. Both block-entry paths narrow it, and both decline on the same `class << ...` bodies,
4113
+ # because the whole distinction rides on `Scope#singleton_class_body?` rather than on the statement
4114
+ # evaluator's frame stack. This pass therefore cannot compute a carrier the evaluator disagrees with — it
4115
+ # matters wherever the block's value is observable, e.g. a project-declared generic `define_method`
4116
+ # signature that returns the block's own type.
4117
+ def block_body_self_narrowing(call_node, receiver_type)
4118
+ MacroBlockSelfType.narrow_self_type_for(
4119
+ scope: scope, call_node: call_node, receiver_type: receiver_type
4120
+ ) || DefineMethodBlockSelf.narrow_self_type_for(scope: scope, call_node: call_node)
4121
+ end
4122
+
3499
4123
  def break_arm_param_types(call_node, receiver)
3500
4124
  MethodDispatcher.expected_block_param_types(
3501
4125
  receiver_type: receiver,
3502
4126
  method_name: call_node.name,
3503
4127
  arg_types: call_arg_types(call_node),
3504
- environment: scope.environment
4128
+ environment: scope.environment,
4129
+ scope: scope
3505
4130
  )
3506
4131
  end
3507
4132
 
3508
- def block_return_for(block_arg, expected, narrowed_self_type: nil)
4133
+ def block_return_for(block_arg, expected, call_node: nil, narrowed_self_type: nil, repeats: false)
3509
4134
  case block_arg
3510
4135
  when Prism::BlockNode
3511
- type_block_body(block_arg, block_entry_scope(block_arg, expected, narrowed_self_type: narrowed_self_type))
4136
+ captured = repeating_captured_bindings(block_arg, expected, repeats)
4137
+ entry = block_entry_scope(
4138
+ block_arg, expected, call_node: call_node, narrowed_self_type: narrowed_self_type, captured: captured
4139
+ )
4140
+ type_block_body(block_arg, entry, captured: captured)
3512
4141
  when Prism::BlockArgumentNode
3513
4142
  symbol_block_return_type(block_arg, expected)
3514
4143
  end
3515
4144
  end
3516
4145
 
3517
4146
  # The scope a block body is typed under: the surrounding scope plus the parameter bindings the receiving
3518
- # method's signature implies.
4147
+ # method's signature implies, with the #587 (b) `captured` binding ({#repeating_captured_bindings}) laid
4148
+ # between the two, so the body reads what a captured local, instance variable, class variable or global
4149
+ # holds on ANY run rather than the first.
3519
4150
  #
3520
4151
  # Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
3521
- # yielding method's business, so the return-typing pass must see the same unmodelled-self mark.
3522
- def block_entry_scope(block_node, expected, narrowed_self_type: nil)
3523
- bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
3524
- block_scope = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
3525
- acc.with_local(name, type)
3526
- end
4152
+ # yielding method's business, so the return-typing pass must see the same unmodelled-self mark. Issue #1358
4153
+ # — and the same match-global view ({MatchRebinding.block_entry}), which reads the owning `call_node`.
4154
+ def block_entry_scope(block_node, expected, call_node: nil, narrowed_self_type: nil, captured: nil)
4155
+ entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
4156
+ entry = captured.lay(entry) if captured
4157
+ block_scope = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
3527
4158
  return block_scope unless narrowed_self_type
3528
4159
 
3529
4160
  block_scope.with_self_type(narrowed_self_type)
3530
4161
  end
3531
4162
 
4163
+ # The #587 (b) captured binding when the call may run the block more than once ({#block_may_repeat?}), and
4164
+ # nil — no binding — for a block it runs at most once, whose captures no earlier run can have moved.
4165
+ def repeating_captured_bindings(block_node, expected, repeats)
4166
+ repeats ? generic_captured_bindings(block_node, expected) : nil
4167
+ end
4168
+
4169
+ # The #587 (b) captured binding for the generic pass — or nil, which keeps the entry scope, when computing
4170
+ # it fails: a raise here would otherwise take out the whole block-return pass.
4171
+ def generic_captured_bindings(block_node, expected)
4172
+ captured_block_bindings(block_node, expected)
4173
+ rescue StandardError
4174
+ nil
4175
+ end
4176
+
3532
4177
  # `&:symbol` desugars to a one-arg Proc that dispatches `symbol` against its argument. When the param
3533
4178
  # type is known and the resulting inner dispatch is precise, this returns the precise carrier;
3534
4179
  # otherwise it returns `Dynamic[Top]` (still non-nil) so the outer dispatcher selects the
@@ -3568,14 +4213,114 @@ module Rigor
3568
4213
  # join, `ops.all? { |o| next false unless o; true }` read as `Constant[true]` and
3569
4214
  # {MethodDispatcher::BlockFolding} folded the call to always-truthy on a program that really can answer
3570
4215
  # false — a warning on correct code.
3571
- def type_block_body(block_node, block_scope)
4216
+ #
4217
+ # `captured` is the #587 (b) binding `block_scope` was laid over, if any; the names it answers are left to it
4218
+ # ({#tail_only_block_body_type}).
4219
+ def type_block_body(block_node, block_scope, captured: nil)
3572
4220
  body = block_node.body
3573
4221
  return Type::Combinator.constant_of(nil) if body.nil?
3574
4222
 
3575
4223
  arms = block_level_next_arms(body)
3576
4224
  return block_body_type_joining_nexts(body, block_scope, arms) if arms
3577
4225
 
3578
- threaded_block_body_type(body, block_scope) || block_scope.type_of(body)
4226
+ threaded_block_body_type(body, block_scope) || tail_only_block_body_type(body, block_scope, captured)
4227
+ end
4228
+
4229
+ # The tail typed in the entry scope — except under {#block_body_threading_suppressed?}, where a tail that
4230
+ # reads what its own prefix changed would get the ENTRY binding back, a stale answer rather than a wider
4231
+ # one. There the tail is typed over {#prefix_answered_scope}, which re-answers exactly those names without
4232
+ # evaluating the prefix. The threading's other declines keep the plain tail-only answer: a body with no
4233
+ # prefix, or whose tail ignores it, is not stale, while a `rescue` body and a prefix that can `break`
4234
+ # ({JUMP_NODES}) are typed tail-only with or without the suppression, and neither path re-answers them.
4235
+ #
4236
+ # A failure in the re-answer falls back to the plain tail-only answer, for the reason
4237
+ # {#threaded_block_body_type} gives: a raise reaching `block_return_type_for` would report "no block".
4238
+ def tail_only_block_body_type(body, block_scope, captured)
4239
+ return block_scope.type_of(body) unless block_body_threading_suppressed? && body.is_a?(Prism::StatementsNode)
4240
+
4241
+ answered =
4242
+ begin
4243
+ prefix_answered_scope(body.body, block_scope, captured)
4244
+ rescue StandardError
4245
+ block_scope
4246
+ end
4247
+ answered.type_of(body)
4248
+ end
4249
+
4250
+ # `block_scope` with every name the tail reads and the prefix changed ({#tail_dependent_body_names}) bound to
4251
+ # what the prefix can have left in it, answered without evaluating the prefix — which is the cost the
4252
+ # suppression refuses — on the terms the per-element fold's captured binding answers a capture under the same
4253
+ # suppression:
4254
+ #
4255
+ # - a name the prefix REBINDS reads `Dynamic[top]` ({#captured_floor}'s answer). `i += w; i` inside a block
4256
+ # the call runs once read `i` at its entry `0`, and `k == 0` then fired always-truthy on a `k` Ruby holds
4257
+ # as `1`.
4258
+ # - a name the prefix only MUTATES IN PLACE reads its unknown-store widening ({UnknownStoreWidening.widen}),
4259
+ # the binding {#stored_capture_bindings} lays: `|_k, a| a << w; a` over `{ x: [], y: [] }` read `a` as its
4260
+ # entry `[]` and the call as `Array[[]]`; it now reads `Array[Array[Dynamic[top]]]`.
4261
+ #
4262
+ # That is a floor per NAME, not per block, so the structure around a floored name survives (`e = e.to_s; [e, w]`
4263
+ # keeps its Tuple), and it is limited to the names tail-only answers stale. A name the entry scope does not bind —
4264
+ # a body-local, or an instance variable, class variable or global nothing bound yet — already reads
4265
+ # `Dynamic[top]`. An instance variable on its ADR-58 class-wide seed takes no floor for a rebind, since the seed
4266
+ # is the union of every write in the class, this prefix's included, though its mutation sites still widen it
4267
+ # ({#class_seeded_ivar?}). A name whose widening declines keeps its entry binding, because the threaded body
4268
+ # would have kept it as well: `s = String.new; … { s << "x"; s }` is `String` either way, and a precise nominal
4269
+ # `Array[String]` is a claim the widening may not grow on either path. And a name the #587 (b) `captured` binding
4270
+ # answers is left to it, as {#unanswered_tail_dependency?} leaves it: the per-element fold computes that binding
4271
+ # before it suppresses the threading above its cap, so it can hold the fixpoint's converged `Integer` for
4272
+ # `total += e; total`, which a floor here would throw away.
4273
+ def prefix_answered_scope(statements, block_scope, captured)
4274
+ return block_scope if statements.size < 2
4275
+
4276
+ names = tail_dependent_body_names(statements) - (captured&.names || EMPTY_NAME_SET)
4277
+ return block_scope if names.empty?
4278
+
4279
+ rebound, sites = prefix_changes(statements)
4280
+ names.reduce(block_scope) do |acc, name|
4281
+ answer = prefix_left_binding(block_scope, name, rebound, sites)
4282
+ answer ? CapturedLocals.bind(acc, name, answer) : acc
4283
+ end
4284
+ end
4285
+
4286
+ def prefix_left_binding(block_scope, name, rebound, sites)
4287
+ entry = CapturedLocals.bound_type(block_scope, name)
4288
+ return nil if entry.nil?
4289
+
4290
+ return Type::Combinator.untyped if rebound.include?(name) && !class_seeded_ivar?(block_scope, name)
4291
+
4292
+ widened = UnknownStoreWidening.widen(entry, sites.fetch(name, NO_MUTATION_SITES))
4293
+ widened == entry ? nil : widened
4294
+ end
4295
+
4296
+ # An instance variable still on its ADR-58 class-wide seed: the union of every WRITE in the class, so a rebind
4297
+ # in the prefix is already in it and needs no floor. An in-place mutation is no write — `@out << w.to_s` leaves
4298
+ # the seed `"k"` while the object holds `"k1"` — so the mutation sites still widen the seed, as they widen any
4299
+ # other entry binding.
4300
+ def class_seeded_ivar?(block_scope, name)
4301
+ CapturedLocals.ivar_name?(name) && block_scope.declaration_sourced?(:ivar, name)
4302
+ end
4303
+
4304
+ NO_MUTATION_SITES = [].freeze
4305
+ private_constant :NO_MUTATION_SITES
4306
+
4307
+ # The prefix's changes split the way {#prefix_left_binding} answers them: the names a write node rebinds,
4308
+ # and each in-place mutation site filed under every variable its receiver can evaluate to. The walk and both
4309
+ # predicates are {#prefix_statement_jump_free?}'s, so a name {#tail_dependent_body_names} reports is always
4310
+ # filed here under one of the two. Only a suppressed tail-only body with a dependent tail pays it.
4311
+ def prefix_changes(statements)
4312
+ rebound = Set.new
4313
+ sites = {}
4314
+ statements[0...-1].each do |statement|
4315
+ Source::NodeWalker.each_with_ancestors(statement) do |node, ancestors|
4316
+ rebound << node.name if VARIABLE_WRITE_NODES.include?(node.class)
4317
+ next unless in_place_mutation?(node)
4318
+
4319
+ nested = ancestors.any? { |ancestor| CLOSURE_NODES.include?(ancestor.class) }
4320
+ each_mutated_name(node, nested) { |name| (sites[name] ||= []) << node }
4321
+ end
4322
+ end
4323
+ [rebound, sites]
3579
4324
  end
3580
4325
 
3581
4326
  # Evaluates the body once under a `next` sink and joins the arms that leave THIS block with the
@@ -3616,39 +4361,14 @@ module Rigor
3616
4361
  block_level_jump_nodes(body, Prism::NextNode)
3617
4362
  end
3618
4363
 
3619
- # True when a `klass` jump is reachable from `node` without crossing a construct that retargets it.
3620
- # Allocation-free and early-exiting: this is the scan every block body pays, and the overwhelming
3621
- # majority of them answer false on it.
3622
- def block_level_jump?(node, klass)
3623
- return false if node.nil?
3624
- return true if node.is_a?(klass)
3625
-
3626
- node.rigor_each_child do |child|
3627
- next if JUMP_BOUNDARY_NODES.include?(child.class)
4364
+ # True when a `klass` jump is reachable from `node` without crossing a construct that retargets it
4365
+ # ({JumpTargets.any?}, allocation-free and early-exiting: this is the scan every block body pays).
4366
+ def block_level_jump?(node, klass) = JumpTargets.any?(node, klass)
3628
4367
 
3629
- return true if block_level_jump?(child, klass)
3630
- end
3631
- false
3632
- end
3633
-
3634
- # The identity-keyed set of `klass` jumps that target THIS block — every one {#block_level_jump?} would
3635
- # answer true for, rather than the first. The sinks in `StatementEvaluator` also collect jumps from
3636
- # nested blocks / loops / defs evaluated under the same installation, so the consumer filters against
3637
- # this set by node identity.
3638
- def block_level_jump_nodes(body, klass)
3639
- found = {}.compare_by_identity
3640
- collect_block_level_jumps(body, klass, found)
3641
- found
3642
- end
3643
-
3644
- def collect_block_level_jumps(node, klass, found)
3645
- found[node] = true if node.is_a?(klass)
3646
- node.rigor_each_child do |child|
3647
- next if JUMP_BOUNDARY_NODES.include?(child.class)
3648
-
3649
- collect_block_level_jumps(child, klass, found)
3650
- end
3651
- end
4368
+ # The identity-keyed set of `klass` jumps that target THIS block ({JumpTargets.of}). The sinks in
4369
+ # `StatementEvaluator` also collect jumps from nested blocks / loops / defs evaluated under the same
4370
+ # installation, so the consumer filters against this set by node identity.
4371
+ def block_level_jump_nodes(body, klass) = JumpTargets.of(body, klass)
3652
4372
 
3653
4373
  # Re-typing the whole body would be wrong to do unconditionally: this path runs for EVERY block-bearing
3654
4374
  # call, and the statements ahead of the tail are pure cost whenever the tail does not depend on them.
@@ -3663,8 +4383,11 @@ module Rigor
3663
4383
  # - the fold is not re-entrant. `StatementEvaluator#eval_call` already evaluates each nested block body
3664
4384
  # once, plus up to three more times under the ADR-56 `BodyFixpoint` when the block rebinds a captured
3665
4385
  # local, so a fold nested inside a fold would multiply that work per block-nesting level. Inside a
3666
- # threaded body a nested block-bearing call reverts to the tail-only path — a wider answer in a rare
3667
- # shape, never a new false positive.
4386
+ # threaded body a nested block-bearing call reverts to the tail-only path. That is a wider answer only
4387
+ # while the nested tail ignores its own prefix: a tail reading a parameter or captured local the prefix
4388
+ # mutated gets the ENTRY binding back, so the per-element and per-pair folds floor that shape
4389
+ # ({#tail_only_body_floor}), and the generic block-return pass re-answers each stale name on its own
4390
+ # ({#prefix_answered_scope}).
3668
4391
  #
3669
4392
  # ADR-56 interaction: the fold cannot double-apply or fight the captured-local write-back. That
3670
4393
  # write-back is `StatementEvaluator#write_back_block_captures`, computed from the CALLER's scope into
@@ -3728,10 +4451,11 @@ module Rigor
3728
4451
  private_constant :VARIABLE_WRITE_NODES
3729
4452
 
3730
4453
  # Every node that OBSERVES a variable binding: the plain reads plus the compound writes, which read
3731
- # their target before rebinding it (`v += 1` in the tail depends on an earlier `v = 0`).
4454
+ # their target before rebinding it (`v += 1` in the tail depends on an earlier `v = 0`). An `it` read
4455
+ # observes the local `:it` ({ReceiverAlias.read_name}); it has no `name` of its own.
3732
4456
  VARIABLE_READ_NODES = (
3733
4457
  VARIABLE_WRITE_NODES | [
3734
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
4458
+ Prism::LocalVariableReadNode, Prism::ItLocalVariableReadNode, Prism::InstanceVariableReadNode,
3735
4459
  Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
3736
4460
  ]
3737
4461
  ).freeze
@@ -3758,11 +4482,9 @@ module Rigor
3758
4482
  # nothing about our block's value — it neither triggers the decline nor joins as an arm. A nested
3759
4483
  # `BlockNode` / `LambdaNode` is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads
3760
4484
  # soundly — the inner `next` ends the inner iteration); a loop consumes both forms (`while … next 5 …
3761
- # end` continues the loop); a `DefNode` body is a different method entirely.
3762
- JUMP_BOUNDARY_NODES = Set[
3763
- Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
3764
- Prism::WhileNode, Prism::UntilNode, Prism::ForNode
3765
- ].freeze
4485
+ # end` continues the loop); a `DefNode` body is a different method entirely. The set is
4486
+ # {JumpTargets::BOUNDARY_NODES}, shared with `StatementEvaluator`'s loop and block joins.
4487
+ JUMP_BOUNDARY_NODES = JumpTargets::BOUNDARY_NODES
3766
4488
  private_constant :JUMP_BOUNDARY_NODES
3767
4489
 
3768
4490
  # True when the tail statement observes a variable name one of the earlier statements binds OR mutates
@@ -3777,9 +4499,13 @@ module Rigor
3777
4499
  # hands downstream rules a provably-empty array. Threading is the fix, not a cost: `StatementEvaluator`
3778
4500
  # runs `MutationWidening.widen_after_call` on the `push`, so the threaded tail reads the widened
3779
4501
  # `Array[…]`. A call therefore contributes every variable its receiver can evaluate to
3780
- # ({ReceiverAlias.candidates} — the ternary-selected receiver of issue #277 included) whenever its name
3781
- # is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
3782
- # tables is what keeps "the scan says thread" and "threading changes something" the same predicate.
4502
+ # ({ReceiverAlias.mutated_reads} — the ternary-selected receiver of issue #277 included) whenever its
4503
+ # name is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
4504
+ # tables and receiver answer is what keeps "the scan says thread" and "threading changes something" the
4505
+ # same predicate. The index-write nodes ({INDEX_WRITE_NODES}) store through `[]=` without being a call,
4506
+ # so a name-keyed scan missed them and `h[:a] += 1; h[:a]` kept the literal's `0`; they contribute their
4507
+ # receiver the same way. A scan that read only local and instance-variable receivers missed `$g << w; $g`
4508
+ # and `it << w; it` the same way, while the tail kept the entry `"k"` / `[]`.
3783
4509
  #
3784
4510
  # Cost is two walks of the body, the second only when the first found a write and no jump — the same
3785
4511
  # order of cost `StatementEvaluator`'s own per-call captured-write scan already pays, and far below
@@ -3792,22 +4518,25 @@ module Rigor
3792
4518
  return false if written.nil?
3793
4519
 
3794
4520
  Source::NodeWalker.each(statements.last) do |node|
3795
- return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
4521
+ return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(ReceiverAlias.read_name(node))
3796
4522
  end
3797
4523
  false
3798
4524
  end
3799
4525
 
3800
4526
  # WHICH names the predicate above answers YES on — every name the tail observes that the prefix binds
3801
- # or mutates in place. Only the arity-cap floor ({#unanswered_tail_dependency?}) needs them, which is
4527
+ # or mutates in place. Only the tail-only floor ({#unanswered_tail_dependency?}) needs them, which is
3802
4528
  # why the predicate is not written over this method: the predicate runs for every multi-statement block
3803
- # body and short-circuits on the first hit, the floor runs for a handful of calls. Sharing
4529
+ # body and short-circuits on the first hit, the floor runs only for a walk typed tail-only. Sharing
3804
4530
  # {#prefix_written_names} is what keeps the two from drifting about what the prefix binds.
3805
4531
  def tail_dependent_body_names(statements)
3806
4532
  written = prefix_written_names(statements)
3807
4533
  return EMPTY_NAME_SET if written.nil?
3808
4534
 
3809
4535
  Source::NodeWalker.each(statements.last).filter_map do |node|
3810
- node.name if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
4536
+ next unless VARIABLE_READ_NODES.include?(node.class)
4537
+
4538
+ name = ReceiverAlias.read_name(node)
4539
+ name if written.include?(name)
3811
4540
  end.to_set
3812
4541
  end
3813
4542
 
@@ -3816,7 +4545,7 @@ module Rigor
3816
4545
  def prefix_written_names(statements)
3817
4546
  written = Set.new
3818
4547
  statements[0...-1].each do |statement|
3819
- return nil unless prefix_statement_jump_free?(statement, written, false)
4548
+ return nil unless prefix_statement_jump_free?(statement, written, false, false)
3820
4549
  end
3821
4550
  return nil if written.empty?
3822
4551
 
@@ -3827,40 +4556,73 @@ module Rigor
3827
4556
  private_constant :EMPTY_NAME_SET
3828
4557
 
3829
4558
  # True when `node` cannot jump out of the block with a value, collecting into `written` the names it
3830
- # binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call, through
3831
- # every variable its receiver can evaluate to) on the way down. `retargeted` is true once the descent
3832
- # has passed a boundary.
4559
+ # binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call or an
4560
+ # {INDEX_WRITE_NODES} store, through every variable its receiver can evaluate to) on the way down.
4561
+ # `retargeted` is true once the descent has passed a boundary, and `nested` once it has passed a block or
4562
+ # lambda ({CLOSURE_NODES}), whose `it` is not the body's.
3833
4563
  #
3834
4564
  # A `Prism::DefinedNode`'s operand is never evaluated, so it is not descended into — the same rule
3835
4565
  # {Source::NodeWalker} applies, for the same reason: neither a write nor a jump under `defined?` runs.
3836
- def prefix_statement_jump_free?(node, written, retargeted)
4566
+ def prefix_statement_jump_free?(node, written, retargeted, nested)
3837
4567
  return false if !retargeted && JUMP_NODES.include?(node.class)
3838
4568
 
3839
4569
  written << node.name if VARIABLE_WRITE_NODES.include?(node.class)
3840
- collect_mutated_receivers(node, written) if node.is_a?(Prism::CallNode)
4570
+ each_mutated_name(node, nested) { |name| written << name } if in_place_mutation?(node)
3841
4571
  return true if node.is_a?(Prism::DefinedNode)
3842
4572
 
3843
4573
  child_retargeted = retargeted || JUMP_BOUNDARY_NODES.include?(node.class)
4574
+ child_nested = nested || CLOSURE_NODES.include?(node.class)
3844
4575
  node.rigor_each_child do |child|
3845
- return false unless prefix_statement_jump_free?(child, written, child_retargeted)
4576
+ return false unless prefix_statement_jump_free?(child, written, child_retargeted, child_nested)
3846
4577
  end
3847
4578
  true
3848
4579
  end
3849
4580
 
3850
- def collect_mutated_receivers(call_node, written)
3851
- return unless MutationWidening::SHAPE_MUTATORS.include?(call_node.name)
4581
+ # The forms that store through `[]=` without being a `[]=` call ({IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES}).
4582
+ # `StatementEvaluator` widens the three compound writes in straight-line code, and its captured-local
4583
+ # write-back widens all four when a nested block stores through one. A straight-line index TARGET widens
4584
+ # too, at the multi-assign, `for` index or `rescue =>` reference that owns it (`eval_multi_write`,
4585
+ # `bind_for_index`, `bind_rescue_reference`).
4586
+ INDEX_WRITE_NODES = Set.new(IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES).freeze
4587
+ private_constant :INDEX_WRITE_NODES
3852
4588
 
3853
- ReceiverAlias.candidates(call_node.receiver).each { |read| written << read.name }
4589
+ def in_place_mutation?(node)
4590
+ return MutationWidening::SHAPE_MUTATORS.include?(node.name) if node.is_a?(Prism::CallNode)
4591
+
4592
+ INDEX_WRITE_NODES.include?(node.class)
3854
4593
  end
3855
4594
 
4595
+ # Yields the name of every variable the in-place mutation `node` changes: its receiver's
4596
+ # {ReceiverAlias.mutated_reads}, the answer the straight-line widening the threaded body runs reads too, so a
4597
+ # global or class variable counts (`$g << w; $g`) as well as a local, an instance variable and the `it`
4598
+ # parameter. An `it` read `nested` under a block or lambda inside the prefix is that closure's own parameter,
4599
+ # never the body's `it`, so it names nothing here: `[[]].each { it << w }; it` leaves the body's `it` as it was.
4600
+ def each_mutated_name(node, nested)
4601
+ ReceiverAlias.mutated_reads(node.receiver).each do |read|
4602
+ next if nested && read.is_a?(Prism::ItLocalVariableReadNode)
4603
+
4604
+ yield ReceiverAlias.read_name(read)
4605
+ end
4606
+ end
4607
+
4608
+ # The closures whose `it` is their own: a nested block or lambda always binds `it` to its own parameter.
4609
+ CLOSURE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
4610
+ private_constant :CLOSURE_NODES
4611
+
3856
4612
  # v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
3857
4613
  # Tuple position, binds the block parameter to that element's type, and re-types the block body. The
3858
4614
  # per-position results are assembled into `Tuple[U_1..U_n]`, strictly tighter than the RBS-projected
3859
4615
  # `Array[union]`.
3860
4616
  #
3861
4617
  # Declines (returns nil) when the receiver is not a `Tuple` with at least one element, when the call
3862
- # has no `Prism::BlockNode`, when the method is outside the supported set, when block typing raises
3863
- # mid-loop, or when the block has no body. The decline path leaves the dispatch chain untouched.
4618
+ # has no `Prism::BlockNode`, when the method is outside the supported set, when the call carries an
4619
+ # argument, when block typing raises mid-loop, or when the block has no body. The decline path leaves the
4620
+ # dispatch chain untouched.
4621
+ #
4622
+ # The walk reads only the block, so an argument declines: `index(value)` / `find_index(value)` search by
4623
+ # `==` without running the block, and `find(ifnone)` / `detect(ifnone)` answer `ifnone.call` when no
4624
+ # position matches. `r = [1, 2].find(-> { 0 }) { |e| e > 5 }` is `0` at runtime; the walk answered `nil`,
4625
+ # and `r + 1` then reported a nil receiver on correct code. The other supported methods take no argument.
3864
4626
  PER_ELEMENT_TUPLE_METHODS = Set[
3865
4627
  :map, :collect, :filter_map, :flat_map,
3866
4628
  :select, :filter, :reject,
@@ -3874,6 +4636,9 @@ module Rigor
3874
4636
  ].freeze
3875
4637
  private_constant :HASH_SHAPE_TRANSFORM_METHODS
3876
4638
 
4639
+ IN_PLACE_HASH_SHAPE_TRANSFORMS = Set[:transform_keys!, :transform_values!].freeze
4640
+ private_constant :IN_PLACE_HASH_SHAPE_TRANSFORMS
4641
+
3877
4642
  # Cardinality cap for per-element block fold over finite-bound `Constant<Range>` receivers. Walking
3878
4643
  # `(1..1_000_000).map { … }` element-wise would balloon block-typing cost and explode the resulting
3879
4644
  # Tuple, so only short ranges expand into per-position folds. Larger ranges decline so the RBS tier
@@ -3883,7 +4648,7 @@ module Rigor
3883
4648
 
3884
4649
  def try_per_element_block_fold(call_node, receiver_type)
3885
4650
  return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
3886
- return nil if find_family_with_args?(call_node)
4651
+ return nil unless call_node.arguments.nil?
3887
4652
 
3888
4653
  element_types = per_element_elements_of(receiver_type)
3889
4654
  return nil if element_types.nil? || element_types.empty?
@@ -3892,12 +4657,13 @@ module Rigor
3892
4657
  return nil if per_position.nil? || per_position.any?(&:nil?)
3893
4658
 
3894
4659
  assemble_per_element_result(call_node.name, per_position, element_types) ||
3895
- find_family_floor(call_node.name, element_types)
4660
+ undecided_fold_floor(call_node.name, element_types, receiver_type)
3896
4661
  end
3897
4662
 
3898
- # The honest answer for `find` / `detect` / `find_index` / `index` when this fold walked every position
3899
- # and the assembler still could not decide — which happens for exactly one reason: some position's
3900
- # predicate is not a `Constant`, so "the first matching one" is not a static fact.
4663
+ # The honest answer for the find family (`find` / `detect` / `find_index` / `index`) and the filter family
4664
+ # (`select` / `filter` / `reject`) when this fold walked every position and the assembler still could not
4665
+ # decide — which happens for exactly one reason: some position's predicate is not a `Constant`, so "the
4666
+ # first matching one" or "the ones kept" is not a static fact.
3901
4667
  #
3902
4668
  # Falling through to the dispatcher was WRONG for this family, and issue #617 residue (1) is the bill:
3903
4669
  # `seen = 0; [1, 2].find do |e| seen += 1; seen == 2 end` answered `nil` where the runtime answers `2`.
@@ -3912,19 +4678,39 @@ module Rigor
3912
4678
  # back an element, it does not compute one. `find_index` / `index` answer a position in the receiver, so
3913
4679
  # `Integer?` is their floor.
3914
4680
  #
3915
- # Nothing else in {PER_ELEMENT_TUPLE_METHODS} takes a floor: `map`'s assembler cannot decline,
3916
- # and `select` / `reject` / `filter_map` / `flat_map` fall through to an RBS projection that is merely
3917
- # wider, never wrong — `BlockFolding`'s filter folds decline on a non-Constant block instead of
3918
- # answering.
3919
- def find_family_floor(method_name, element_types)
4681
+ # `select` / `filter` / `reject` take a floor for the same reason. Their fall-through is not merely
4682
+ # wider: `BlockFolding`'s filter folds DO answer on a `Constant` block, and the entry-scope pin hands
4683
+ # them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2 end` answered a provably-empty `[]`
4684
+ # where Ruby answers `[2]`. Nested inside a threaded body, where a position can be typed tail-only
4685
+ # ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end` did the same. Their floor is an
4686
+ # Array of the receiver's own elements: which of them survive is undecided, and what they are is not.
4687
+ # A range receiver's elements are widened first ({#filter_family_floor}).
4688
+ #
4689
+ # Nothing else in {PER_ELEMENT_TUPLE_METHODS} takes a floor. `map`'s assembler cannot decline, and
4690
+ # `BlockFolding` never folds `filter_map` / `flat_map`, so their fall-through is the RBS `Array[U]`
4691
+ # projection.
4692
+ def undecided_fold_floor(method_name, element_types, receiver_type)
3920
4693
  case method_name
3921
4694
  when :find, :detect
3922
4695
  Type::Combinator.union(*element_types, Type::Combinator.constant_of(nil))
4696
+ when :select, :filter, :reject
4697
+ filter_family_floor(element_types, receiver_type)
3923
4698
  when :find_index, :index
3924
4699
  Type::Combinator.union(Type::Combinator.nominal_of("Integer"), Type::Combinator.constant_of(nil))
3925
4700
  end
3926
4701
  end
3927
4702
 
4703
+ # A `Constant<Range>` receiver's elements are values this walk enumerated, not ones the program wrote, and
4704
+ # the RBS answer the floor replaces carried no pin. Kept, the pins would make `(1..4).filter { … }` an
4705
+ # `Array[1 | 2 | 3 | 4]` that a later `q << 9` cannot widen (#580 leaves a precise Array without a
4706
+ # gradual arm alone), so `q.last == 9` would fold always-falsey on correct code. A Tuple receiver's pins
4707
+ # are the literal's own and stay, as the RBS projection kept them.
4708
+ def filter_family_floor(element_types, receiver_type)
4709
+ element = Type::Combinator.union(*element_types)
4710
+ element = Type::Combinator.widen_value_pinned(element) if receiver_type.is_a?(Type::Constant)
4711
+ Type::Combinator.nominal_of("Array", type_args: [element])
4712
+ end
4713
+
3928
4714
  # Evaluates the call's block once per receiver element. Two block shapes are supported:
3929
4715
  #
3930
4716
  # - `Prism::BlockNode` — a full `do … end` / `{ … }` block; the body is re-typed per position with the
@@ -3957,49 +4743,84 @@ module Rigor
3957
4743
  def per_element_body_results(block, element_types)
3958
4744
  captured = per_element_captured_bindings(block, element_types)
3959
4745
  results = lambda do
3960
- element_types.map { |element_type| type_block_body_with_param(block, [element_type], captured: captured) }
4746
+ element_types.each_with_index.map do |element_type, position|
4747
+ type_block_body_with_param(block, [element_type], captured: captured, position: position)
4748
+ end
3961
4749
  end
3962
- return results.call if element_types.size <= PER_ELEMENT_THREADING_LIMIT
3963
- return uncapped_body_floor(element_types) if unanswered_tail_dependency?(block, captured)
4750
+ return results.call unless tail_only_walk?(element_types)
4751
+ return tail_only_body_floor(element_types) if unanswered_tail_dependency?(block, captured)
3964
4752
 
3965
4753
  without_block_body_threading(&results)
3966
4754
  end
3967
4755
 
3968
- # Above {PER_ELEMENT_THREADING_LIMIT} the threading is suppressed, and a position is typed tail-only.
3969
- # For a tail that reads what the body's own prefix wrote or mutated, tail-only is not a wider answer —
3970
- # it is the ENTRY binding, which the prefix has already falsified.
4756
+ # A position is typed tail-only in two cases: above {PER_ELEMENT_THREADING_LIMIT}, where this walk
4757
+ # suppresses the threading itself, and anywhere the walk runs nested inside a body some other pass is
4758
+ # already evaluating whole. {#threaded_block_body_type}, the `next` join
4759
+ # ({#block_body_type_joining_nexts}), the break-arm collection ({#collect_break_arm_types}), the
4760
+ # captured-local fixpoint ({#captured_exit_bindings}) and an enclosing walk of this fold above the cap
4761
+ # all suppress the threading while they run, so a fold never re-enters. The second case has no arity in
4762
+ # it: `[[], []].map do |a| a << w; a end` inside a threaded `m.synchronize do w = v; … end` is typed
4763
+ # tail-only at two positions.
4764
+ def tail_only_walk?(element_types)
4765
+ element_types.size > PER_ELEMENT_THREADING_LIMIT || block_body_threading_suppressed?
4766
+ end
4767
+
4768
+ # Wherever a position is typed tail-only ({#tail_only_walk?}), a tail that reads what the body's own
4769
+ # prefix wrote or mutated does not get a wider answer. It gets the ENTRY binding, which the prefix has
4770
+ # already falsified.
3971
4771
  #
3972
4772
  # #584's cliff comment promised `Dynamic[top]` above the cap, and that held for a body-LOCAL: `[1, …,
3973
- # 9].map do v = e; v end` has no entry binding for `v`, so tail-only lands on `Dynamic[top]` by itself.
3974
- # A mutated PARAMETER has one, and issue #617 residue (2) is what it buys: `([[]] * 9).map do |a| a <<
3975
- # 1; a end` answered nine stale `[]`, a provably-empty array at every position of a result whose slots
3976
- # each hold `[1]`. Flooring the whole walk restores the promise for both shapes — the cost the cap
3977
- # refuses to pay is the per-position body evaluation, and declining to pay it means declining to know,
3978
- # not answering the pre-state.
3979
- #
3980
- # {#tail_depends_on_body_binding?} is the same predicate the threading gate uses, so "would threading
3981
- # have changed this tail" and "is tail-only untrustworthy here" stay one question. A body it answers
3982
- # false for keeps its exact tail-only fold above the cap, which is every single-statement block and
3983
- # every multi-statement block whose tail ignores its prefix.
3984
- def uncapped_body_floor(element_types)
4773
+ # 9].map do v = e; v end` has no entry binding for `v`, so tail-only lands on `Dynamic[top]` by itself,
4774
+ # which is why a body-local alone does not call for the floor. A mutated PARAMETER has one, and issue
4775
+ # #617 residue (2) is what it buys: `([[]] * 9).map do |a| a << 1; a end` answered nine stale `[]`, a
4776
+ # provably-empty array at every position of a result whose slots all hold the one array the nine `<<`
4777
+ # filled. Nested under the suppression the same body did it at two positions, and a `transform_values`
4778
+ # over `{ x: [], y: [] }` did it at every pair ({#tail_only_pairs_floored?}). Flooring every position
4779
+ # restores the promise for the positions; the assembler still decides the call from them, and a method
4780
+ # whose fall-through could read a pin again takes its own floor ({#undecided_fold_floor}). What the cap
4781
+ # and the suppression refuse to pay is the per-position body evaluation, and refusing to pay it means
4782
+ # declining to know, not answering the pre-state.
4783
+ #
4784
+ # The names come from the scan the threading gate uses ({#tail_depends_on_body_binding?}), so "would
4785
+ # threading have changed this tail" and "is tail-only untrustworthy here" stay one question. A body
4786
+ # {#unanswered_tail_dependency?} answers false for keeps its exact fold: every single-statement block,
4787
+ # and every multi-statement block whose tail ignores its prefix, reads only its own body-locals, or
4788
+ # leaves through a `next` (the last is evaluated whole, never tail-only).
4789
+ def tail_only_body_floor(element_types)
3985
4790
  Array.new(element_types.size) { Type::Combinator.untyped }
3986
4791
  end
3987
4792
 
3988
4793
  # True when the tail reads something the prefix changed that NOTHING has re-answered for this walk.
3989
4794
  #
3990
- # `captured` is the converged binding of every outer local the block rebinds ({#per_element_captured_bindings}),
3991
- # and its cost is independent of the arity, so it keeps working above the cap: `total = 0; [1, …,
3992
- # 9].map do total += e; total end` reads `total` as the fixpoint's `Integer` at every position and needs
3993
- # no floor. What the cap actually withholds is the per-position body evaluation, so the names it leaves
3994
- # unanswered are the ones the fixpoint does not cover — a mutated block PARAMETER (issue #617 residue
3995
- # (2)'s `|a| a << 1; a`) or a mutated outer local the block never rebinds.
4795
+ # `captured` is the #587 (b) any-iteration binding of every outer local and instance variable the block
4796
+ # rebinds or mutates in place ({#per_element_captured_bindings}), and its cost is independent of the
4797
+ # arity, so it keeps working above the cap: `total = 0; [1, …, 9].map do total += e; total end` reads
4798
+ # `total` as the fixpoint's `Integer` at every position and needs no floor, and `out = []; … do out << e;
4799
+ # out.size end` reads `out` as the widened `Array[Dynamic[top]]`, whose content carries a gradual arm and
4800
+ # whose arity is open. Under the nesting suppression a rebound name takes the escaping-block floor name by
4801
+ # name, so `[total, e]` keeps its `Tuple` rather than collapsing whole, and the in-place widening, which
4802
+ # evaluates no body, applies as it does anywhere. What a tail-only walk withholds is the per-position body
4803
+ # evaluation, so the names left unanswered are the ones neither binding covers — a mutated block
4804
+ # PARAMETER (issue #617 residue (2)'s `|a| a << 1; a`), and a captured local whose in-place widening
4805
+ # declined, which is left out of `captured` for exactly this reason.
4806
+ #
4807
+ # Two tails are answered without it. A name with no ENTRY binding — a body-local such as `key = k.to_s;
4808
+ # [key, n + w]` — reads as `Dynamic[top]` under tail-only by itself, which is sound, so flooring the
4809
+ # whole position would only erase the structure around it. Instance and global variables always count,
4810
+ # since the scope may still hold a pre-state for them. And a body with a block-level `next` is never
4811
+ # typed tail-only at all: {#block_body_type_joining_nexts} evaluates the whole body whatever the
4812
+ # suppression says.
3996
4813
  def unanswered_tail_dependency?(block, captured)
3997
4814
  body = block.body
3998
4815
  return false unless body.is_a?(Prism::StatementsNode)
3999
4816
  return false if body.body.size < 2
4817
+ return false if block_level_jump?(body, Prism::NextNode)
4818
+
4819
+ names = tail_dependent_body_names(body.body) - (captured&.names || EMPTY_NAME_SET)
4820
+ return false if names.empty?
4000
4821
 
4001
- answered = captured&.keys || []
4002
- tail_dependent_body_names(body.body).any? { |name| !answered.include?(name) }
4822
+ entry = BlockParameterBinder.new.bind_onto(block, scope)
4823
+ names.any? { |name| name.start_with?("@", "$") || !entry.local(name).nil? }
4003
4824
  end
4004
4825
 
4005
4826
  # Issue #587 (b) — first-iteration pinning. Every position of this fold is typed from the SAME entry
@@ -4014,12 +4835,12 @@ module Rigor
4014
4835
  # position's entry scope, so a position answers what the local can be in ANY iteration
4015
4836
  # (`[Integer, Integer]`), never what it was in the first.
4016
4837
  #
4017
- # Only the rebound names move. A position whose tail reads an untouched captured local or a block-local
4018
- # keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound counter still
4019
- # decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would have lost all
4020
- # three for nothing. The fixpoint binds the block parameter to the union of the elements, so its cost
4021
- # is independent of the arity — which is why the per-element threading cap is NOT a reason to floor: a
4022
- # ninth element keeps `Integer` where it would otherwise keep the stale `0`.
4838
+ # Only the names the body changes move. A position whose tail reads an untouched captured local or a
4839
+ # block-local keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound
4840
+ # counter still decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would
4841
+ # have lost all three for nothing. The fixpoint binds the block parameter to the union of the elements,
4842
+ # so its cost is independent of the arity — which is why the per-element threading cap is NOT a reason to
4843
+ # floor: a ninth element keeps `Integer` where it would otherwise keep the stale `0`.
4023
4844
  #
4024
4845
  # Under threading suppression — this fold nested inside another threaded body — the fixpoint's body
4025
4846
  # evaluations are exactly the re-entrant cost the suppression exists to refuse, so the names take the
@@ -4027,74 +4848,256 @@ module Rigor
4027
4848
  # fixpoint takes the same floor rather than the seed — a seed that reaches a position is the pin this
4028
4849
  # exists to remove.
4029
4850
  #
4030
- # Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds nothing captured.
4851
+ # An instance variable pins the same way — the block shares the caller's `self`, so `@t = 0; [1,
4852
+ # 2].map { @t += 1 }` folded to `[1, 1]` too — and so do a class variable and a global. Each takes the
4853
+ # same treatment under every rule above: the ones the body rebinds ({CapturedLocals.writes} with
4854
+ # `non_locals: true`, which also counts `self.w = …` as a rebind of `@w`) join the name set. They keep
4855
+ # their sigil, so the one map cannot confuse `@t` with a local `t`, and the arity-cap floor
4856
+ # ({#unanswered_tail_dependency?}), which compares names sigil-and-all, counts a rebound ivar as answered.
4857
+ #
4858
+ # A name the body rebinds where the fixpoint's pass cannot see it ({UnthreadedRebinds}) takes the floor
4859
+ # outright; see {#converged_captured_bindings}. The optimistic nil-freeness mark a pass's exit binding
4860
+ # carries is kept with the converged type ({CapturedLocals::Bindings}).
4861
+ #
4862
+ # The same pin has a CONTENT half the rebind set cannot see: a captured receiver the body mutates in place
4863
+ # is never rebound, so `h = { a: 0 }; [:a, :a].map { |k| h[k] = h[k] + 1 }` read `h` at its entry
4864
+ # contents at every position and folded to `[1, 1]` (runtime `[1, 2]`). Each such local
4865
+ # ({CapturedLocals.content_mutations}) is bound as if every mutation site in the body had already stored
4866
+ # an unknown value ({UnknownStoreWidening.widen}): `Hash[Dynamic[top] | Symbol, Dynamic[top] |
4867
+ # Integer]`, an open arity with a gradual arm on every content parameter a storing site touches. A local
4868
+ # whose widening declines (a precise nominal) gets no binding at all, so it keeps today's answer. The
4869
+ # binding evaluates no body, so it applies under threading suppression too; the rebind fixpoint runs
4870
+ # over it, and a local the body both rebinds and mutates takes the same widening over its converged
4871
+ # type — the rebind can bring a fresh literal back, which the next iteration then mutates. A local mutated
4872
+ # through an element read (`a[0] << e`) or passed to a self-call whose callee content-mutates that
4873
+ # parameter (`add_to(a, e)`) takes it too, widened as straight-line code widens it after such a site. An
4874
+ # unmutated captured local keeps its exact binding (`h = { a: 0 }; [:a, :a].map { |k| h[k] }` still folds
4875
+ # to `[0, 0]`). An instance variable the body mutates in place takes the same binding
4876
+ # ({CapturedLocals.content_mutations} with `non_locals: true`, on the rebind set's terms): once the #587
4877
+ # (a) gate threads an index write, `@cache[:first] ||= e` pins the same way. So do a class variable and a
4878
+ # global.
4879
+ #
4880
+ # The generic block-return pass lays the same binding ({#block_entry_scope}), with the fixpoint's block
4881
+ # parameters bound to the signature's instead of to the elements' union.
4882
+ #
4883
+ # The binding also marks the index `||=` sites whose slot an earlier position may have filled
4884
+ # ({RepeatedOrWrites}), so the memoizing `||=` reading (`StatementEvaluator#index_compound_write_value`)
4885
+ # does not take such a slot's lone `Dynamic` for "no evidence about the slot" and answer one position's
4886
+ # rvalue: `cache = {}; [1, 2].find { |e| (cache[:first] ||= e) == 2 }` answered `2 == 2` at the second
4887
+ # position and folded `find` to `2` where Ruby, keeping the first iteration's `1`, answers `nil`.
4888
+ #
4889
+ # Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds and mutates nothing
4890
+ # captured and holds no such site.
4031
4891
  def per_element_captured_bindings(block, element_types)
4032
- names = CapturedLocals.writes(block, scope)
4033
- return nil if names.empty?
4034
- return captured_floor(names) if block_body_threading_suppressed?
4892
+ captured_block_bindings(block, [Type::Combinator.union(*element_types)], element_types: element_types)
4893
+ end
4894
+
4895
+ # The #587 (b) binding for `block`, with the fixpoint's block parameters bound to `param_types`.
4896
+ # `element_types` is the per-element fold's positions, which {RepeatedOrWrites} reads; nil for the generic
4897
+ # block-return pass.
4898
+ def captured_block_bindings(block, param_types, element_types: nil)
4899
+ stores = CapturedLocals.content_mutations(block, scope, non_locals: true)
4900
+ names = CapturedLocals.writes(block, scope, non_locals: true)
4901
+ repeated = RepeatedOrWrites.sites(block, stores, scope, element_types: element_types)
4902
+ return nil if stores.empty? && names.empty? && repeated.empty?
4903
+
4904
+ stored = stored_capture_bindings(stores)
4905
+ types = stored.dup
4906
+ marks = {}
4907
+ unless names.empty?
4908
+ rebound_capture_bindings(block, names, param_types, stored, marks).each do |name, converged|
4909
+ types[name] = stores.key?(name) ? UnknownStoreWidening.widen(converged, stores[name]) : converged
4910
+ end
4911
+ end
4912
+ return nil if types.empty? && repeated.empty?
4035
4913
 
4036
- begin
4037
- converged_captured_bindings(block, names, element_types)
4038
- rescue StandardError
4039
- captured_floor(names)
4914
+ CapturedLocals::Bindings.new(types: types, marks: marks, repeated: repeated)
4915
+ end
4916
+
4917
+ # Only a binding the widening MOVED is recorded. One it declined (a precise nominal, a refinement under
4918
+ # `sort!`) is still the entry binding, which says nothing about later iterations; recording it would make the
4919
+ # arity-cap floor ({#unanswered_tail_dependency?}) treat the name as answered and type the tail from that
4920
+ # stale binding. Left out, the name keeps the entry binding below the cap and
4921
+ # the floor above it, which is the fold's answer without this pass.
4922
+ def stored_capture_bindings(stores)
4923
+ stores.each_with_object({}) do |(name, sites), bindings|
4924
+ seed = CapturedLocals.bound_type(scope, name)
4925
+ next if seed.nil?
4926
+
4927
+ widened = UnknownStoreWidening.widen(seed, sites)
4928
+ bindings[name] = widened unless widened == seed
4040
4929
  end
4041
4930
  end
4042
4931
 
4932
+ # `stored` is laid under every fixpoint pass, so the rebind converges over the widened contents rather
4933
+ # than the entry ones. `marks` collects the optimistic nil-freeness mark a pass's exit binding carries.
4934
+ def rebound_capture_bindings(block, names, param_types, stored, marks)
4935
+ return captured_floor(names) if block_body_threading_suppressed?
4936
+
4937
+ converged_captured_bindings(block, names, param_types, stored, marks)
4938
+ rescue StandardError
4939
+ captured_floor(names)
4940
+ end
4941
+
4043
4942
  def captured_floor(names)
4044
4943
  names.to_h { |name| [name, Type::Combinator.untyped] }
4045
4944
  end
4046
4945
 
4047
- def converged_captured_bindings(block, names, element_types)
4048
- param_types = [Type::Combinator.union(*element_types)]
4049
- seeds = names.to_h { |name| [name, scope.local(name)] }
4946
+ # A name the body rebinds somewhere the pass's exit scope never sees ({UnthreadedRebinds}) takes the floor
4947
+ # without running the fixpoint, and is bound to it under every pass, so a name that reads it converges over
4948
+ # the floor rather than over the pin: `t = s` after an unthreaded `s` rebind would otherwise converge on
4949
+ # the first iteration's `s`. The unmoved-pin test below still runs over every other name.
4950
+ def converged_captured_bindings(block, names, param_types, stored, marks)
4951
+ floored = UnthreadedRebinds.names(block, names)
4952
+ moving = names.reject { |name| floored.include?(name) }
4953
+ # Issue #1358 — each pass is an iteration, so it reads the match globals the block entry does.
4954
+ base = stored.reduce(MatchRebinding.block_entry(scope, block)) do |acc, (name, type)|
4955
+ CapturedLocals.bind(acc, name, type)
4956
+ end
4957
+ base = floored.reduce(base) { |acc, name| CapturedLocals.bind(acc, name, Type::Combinator.untyped) }
4958
+ seeds = moving.to_h { |name| [name, CapturedLocals.bound_type(base, name)] }
4959
+ moved = {}
4050
4960
  converged = BodyFixpoint.converge(
4051
- names: names,
4961
+ names: moving,
4052
4962
  seed_bindings: seeds,
4053
4963
  widen: Type::Combinator.method(:widen_value_pinned),
4054
- evaluate_body: ->(bindings) { captured_exit_bindings(block, param_types, bindings, names) }
4964
+ evaluate_body: lambda do |bindings|
4965
+ captured_exit_bindings(block, param_types, base, bindings, moved, marks)
4966
+ end
4055
4967
  )
4056
- unmoved_pins_floored(converged, seeds)
4968
+ unmoved_pins_floored(converged, seeds, moved).merge(captured_floor(floored))
4057
4969
  end
4058
4970
 
4059
4971
  # A name the write scan says this block REBINDS, whose fixpoint came back on exactly its value-pinned
4060
4972
  # seed, is floored rather than believed.
4061
4973
  #
4062
- # The fixpoint reads each pass's exit binding out of `StatementEvaluator`, and that evaluator only
4063
- # threads a write it sees as a STATEMENT: a rebind nested inside an expression — `(seen += 1) == 2` as
4064
- # the block's whole body — leaves the exit scope holding the entry binding, so the fixpoint converges on
4065
- # the seed and every position of the fold answers the first iteration again. That is issue #617 residue
4066
- # (1)'s one-liner, `seen = 0; [1, 2].find { |e| (seen += 1) == 2 }` answering `nil` where Ruby answers
4067
- # `2`: the pinned `Constant[0]` made both predicates `Constant[false]`, and `find` short-circuits on a
4068
- # provably-falsey block.
4974
+ # The fixpoint reads each pass's exit binding out of `StatementEvaluator`, and that evaluator does not
4975
+ # thread a write from every position: a rebind nested where it types a pure value leaves the exit scope
4976
+ # holding the entry binding, so the fixpoint converges on the seed and every position of the fold answers
4977
+ # the first iteration again. That is issue #617 residue (1)'s one-liner, `seen = 0; [1, 2].find { |e|
4978
+ # (seen += 1) == 2 }` answering `nil` where Ruby answers `2`: the pinned `Constant[0]` made both
4979
+ # predicates `Constant[false]`, and `find` short-circuits on a provably-falsey block. The evaluator has
4980
+ # threaded a call's receiver since #1223, but a `when` condition or a `yield` argument is still such a
4981
+ # position.
4069
4982
  #
4070
4983
  # An unmoved pin cannot be distinguished from a write that genuinely restores its own entry value
4071
4984
  # (`x = 5; xs.each { x = 5 }`), so the floor gives that shape up too. It is the far cheaper side: a
4072
4985
  # value-pinned seed the block rebinds is the exact pre-state this fold exists to stop trusting, and
4073
4986
  # `Dynamic[top]` is the same escaping-block floor {#captured_floor} already uses. Seeds that carry no
4074
- # value pinning are left alone — there is no first-iteration constant in them to remove, and widening a
4987
+ # pinning are left alone — there is no first-iteration value or shape in them to remove, and widening a
4075
4988
  # `Nominal` here would only lose a class for nothing.
4076
- def unmoved_pins_floored(converged, seeds)
4989
+ #
4990
+ # A second route reaches this test with nothing hidden from the evaluator. The fixpoint stops after
4991
+ # {BodyFixpoint::CAP} passes, so a rebind a counter guards past the third iteration (`row = [v, v] if
4992
+ # count > 3`) runs in none of them, and the widen on the capped pass erases a `Constant`'s value but leaves
4993
+ # a `Tuple` / `HashShape` as it is. `row = []` therefore came back on its seed, and `sizes.last == 2`
4994
+ # folded always-falsey on a runtime `2`, so a shape carrier counts as pinned ({#value_pinned?}). For a
4995
+ # shape the unmoved test only asks whether the seed already covers the converged type, so any rebind the
4996
+ # seed covers is floored with it, not only one restoring the entry value: `qr = n.divmod(3)` rebound to
4997
+ # another `divmod` reads `Dynamic[top]` too.
4998
+ #
4999
+ # The pin test reads a binding as a whole and nothing else. A `0 | Integer` seed is floored too, although a
5000
+ # threaded `x += 1` only joins back into it: an unthreaded write storing another class (`log(x = nil)`)
5001
+ # converges on the same seed, and only the floor keeps `x.nil?` from folding to `false`. Nor does a pass
5002
+ # whose exit binding moved prove the rebind was threaded — a narrowing (`next false unless x`) or a
5003
+ # threaded prefix (`x ||= 0`) moves it while `(x += 1) == 2` stays unthreaded.
5004
+ #
5005
+ # "Unmoved" compares against the fixpoint's own seed, but "pinned" is asked of the CALL-SITE binding. For a
5006
+ # name the body also mutates in place the seed is already the in-place widening of that binding
5007
+ # ({#stored_capture_bindings}), and the widening erases exactly the pin this test looks for: `s = +"ab"`
5008
+ # seeds `String`, not `"ab"`. Asking the seed would let a nested rebind the evaluator cannot see (`(s &&=
5009
+ # s.to_sym)` inside an expression) converge on `String` and be believed, where the local really holds a
5010
+ # Symbol from the second iteration on. For every other name the two bindings are the same. The price is
5011
+ # the trade the paragraph above already makes: a name the body mutates and VISIBLY rebinds to the widened
5012
+ # class (`t << "c"; t = t.strip`) converges on that seed as well, and is floored with the hidden case.
5013
+ #
5014
+ # A statement rebind does not buy that case back, because converging on the widened seed is not evidence
5015
+ # that the rebind was all there was. The capped pass above runs no rebind a counter guards past the third
5016
+ # iteration (`s = s.dup; n += 1; s = [e] if n > 3`), and the scans see only write nodes in the body, so a
5017
+ # lambda defined outside it (`close = -> { cur = nil }; … close.call`) or `binding.local_variable_set`
5018
+ # rebinds the local where no pass looks. Both converge on the seed next to a statement rebind, and
5019
+ # believing `String` there reports `undefined method` or an always-falsey `cur.nil?` on correct code.
5020
+ #
5021
+ # A pass's exit binding also joins every block-level `next` ({StatementEvaluator#evaluate_invocation}), and
5022
+ # a rebind on a `next` arm moves the converged binding off its seed while an unthreaded write on the
5023
+ # fall-through stays invisible: `if c; seen = 100; next false; end; (seen += 1) == 2` converged on `0 |
5024
+ # 100` and folded `find` to `nil`. So `moved` ({#captured_exit_bindings}) records which path moved each
5025
+ # name. One the FALL-THROUGH moved is believed; one only a `next` arm moved is unmoved for this test, and
5026
+ # floored with the hidden case; one neither moved is judged against its seed as before, so a block without
5027
+ # a block-level `next` is judged exactly as it always was.
5028
+ #
5029
+ # {UnthreadedRebinds} floors every name it can show is rebound out of the pass's sight before this test
5030
+ # runs, whatever the fixpoint converged to — the threaded `||=` that moves the fall-through beside an
5031
+ # unthreaded `(seen += 1) == 2` included — so this test only ever sees a name whose rebinds the scan found
5032
+ # threaded. It stays as the backstop for what the scan cannot see, and so the `0 | Integer` seed above
5033
+ # keeps its floor: skipping it for a scan-clean name would trade that backstop for precision.
5034
+ def unmoved_pins_floored(converged, seeds, moved)
4077
5035
  converged.to_h do |name, type|
4078
- seed = seeds[name]
4079
- next [name, type] unless type == seed && value_pinned?(seed)
5036
+ unmoved =
5037
+ case moved[name]
5038
+ when :fall_through then false
5039
+ when :jump then true
5040
+ else type == seeds[name]
5041
+ end
5042
+ next [name, type] unless unmoved && value_pinned?(CapturedLocals.bound_type(scope, name))
4080
5043
 
4081
5044
  [name, Type::Combinator.untyped]
4082
5045
  end
4083
5046
  end
4084
5047
 
5048
+ # A binding carries a pin when widening its values changes it, or when it is a `Tuple` / `HashShape` —
5049
+ # alone or as one member of a union. A shape carrier is pinned whatever its elements are: its arity and
5050
+ # key set are one assignment's, and the fixpoint's final widen erases a value but never a shape.
4085
5051
  def value_pinned?(type)
4086
- !type.nil? && Type::Combinator.widen_value_pinned(type) != type
5052
+ case type
5053
+ when nil then false
5054
+ when Type::Tuple, Type::HashShape then true
5055
+ when Type::Union then type.members.any? { |member| value_pinned?(member) }
5056
+ else Type::Combinator.widen_value_pinned(type) != type
5057
+ end
4087
5058
  end
4088
5059
 
4089
5060
  # One fixpoint pass: the body evaluated from `bindings` with the block parameters bound over them (the
4090
- # same layering as {#type_block_body_with_param}), returning the per-name exit binding. Threading is
4091
- # suppressed for the pass, as it is for every full body evaluation the block-return pass runs.
4092
- def captured_exit_bindings(block, param_types, bindings, names)
4093
- params = BlockParameterBinder.new(expected_param_types: param_types).bind(block)
4094
- entry = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
4095
- entry = params.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
4096
- _type, exit_scope = without_block_body_threading { entry.evaluate(block.body) }
4097
- names.to_h { |name| [name, exit_scope.local(name)] }
5061
+ # same layering as {#type_block_body_with_param}), returning the per-name exit binding — the invocation's
5062
+ # exit, which joins every block-level `next` ({StatementEvaluator#evaluate_invocation}), so a rebind on a
5063
+ # jumping path reaches the fixpoint. `moved` records, per name, whether this pass's FALL-THROUGH moved it
5064
+ # off its entry (`:fall_through`) or only the joined `next` arms did (`:jump`), for {#unmoved_pins_floored}.
5065
+ # An exit mark is recorded into `marks` and carried into later passes, since the next iteration enters on
5066
+ # that binding. Threading is suppressed for the pass, as it is for every full body evaluation the
5067
+ # block-return pass runs.
5068
+ def captured_exit_bindings(block, param_types, base, bindings, moved, marks)
5069
+ entry = bindings.reduce(base) do |acc, (name, type)|
5070
+ CapturedLocals.bind(acc, name, type, optimistic: marks[name])
5071
+ end
5072
+ entry = BlockParameterBinder.new(expected_param_types: param_types).bind_onto(block, entry)
5073
+ _type, fall_through, exit_scope = without_block_body_threading do
5074
+ StatementEvaluator.new(scope: entry).evaluate_invocation(block)
5075
+ end
5076
+ bindings.to_h do |name, entry_type|
5077
+ exit_type = CapturedLocals.bound_type(exit_scope, name)
5078
+ record_capture_move(moved, name, entry_type, fall_through, exit_type)
5079
+ mark = CapturedLocals.optimistic_mark(exit_scope, name)
5080
+ marks[name] ||= mark if mark
5081
+ [name, exit_type]
5082
+ end
5083
+ end
5084
+
5085
+ # Which path of one pass moved `name` off its `entry_type`: the fall-through outranks a `next` arm, and a
5086
+ # name the fall-through moved in any pass stays moved.
5087
+ def record_capture_move(moved, name, entry_type, fall_through, exit_type)
5088
+ if moved_off?(entry_type, CapturedLocals.bound_type(fall_through, name))
5089
+ moved[name] = :fall_through
5090
+ elsif moved_off?(entry_type, exit_type)
5091
+ moved[name] ||= :jump
5092
+ end
5093
+ end
5094
+
5095
+ # True when `after` is not already contained in `before` — the same stability test `BodyFixpoint` applies.
5096
+ def moved_off?(before, after)
5097
+ return false if after.nil?
5098
+ return true if before.nil?
5099
+
5100
+ Type::Combinator.union(before, after) != before
4098
5101
  end
4099
5102
 
4100
5103
  def per_element_symbol_results(block_arg, element_types)
@@ -4318,15 +5321,6 @@ module Rigor
4318
5321
  converged[:__inject_acc__] || seed_acc
4319
5322
  end
4320
5323
 
4321
- # `index(value)` and `find_index(value)` carry a positional argument and search by `==` rather than
4322
- # running the block. Decline so the RBS tier owns those forms.
4323
- def find_family_with_args?(call_node)
4324
- return false unless %i[find_index index].include?(call_node.name)
4325
-
4326
- args = call_node.arguments
4327
- !args.nil? && !args.arguments.empty?
4328
- end
4329
-
4330
5324
  def assemble_per_element_result(method_name, per_position, element_types)
4331
5325
  case method_name
4332
5326
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
@@ -4394,28 +5388,40 @@ module Rigor
4394
5388
  # `find` / `detect`: returns the first receiver element whose block result is Ruby-truthy, or `nil`
4395
5389
  # when no position folds to truthy.
4396
5390
  #
4397
- # Folds tightly only when every per-position block result is a `Type::Constant` — otherwise we cannot
4398
- # decide which position (if any) is "the first matching one". When the first decisive truthy position
4399
- # is found, the answer is the corresponding receiver element. When every position folds to falsey,
4400
- # the answer is `Constant[nil]`.
5391
+ # When the first decisive truthy position is found, the answer is the corresponding receiver element,
5392
+ # joined with the element of every earlier position whose result is not a `Type::Constant`: such a
5393
+ # position may match first, but the search ends at the truthy one whatever it answers, so the call
5394
+ # never returns `nil`. When every position folds to falsey, the answer is `Constant[nil]`. With no
5395
+ # decisive truthy position and some undecided one, it declines ({#undecided_fold_floor}).
4401
5396
  def assemble_find_result(per_position, element_types)
4402
- return nil unless per_position.all?(Type::Constant)
4403
-
4404
- first_truthy_index = per_position.index { |type| truthy_constant?(type) }
4405
- return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
5397
+ candidates = first_match_candidates(per_position)
5398
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
4406
5399
 
4407
- element_types[first_truthy_index]
5400
+ Type::Combinator.union(*candidates.map { |index| element_types[index] })
4408
5401
  end
4409
5402
 
4410
5403
  # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
4411
- # nothing matches.
5404
+ # nothing matches, on {#assemble_find_result}'s terms.
4412
5405
  def assemble_find_index_result(per_position)
4413
- return nil unless per_position.all?(Type::Constant)
5406
+ candidates = first_match_candidates(per_position)
5407
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
5408
+
5409
+ Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
5410
+ end
5411
+
5412
+ NO_MATCH = Type::Combinator.constant_of(nil)
5413
+ private_constant :NO_MATCH
4414
5414
 
5415
+ # The positions `find` may stop at — the first decisive truthy one and every undecided one before it —
5416
+ # `NO_MATCH` when every position folds falsey, or nil when an undecided position has no truthy one after it.
5417
+ def first_match_candidates(per_position)
4415
5418
  first_truthy_index = per_position.index { |type| truthy_constant?(type) }
4416
- return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
5419
+ if first_truthy_index.nil?
5420
+ return per_position.all?(Type::Constant) ? NO_MATCH : nil
5421
+ end
4417
5422
 
4418
- Type::Combinator.constant_of(first_truthy_index)
5423
+ undecided = (0...first_truthy_index).reject { |index| per_position[index].is_a?(Type::Constant) }
5424
+ undecided + [first_truthy_index]
4419
5425
  end
4420
5426
 
4421
5427
  def truthy_constant?(type)
@@ -4436,28 +5442,47 @@ module Rigor
4436
5442
  # `Constant[Symbol | String]` — otherwise the tier declines (the new key cannot be used as a static
4437
5443
  # HashShape index). Collisions (two old keys mapping to the same new key) also decline.
4438
5444
  #
5445
+ # Two runtime behaviours fall outside this model, and both decline:
5446
+ #
5447
+ # - A call with arguments. `transform_keys(mapping)` renames the keys `mapping` names and yields only
5448
+ # the rest to the block, and the fold never reads the mapping. `transform_values` takes none.
5449
+ # - An in-place form whose block could see the receiver ({ReceiverBlindBlock}). The bang forms rewrite
5450
+ # the receiver pair by pair, so the block can see pairs it has already rewritten, while the fold types
5451
+ # every pair against the pre-call shape. The non-bang forms build a new hash and keep the exact fold.
5452
+ #
4439
5453
  # Returns `nil` on any decline so the dispatcher falls through to `RbsDispatch` and gets the widened
4440
5454
  # `Hash[K, V]` answer.
4441
5455
  def try_hash_shape_block_fold(call_node, receiver_type)
4442
- return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
4443
- return nil unless receiver_type.is_a?(Type::HashShape)
4444
- return nil unless receiver_type.closed?
4445
- return nil unless receiver_type.optional_keys.empty?
5456
+ return nil unless hash_shape_fold_applicable?(call_node, receiver_type)
4446
5457
 
4447
5458
  block_arg = call_node.block
4448
- return nil if block_arg.nil?
5459
+ return nil if in_place_block_sees_receiver?(call_node.name, block_arg)
4449
5460
 
4450
5461
  if %i[transform_values transform_values!].include?(call_node.name)
4451
5462
  fold_hash_shape_transform_values(receiver_type, block_arg)
4452
5463
  else
4453
- fold_hash_shape_transform_keys(receiver_type, block_arg)
5464
+ fold_hash_shape_transform_keys(receiver_type, block_arg, in_place: call_node.name == :transform_keys!)
4454
5465
  end
4455
5466
  end
4456
5467
 
5468
+ def hash_shape_fold_applicable?(call_node, receiver_type)
5469
+ HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name) &&
5470
+ receiver_type.is_a?(Type::HashShape) && receiver_type.closed? && receiver_type.optional_keys.empty? &&
5471
+ call_node.arguments.nil? && !call_node.block.nil?
5472
+ end
5473
+
5474
+ def in_place_block_sees_receiver?(method_name, block_arg)
5475
+ IN_PLACE_HASH_SHAPE_TRANSFORMS.include?(method_name) &&
5476
+ block_arg.is_a?(Prism::BlockNode) && !ReceiverBlindBlock.blind?(block_arg, scope)
5477
+ end
5478
+
4457
5479
  def fold_hash_shape_transform_values(shape, block_arg)
5480
+ captured = hash_block_captured_bindings(block_arg, shape.pairs.values)
5481
+ return hash_shape_values_floor(shape) if tail_only_pairs_floored?(shape, block_arg, captured)
5482
+
4458
5483
  new_pairs = {}
4459
- shape.pairs.each do |key, value|
4460
- new_value = apply_hash_block(block_arg, value)
5484
+ shape.pairs.each_with_index do |(key, value), position|
5485
+ new_value = apply_hash_block(block_arg, value, captured: captured, position: position)
4461
5486
  return nil if new_value.nil?
4462
5487
 
4463
5488
  new_pairs[key] = new_value
@@ -4465,28 +5490,125 @@ module Rigor
4465
5490
  Type::Combinator.hash_shape_of(new_pairs)
4466
5491
  end
4467
5492
 
4468
- def fold_hash_shape_transform_keys(shape, block_arg)
4469
- new_pairs = {}
4470
- shape.pairs.each do |key, value|
4471
- key_type = Type::Combinator.constant_of(key)
4472
- new_key_type = apply_hash_block(block_arg, key_type)
4473
- return nil unless new_key_type.is_a?(Type::Constant)
5493
+ def fold_hash_shape_transform_keys(shape, block_arg, in_place: false)
5494
+ key_types = shape.pairs.keys.map { |key| Type::Combinator.constant_of(key) }
5495
+ captured = hash_block_captured_bindings(block_arg, key_types)
5496
+ return hash_keys_floor(shape) if tail_only_pairs_floored?(shape, block_arg, captured)
4474
5497
 
4475
- new_key = new_key_type.value
4476
- return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
4477
- return nil if new_pairs.key?(new_key)
5498
+ new_pairs = {}
5499
+ key_types.zip(shape.pairs.values).each_with_index do |(key_type, value), position|
5500
+ typed_key = apply_hash_block(block_arg, key_type, captured: captured, position: position)
5501
+ new_key = new_shape_key(typed_key, new_pairs)
5502
+ return undecided_keys_floor(shape, block_arg, key_types, captured) if new_key.nil?
4478
5503
 
4479
5504
  new_pairs[new_key] = value
4480
5505
  end
4481
- Type::Combinator.hash_shape_of(new_pairs)
5506
+ Type::Combinator.hash_shape_of(in_place ? in_place_key_order(shape.pairs, new_pairs) : new_pairs)
5507
+ end
5508
+
5509
+ # `transform_keys!` rewrites the receiver in place, pair by pair, so its pairs end in a different order from
5510
+ # the new hash `transform_keys` builds. For each old pair, CRuby's `rb_hash_transform_keys_bang` deletes the
5511
+ # old key unless an earlier pair already produced it as a new key, then stores the new key: in place when the
5512
+ # new key is an old key not yet reached, appended otherwise. `{ a: 1, b: 2, c: 3 }.transform_keys! { |k| k ==
5513
+ # :b ? :c : (k == :c ? :b : k) }` is `{ c: 2, a: 1, b: 3 }`, not `{ a: 1, c: 2, b: 3 }`, and `r.keys.first ==
5514
+ # :a` folded always-truthy on the wrong order. Replaying those steps over a Ruby `Hash`, which orders its keys
5515
+ # the same way, gives the same order. `new_pairs` is collision-free and in the old pairs' order, so every
5516
+ # old key is either deleted or overwritten, and no old value survives.
5517
+ def in_place_key_order(old_pairs, new_pairs)
5518
+ produced = {}
5519
+ old_pairs.keys.zip(new_pairs).each_with_object(old_pairs.dup) do |(old_key, (new_key, value)), result|
5520
+ result.delete(old_key) unless produced.key?(old_key)
5521
+ result[new_key] = value
5522
+ produced[new_key] = true
5523
+ end
5524
+ end
5525
+
5526
+ # The key a pair's block result can index the new `HashShape` with — a `Constant` Symbol or String that no
5527
+ # earlier pair already took — or nil.
5528
+ def new_shape_key(new_key_type, new_pairs)
5529
+ return nil unless new_key_type.is_a?(Type::Constant)
5530
+
5531
+ new_key = new_key_type.value
5532
+ return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
5533
+
5534
+ new_pairs.key?(new_key) ? nil : new_key
5535
+ end
5536
+
5537
+ # A key fold that cannot build a `HashShape` declines to the dispatcher — except under the suppression, where
5538
+ # the dispatcher's block-return pass is typed tail-only too and read a captured value the body mutates in
5539
+ # place at its entry contents: `buf = +"k"; … { a: 1 }.transform_keys { |k| buf << w.to_s; buf }` answered
5540
+ # `Hash["k", 1]` for `{ "k1" => 1 }`, though the pairs themselves were typed over the #587 (b) binding that
5541
+ # answers `buf`. There the fold answers from the keys it typed, `Hash[union(<new keys>), union(<values>)]`,
5542
+ # and takes the plain floor ({#hash_keys_floor}) when a pair could not be typed at all. The pairs are
5543
+ # re-typed for it, which only a suppressed, undecided key fold pays. That pass now re-answers such a name
5544
+ # itself ({#prefix_answered_scope}), so a decline would no longer read `"k"`; the fold still answers from
5545
+ # the pairs it has typed.
5546
+ def undecided_keys_floor(shape, block_arg, key_types, captured)
5547
+ return nil unless block_body_threading_suppressed?
5548
+
5549
+ new_key_types = key_types.each_with_index.map do |key_type, position|
5550
+ apply_hash_block(block_arg, key_type, captured: captured, position: position)
5551
+ end
5552
+ return hash_keys_floor(shape) if new_key_types.any?(&:nil?)
5553
+
5554
+ Type::Combinator.nominal_of(
5555
+ "Hash", type_args: [Type::Combinator.union(*new_key_types), Type::Combinator.union(*shape.pairs.values)]
5556
+ )
5557
+ end
5558
+
5559
+ # The per-pair twin of issue #587 (b)'s first-iteration pin. Every pair is typed from the SAME entry scope,
5560
+ # so a body that rebinds a captured outer local answered the first pair's value at every pair: `total = 0;
5561
+ # { x: 1, y: 2 }.transform_values { total += 1 }` folded to `{ x: 1, y: 1 }` (runtime `{ x: 1, y: 2 }`),
5562
+ # and `r[:y] == 1` then fired always-truthy on correct code. The pairs take the per-element fold's
5563
+ # captured-local entry binding ({#per_element_captured_bindings}) — the fixpoint's block parameter bound
5564
+ # to the union of the values, or of the `Constant` keys — rather than a copy of it: issue #1198 is where
5565
+ # the block-entry models consolidate. A captured local the body does not change keeps its exact per-pair
5566
+ # fold, and a `&:symbol` block captures nothing.
5567
+ #
5568
+ # An empty shape has no pair to type, so it does not pay for the fixpoint.
5569
+ def hash_block_captured_bindings(block_arg, param_types)
5570
+ return nil unless block_arg.is_a?(Prism::BlockNode)
5571
+ return nil if param_types.empty?
5572
+
5573
+ per_element_captured_bindings(block_arg, param_types)
5574
+ end
5575
+
5576
+ # The per-pair fold has no arity cap, so running nested inside a body another pass is evaluating whole
5577
+ # ({#tail_only_walk?}'s second case) is the one way it reaches a tail-only pair the dependency predicate
5578
+ # can flag, and that pair holds the same pre-state {#tail_only_body_floor} describes:
5579
+ # `{ x: [], y: [] }.transform_values do |a| a << w; a end` inside a threaded `m.synchronize do w = v; …
5580
+ # end` answered `{ x: [], y: [] }` for a hash whose values each hold `[1]`, and `r[:x].first + 1` was
5581
+ # then reported on correct code. The floor is the Tuple fold's, applied per pair and over the same
5582
+ # `captured` names, with the fold answering it rather than declining: a decline reached the dispatcher,
5583
+ # whose block-return pass the same suppression typed tail-only, so `k = "#{k}#{w}"; k` read its keys
5584
+ # as the entry `:a | :b` until that pass re-answered the stale name itself ({#prefix_answered_scope}).
5585
+ # An empty shape types no pair, so it keeps its exact `{}`.
5586
+ def tail_only_pairs_floored?(shape, block_arg, captured)
5587
+ !shape.pairs.empty? && block_arg.is_a?(Prism::BlockNode) && block_body_threading_suppressed? &&
5588
+ unanswered_tail_dependency?(block_arg, captured)
5589
+ end
5590
+
5591
+ # `transform_values` keeps the keys and answers every value `Dynamic[top]`.
5592
+ def hash_shape_values_floor(shape)
5593
+ Type::Combinator.hash_shape_of(shape.pairs.transform_values { Type::Combinator.untyped })
5594
+ end
5595
+
5596
+ # `transform_keys` keeps the values, but a `Dynamic[top]` key cannot index a `HashShape`, and two keys may
5597
+ # collide, so the floor is the plain `Hash` over them.
5598
+ def hash_keys_floor(shape)
5599
+ Type::Combinator.nominal_of(
5600
+ "Hash", type_args: [Type::Combinator.untyped, Type::Combinator.union(*shape.pairs.values)]
5601
+ )
4482
5602
  end
4483
5603
 
4484
5604
  # Applies a single-argument block (either a full BlockNode or a `&:symbol` BlockArgumentNode) to
4485
- # `param_type` and returns the resulting type, or `nil` on failure.
4486
- def apply_hash_block(block_arg, param_type)
5605
+ # `param_type` and returns the resulting type, or `nil` on failure. `captured:` is the pair-independent
5606
+ # entry binding from {#hash_block_captured_bindings}, and `position:` the pair's index, which picks the
5607
+ # index `||=` sites that binding marks there.
5608
+ def apply_hash_block(block_arg, param_type, captured: nil, position: nil)
4487
5609
  case block_arg
4488
5610
  when Prism::BlockNode
4489
- type_block_body_with_param(block_arg, [param_type])
5611
+ type_block_body_with_param(block_arg, [param_type], captured: captured, position: position)
4490
5612
  when Prism::BlockArgumentNode
4491
5613
  expression = block_arg.expression
4492
5614
  return nil unless expression.is_a?(Prism::SymbolNode)
@@ -4503,13 +5625,18 @@ module Rigor
4503
5625
  end
4504
5626
  end
4505
5627
 
4506
- # `captured:` — issue #587 (b): the per-name entry binding of every captured outer local the body rebinds
5628
+ # `captured:` — issue #587 (b): the per-name entry binding of every captured outer local and instance
5629
+ # variable the body rebinds, and of every captured local it mutates in place
4507
5630
  # ({#per_element_captured_bindings}), laid under the parameter bindings so a parameter still shadows.
4508
- def type_block_body_with_param(block_node, expected_param_types, captured: nil)
4509
- bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
4510
- block_scope = (captured || {}).reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
4511
- block_scope = bindings.reduce(block_scope) { |acc, (name, type)| acc.with_local(name, type) }
4512
- type_block_body(block_node, block_scope)
5631
+ # `position:` is the element's index in the fold; the binding marks the index `||=` sites an earlier
5632
+ # position may have filled there ({RepeatedOrWrites::Marks}).
5633
+ def type_block_body_with_param(block_node, expected_param_types, captured: nil, position: nil)
5634
+ # Issue #1358 — a position past the first runs after an earlier one may have rebound the match globals.
5635
+ entry = MatchRebinding.block_entry(scope, block_node)
5636
+ block_scope = captured ? captured.lay(entry, position: position) : entry
5637
+ block_scope = BlockParameterBinder.new(expected_param_types: expected_param_types)
5638
+ .bind_onto(block_node, block_scope)
5639
+ type_block_body(block_node, block_scope, captured: captured)
4513
5640
  rescue StandardError
4514
5641
  nil
4515
5642
  end