rigortype 0.3.9 → 0.4.0

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 (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  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/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -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)
1350
1549
 
1351
- try_user_method_inference(receiver, node, arg_types) || dynamic_top
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
1560
+
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)
1872
+
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)
1622
1877
 
1623
- Type::Combinator.union(result, *arms)
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
@@ -2144,26 +2426,49 @@ module Rigor
2144
2426
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
2145
2427
  private_constant :CLASS_GRAPH_CACHE_KEY
2146
2428
 
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.)
2429
+ # Memo for the static class-graph resolvers below. They are pure functions of the scope's *frozen*
2430
+ # discovery tables — `user_def_for` / `superclass_of` / `includes_of` and the ancestor-name resolver
2431
+ # read nothing else, and never touch the current scope's locals or narrowings — so a result computed
2432
+ # for one `(class, method)` is valid for every `Scope` carrying the same {DiscoveryIndex}.
2433
+ #
2434
+ # The key is that index's identity, NOT the three tables the walk is usually described by. Five
2435
+ # tables are in play: `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`,
2436
+ # plus `discovered_header_nestings` and `discovered_methods`, which {Scope#ancestor_name_candidates}
2437
+ # and {Scope#known_user_class?} read when resolving an ancestor name to a project class. Keying on
2438
+ # the trio alone would let a `discovery.with(discovered_methods: …)` that happens to share the trio
2439
+ # serve a name resolution computed against the OLD table. The index that owns all five is one
2440
+ # object, so keying on it is both cheaper to check and correct by construction — and stays correct
2441
+ # when a sixth table joins the walk.
2442
+ #
2443
+ # ONE slot, replaced rather than accumulated — the same shape, and now the same key, as
2444
+ # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. `ExpressionTyper` is rebuilt per
2445
+ # `Scope#type_of`, so the slot lives on `Thread.current` rather than on `self`. A `Scope` merges its
2446
+ # file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
2447
+ # identity-keyed *store* grew one bucket per file and, because the keys ARE the tables, pinned every
2448
+ # file's whole discovery index for the length of the run. What that bought, measured over
2449
+ # `lib`+`plugins`: 552 buckets holding 1,834 entries between them, and 58 of 80,106 calls answered
2450
+ # across files — 149 of the 701 slot switches returned to a bucket seen before, all of them in the
2451
+ # seed phases that revisit the project index. One slot gives those 58 answers up and allocates 706
2452
+ # buckets instead of 552, and still allocates FEWER objects over `lib` than the store did (−368),
2453
+ # because one `equal?` is cheaper than three identity-hash lookups.
2454
+ #
2455
+ # Steady-state cost is one `equal?` check and zero allocation; the array and the buckets are
2456
+ # allocated once per index. (Pool mode forks per worker, so the `Thread.current` slot is
2457
+ # process-local and never crosses a project boundary — and note that the parent's reported
2458
+ # `Memory peak` therefore does not see a worker's share of this at all.)
2158
2459
  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 }
2460
+ discovery = scope.discovery
2461
+ slot = Thread.current[CLASS_GRAPH_CACHE_KEY]
2462
+ unless slot && slot[0].equal?(discovery)
2463
+ # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
2464
+ # is a pure function of the same frozen index — the sibling resolver it walks reads nothing else.
2465
+ # `singleton_def` is added lazily by {#singleton_def_through_ancestors}'s caller.
2466
+ slot = [discovery,
2467
+ { name: {}, user_def: {}, self_pure: {}.compare_by_identity,
2468
+ yields: {}.compare_by_identity }]
2469
+ Thread.current[CLASS_GRAPH_CACHE_KEY] = slot
2470
+ end
2471
+ slot[1]
2167
2472
  end
2168
2473
 
2169
2474
  def resolve_user_def_through_ancestors(class_name, method_name)
@@ -2222,16 +2527,40 @@ module Rigor
2222
2527
  OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
2223
2528
  private_constant :OVERRIDE_GATE_CACHE_KEY
2224
2529
 
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.
2530
+ # Memo for {#overridden_in_project?}, nested `kind → owner → method_name`. The predicate is a pure
2531
+ # function of the scope's frozen discovery tables, so that index's *identity* is what says whether a
2532
+ # bucket still applies. Nesting under `kind` and `owner` avoids allocating a composite cache key on
2533
+ # the hot path (the gate runs on every adopted self-call return), so a steady-state hit is one
2534
+ # `equal?` check and three keyed hash reads with zero allocation.
2535
+ #
2536
+ # The key is the whole {Scope::DiscoveryIndex}, NOT the `discovered_def_nodes` /
2537
+ # `discovered_superclasses` / `discovered_includes` trio the walk is usually described by — the same
2538
+ # key, for the same reason, as {#class_graph_buckets}. {#related_to_owner?} reaches
2539
+ # {Scope#ancestor_name_candidates} and {Scope#known_user_class?}, which read
2540
+ # `discovered_header_nestings` and `discovered_methods` as well, so a trio key would let an index
2541
+ # that swapped only one of those serve an answer computed against the old table. Over `lib`+`plugins`
2542
+ # the two keys switch the same 113 times, so correctness here is free.
2543
+ #
2544
+ # ONE slot, replaced rather than accumulated — the same shape as
2545
+ # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. A `Scope` merges its file's discovery with the
2546
+ # project pre-pass, so every analysed file gets a fresh index: an identity-keyed *store* grew one
2547
+ # bucket per file and, because the key IS the index, pinned every file's whole discovery index for the
2548
+ # length of the run — 109 live buckets over `lib`+`plugins`, one per distinct trio at every key level,
2549
+ # holding 418 answers between them (211 instance, 207 singleton).
2550
+ #
2551
+ # What the store bought for that was one bucket's worth of cross-file reuse, not 109: only the
2552
+ # project-seed scope recurs, because {Analysis::Runner} types each file's pre-passes under it before
2553
+ # {ScopeIndexer} merges the file's own discovery in. A bucket is two small hashes, so giving that up
2554
+ # is free — unlike {#method_definers_index} below, whose bucket costs a full table scan and which is
2555
+ # keyed and bounded differently for exactly that reason.
2230
2556
  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: {} }
2557
+ discovery = scope.discovery
2558
+ slot = Thread.current[OVERRIDE_GATE_CACHE_KEY]
2559
+ unless slot && slot[0].equal?(discovery)
2560
+ slot = [discovery, { instance: {}, singleton: {} }]
2561
+ Thread.current[OVERRIDE_GATE_CACHE_KEY] = slot
2562
+ end
2563
+ slot[1]
2235
2564
  end
2236
2565
 
2237
2566
  # True when some discovered project class/module — distinct from `owner` — redefines `(method_name,
@@ -2271,14 +2600,65 @@ module Rigor
2271
2600
  METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
2272
2601
  private_constant :METHOD_DEFINERS_INDEX_KEY
2273
2602
 
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.
2603
+ # The second way. Two flat thread slots rather than one slot holding an array of ways: a
2604
+ # `Thread.current[KEY] ||= […]` seeds the store with an array LITERAL, which folds to a tuple whose
2605
+ # elements are pinned, and the engine then reads the `way[0].equal?(…)` guards below as always-falsey
2606
+ # (`make check` fires `flow.always-truthy-condition` on this very file). Two keys read as two plain
2607
+ # `Thread.current` reads and keep the guards analysable.
2608
+ METHOD_DEFINERS_INDEX_ALT_KEY = :__rigor_method_definers_index_alt__
2609
+ private_constant :METHOD_DEFINERS_INDEX_ALT_KEY
2610
+
2611
+ # `method_name (Symbol) → [owner names]` inverted index over the instance / singleton def tables,
2612
+ # valid for exactly as long as the table it inverts: a new analysis generation, or a `Scope` that swaps
2613
+ # its index through {Scope#with_discovery}, needs a fresh one. The toplevel sentinel is excluded — a
2614
+ # toplevel `def` has no class ancestry and so can never be an override.
2615
+ #
2616
+ # Bounded to TWO slots, replaced rather than accumulated. This memo is the costliest member of the
2617
+ # per-file-store family, because it does not hold a handful of resolved answers but a whole inverted
2618
+ # index built over a file's merged def table: an identity-keyed *store* held 135 of them over
2619
+ # `lib`+`plugins` — 744,431 index rows, 50.0 MB in the index hashes and their owner-name arrays alone,
2620
+ # on top of the def tables the keys pinned.
2621
+ #
2622
+ # Two, and not the one slot the cheaper memos in this family use, because the tables ALTERNATE.
2623
+ # {Analysis::Runner} types each file's pre-passes under the project-seed scope and its main pass under
2624
+ # the file's merged tables. Where a file reaches this gate from BOTH phases the request sequence is
2625
+ # `seed, file_1, seed, file_2, …`, and a single slot evicts the seed's index once per file and
2626
+ # rebuilds it on the next — a full scan of the seed's whole def table, every file. 200 synthetic
2627
+ # files shaped that way measured +22.6 % allocations against the store, which a bounded slot must not
2628
+ # cost. Two ways — a most-recently-used slot and one alternate, swapped on a hit in the alternate —
2629
+ # cover that period-2 shape exactly: the same 200 files rebuild 201 times, matching the store.
2630
+ #
2631
+ # Wider rotations are not covered, and do not need to be. On `lib`+`plugins` only 6 of the 109 gating
2632
+ # files consult the seed's table, so its uses are separated by many other files' tables and fall out
2633
+ # of both ways: five rebuilds of a 700-entry table remain, about 4 ms. Retention is bounded at two
2634
+ # generations either way, which is what the store failed to do.
2635
+ #
2636
+ # A way is `[def_nodes, singleton_def_nodes, instance_index, singleton_index]`, so it keys on BOTH def
2637
+ # tables and a gate call that alternates instance and singleton kinds within one file does not rebuild
2638
+ # either. Each index is still built lazily: a run that never asks a singleton question never pays for
2639
+ # the singleton index.
2640
+ #
2641
+ # DELIBERATELY not keyed on the whole {Scope::DiscoveryIndex}, unlike {#class_graph_buckets} and
2642
+ # {#override_gate_buckets} above. {#build_method_definers_index} reads the ONE table it is handed and
2643
+ # nothing else, so the def tables are already the complete key, and the index object is a strictly
2644
+ # narrower one: the project-seed scope carries a fresh index per file while its def tables stay the
2645
+ # same object. Over the 200 synthetic files above, an index-keyed memo misses all 400 times where a
2646
+ # def-table-keyed one misses 201 — it would rebuild the seed's 934-entry table once per file and hand
2647
+ # back the +22.6 % this bound exists to avoid. Check that number before widening this key to match
2648
+ # its siblings.
2278
2649
  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)
2650
+ def_nodes = scope.discovered_def_nodes
2651
+ singleton_def_nodes = scope.discovered_singleton_def_nodes
2652
+ way = Thread.current[METHOD_DEFINERS_INDEX_KEY]
2653
+ unless way && way[0].equal?(def_nodes) && way[1].equal?(singleton_def_nodes)
2654
+ alt = Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY]
2655
+ alt = nil unless alt && alt[0].equal?(def_nodes) && alt[1].equal?(singleton_def_nodes)
2656
+ Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY] = way
2657
+ way = alt || [def_nodes, singleton_def_nodes, nil, nil]
2658
+ Thread.current[METHOD_DEFINERS_INDEX_KEY] = way
2659
+ end
2660
+ singleton = kind == :singleton
2661
+ way[singleton ? 3 : 2] ||= build_method_definers_index(singleton ? singleton_def_nodes : def_nodes)
2282
2662
  end
2283
2663
 
2284
2664
  def build_method_definers_index(table)
@@ -2541,7 +2921,7 @@ module Rigor
2541
2921
  # below reads it back from here rather than taking a second parameter, so key and frame cannot
2542
2922
  # disagree). It is installed even when nil, which is what stops a blockless callee reached from
2543
2923
  # inside a yielding body from inheriting the outer caller's block.
2544
- ExpressionTyper.with_yield_value_type(yield_type) do
2924
+ ExpressionTyper.with_call_site_frame(yield_type, forwarded_frame_types(def_node, arg_types)) do
2545
2925
  unless memo_candidate?(stack, plain_signature)
2546
2926
  trace_memo_refusal(stack, plain_signature)
2547
2927
  next compute_user_method_return(def_node, body_scope, stack, summaries,
@@ -2770,6 +3150,28 @@ module Rigor
2770
3150
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2771
3151
  end
2772
3152
 
3153
+ # Issue #1125 — the argument list a `def m(...)` body's `...` re-supplies, or nil for a def without the
3154
+ # forwarding parameter (whose body cannot contain a `...` call at all) and for a call that does not even
3155
+ # satisfy `m`'s own required positionals (that call raises at runtime; the body is not worth re-typing).
3156
+ #
3157
+ # Ruby allows `...` only beside leading positional parameters (`def m(a, ...)`, `def m(a = 1, ...)`) — a
3158
+ # keyword / rest / block parameter next to it is a syntax error — so the tail is the call's own argument
3159
+ # list MINUS the leading positionals those named parameters consume, keeping a trailing keyword shape
3160
+ # (which `takes_keywords?` reads as the keyword tail rather than as a positional). The callee's own
3161
+ # call-site `arg_types` are what remains.
3162
+ def forwarded_frame_types(def_node, arg_types)
3163
+ params = def_node.parameters
3164
+ return nil unless params.is_a?(Prism::ParametersNode)
3165
+ return nil unless params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3166
+
3167
+ positional = arg_types.dup
3168
+ kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
3169
+ return nil if positional.size < params.requireds.size
3170
+
3171
+ tail = positional[(params.requireds.size + params.optionals.size)..] || []
3172
+ kw_shape ? tail + [kw_shape] : tail
3173
+ end
3174
+
2773
3175
  # True when this frame's result is a candidate for the return memo: the one structural precondition,
2774
3176
  # stable across the body walk, that is necessary (but not sufficient) for a FINAL result — this plain
2775
3177
  # signature must not itself be on the recursion guard stack (else we are inside its own cycle,
@@ -3198,7 +3600,8 @@ module Rigor
3198
3600
  locals = bind_params_from_call_types(params, arg_types)
3199
3601
  return nil if locals.nil?
3200
3602
 
3201
- # Construct the body scope in a SINGLE allocation — the previous `Scope.empty.with_*.with_*…` chain
3603
+ # Construct the body scope in a SINGLE Scope allocation (plus the issue #1358 frame it carries, whose
3604
+ # scans run only on demand) — the previous `Scope.empty.with_*.with_*…` chain
3202
3605
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
3203
3606
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
3204
3607
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
@@ -3216,7 +3619,9 @@ module Rigor
3216
3619
  lexical_nesting: recorded_def_nesting(def_node),
3217
3620
  discovery: scope.discovery,
3218
3621
  struct_fold_safe_locals: body_fold_safe_locals(def_node, receiver, self_fold_safe),
3219
- dynamic_origins: scope.dynamic_origins
3622
+ dynamic_origins: scope.dynamic_origins,
3623
+ # Issue #1358 — the callee runs in a frame of its own ({Scope#with_match_frame}).
3624
+ match_frame: MatchRebinding::Frame.new(def_node.body, def_node.parameters)
3220
3625
  )
3221
3626
  end
3222
3627
 
@@ -3296,17 +3701,35 @@ module Rigor
3296
3701
  return nil if locals.nil?
3297
3702
 
3298
3703
  bind_keyword_params(params, kw_shape, locals)
3299
- locals[params.keyword_rest.name.to_sym] = dynamic_top if params.keyword_rest&.name
3704
+ rest_name = keyword_rest_name(params)
3705
+ locals[rest_name.to_sym] = keyword_rest_type(params, kw_shape) if rest_name
3300
3706
  locals[params.block.name.to_sym] = dynamic_top if params.block&.name
3301
3707
  locals
3302
3708
  end
3303
3709
 
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.
3710
+ # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence and stay
3711
+ # declined — correspondence, not width, is the issue. Issue #1125: `def m(...)`'s forwarding slot is no
3712
+ # longer a decline. Its NAMED parameters (a leading `def m(a, ...)` required) bind exactly as before;
3713
+ # the forwarded tail is not a binding, so nothing is bound for it — the body's own `f(...)` reads the
3714
+ # frame's argument list instead ({#forwarded_argument_types}).
3306
3715
  def bindable_param_shape?(params)
3307
- params.is_a?(Prism::ParametersNode) &&
3308
- params.posts.empty? &&
3309
- !params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3716
+ params.is_a?(Prism::ParametersNode) && params.posts.empty?
3717
+ end
3718
+
3719
+ # Whether the def's trailing slot is `...` rather than a named `*rest` / `**rest`. Such a def accepts
3720
+ # an unbounded positional tail it does not name, so it behaves as a rest for the positional
3721
+ # correspondence and binds no local.
3722
+ def forwarding_params?(params)
3723
+ params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
3724
+ end
3725
+
3726
+ # A `**rest` parameter's name, or nil when the slot is `def m(...)`'s forwarding parameter —
3727
+ # `Prism::ForwardingParameterNode` carries no name at all.
3728
+ def keyword_rest_name(params)
3729
+ rest = params.keyword_rest
3730
+ return nil unless rest.respond_to?(:name)
3731
+
3732
+ rest.name
3310
3733
  end
3311
3734
 
3312
3735
  def takes_keywords?(params)
@@ -3317,7 +3740,9 @@ module Rigor
3317
3740
  requireds = params.requireds
3318
3741
  optionals = params.optionals
3319
3742
  return nil if positional.size < requireds.size
3320
- return nil if params.rest.nil? && positional.size > requireds.size + optionals.size
3743
+
3744
+ extra = positional.size - requireds.size - optionals.size
3745
+ return nil if extra.positive? && params.rest.nil? && !forwarding_params?(params)
3321
3746
 
3322
3747
  locals = {}
3323
3748
  requireds.each_with_index { |param, index| bind_positional_param(locals, param, positional[index]) }
@@ -3361,6 +3786,23 @@ module Rigor
3361
3786
  param.respond_to?(:value) && param.value ? literal_default_type(param.value) : dynamic_top
3362
3787
  end
3363
3788
 
3789
+ # Issue #1125 — `**rest` collects the keyword-shape entries no named keyword parameter consumed, as a
3790
+ # closed `HashShape` of their value types. The pre-#1125 `Dynamic[top]` is kept whenever there is
3791
+ # nothing left to collect: no keyword shape at all, an open shape (unknown extras — every remaining-key
3792
+ # answer would be a guess), or a shape whose every pair a named parameter consumed. That last case is
3793
+ # what leaves the literal form's existing binding untouched (`target(a: 1, b: 2)` against
3794
+ # `def target(a:, b:, **rest)` still binds `rest` to `Dynamic[top]`), while a caller that DOES leave
3795
+ # keys over — the only shape where the parameter holds something to report — gets them typed.
3796
+ def keyword_rest_type(params, kw_shape)
3797
+ return dynamic_top if kw_shape.nil? || kw_shape.open?
3798
+
3799
+ consumed = params.keywords.map { |param| param.name.to_s.delete_suffix(":").to_sym }
3800
+ leftovers = kw_shape.pairs.except(*consumed)
3801
+ return dynamic_top if leftovers.empty?
3802
+
3803
+ Type::Combinator.hash_shape_of(leftovers)
3804
+ end
3805
+
3364
3806
  # A default expression contributes its type only when it is lexically scope-free — a scalar literal
3365
3807
  # or an EMPTY collection literal (`options = {}` is the dominant Rails idiom). Anything that could
3366
3808
  # read the def's own lexical scope binds `Dynamic` instead of being mis-typed in the caller's scope.
@@ -3405,11 +3847,60 @@ module Rigor
3405
3847
  scope.environment.nominal_for_name("Object") || dynamic_top
3406
3848
  end
3407
3849
 
3850
+ # Issue #1125 — two call-site argument shapes that used to reach every consumer (the parameter
3851
+ # binder, RBS dispatch, the block-parameter reader) as an opaque `Dynamic[top]` / bare `Nominal[Hash]`
3852
+ # now carry what the callee needs:
3853
+ #
3854
+ # - `f(...)` inside `def m(...)` re-supplies the arguments `m` was called with, read from the frame
3855
+ # {#infer_user_method_return} installed ({FORWARDED_ARGS_KEY}) — so it expands to a whole LIST, not
3856
+ # one type, which is why the map became a flat_map.
3857
+ # - a keyword hash built ENTIRELY from a double splat (`f(**h)`) is the shape of `h`, so `f(**h)`
3858
+ # binds the same parameters the literal form `f(a: 1, b: 2)` does.
3859
+ #
3860
+ # Both are precision-only. A `...` outside a forwarding frame (unreachable in valid Ruby) and a double
3861
+ # splat of anything but a Symbol-keyed closed `HashShape` — a shapeless `Hash[Symbol, V]`, an opaque
3862
+ # value, a mixed hash — fall back to exactly the pre-#1125 answer.
3408
3863
  def call_arg_types(node)
3409
3864
  arguments_node = node.arguments
3410
3865
  return [] if arguments_node.nil?
3411
3866
 
3412
- arguments_node.arguments.map { |argument| type_of(argument) }
3867
+ arguments_node.arguments.flat_map do |argument|
3868
+ forwarded_argument_types(argument) || [call_arg_type(argument)]
3869
+ end
3870
+ end
3871
+
3872
+ # The frame's argument list when `argument` is `...`, else nil so the caller types it normally.
3873
+ def forwarded_argument_types(argument)
3874
+ return nil unless argument.is_a?(Prism::ForwardingArgumentsNode)
3875
+
3876
+ ExpressionTyper.current_forwarded_arg_types
3877
+ end
3878
+
3879
+ # The type a single call argument contributes; a keyword-hash argument first offers the `HashShape` it
3880
+ # stands for, and otherwise keeps its own type.
3881
+ def call_arg_type(argument)
3882
+ return type_of(argument) unless argument.is_a?(Prism::KeywordHashNode)
3883
+
3884
+ double_splat_hash_shape(argument) || type_of(argument)
3885
+ end
3886
+
3887
+ # The `HashShape` a `f(**h)` keyword-hash argument stands for, or nil to keep the argument's own type.
3888
+ # Declines a MIXED hash (`f(a: 1, **h)` — merging the literal pairs with the splatted shape is a second
3889
+ # shape algebra this slice does not need), an OPEN shape (unknown extras make every missing-keyword
3890
+ # answer a guess rather than a read), a shape with a non-Symbol key (Ruby itself rejects those as
3891
+ # keywords), and a splat of anything that is not a shape at all.
3892
+ def double_splat_hash_shape(node)
3893
+ elements = node.elements
3894
+ return nil unless elements.size == 1
3895
+
3896
+ splat = elements.first
3897
+ return nil unless splat.is_a?(Prism::AssocSplatNode) && splat.value
3898
+
3899
+ type = type_of(splat.value)
3900
+ return nil unless type.is_a?(Type::HashShape)
3901
+ return nil unless type.closed? && type.pairs.each_key.all?(Symbol)
3902
+
3903
+ type
3413
3904
  end
3414
3905
 
3415
3906
  # When the call carries a `Prism::BlockNode`, build the block's entry scope (outer locals plus
@@ -3431,23 +3922,44 @@ module Rigor
3431
3922
  return nil if block_arg.nil?
3432
3923
  return nil if receiver_type.nil?
3433
3924
 
3925
+ rebound = rebound_operand_typer(call_node)
3926
+ return rebound.send(:block_return_type_for, call_node, receiver_type, arg_types) if rebound
3927
+
3434
3928
  expected = MethodDispatcher.expected_block_param_types(
3435
3929
  receiver_type: receiver_type,
3436
3930
  method_name: call_node.name,
3437
3931
  arg_types: arg_types,
3438
- environment: scope.environment
3932
+ environment: scope.environment,
3933
+ scope: scope
3439
3934
  )
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
3935
+ block_return_for(
3936
+ block_arg, expected,
3937
+ call_node: call_node,
3938
+ narrowed_self_type: block_body_self_narrowing(call_node, receiver_type),
3939
+ repeats: !BLOCK_VALUE_DISCARDING.include?(call_node.name) && block_may_repeat?(call_node, receiver_type)
3445
3940
  )
3446
- block_return_for(block_arg, expected, narrowed_self_type: narrowed_self)
3447
3941
  rescue StandardError
3448
3942
  nil
3449
3943
  end
3450
3944
 
3945
+ # The catalogued iterators that discard their block's value — it selects the block-bearing overload and
3946
+ # nothing more — so the value pass lays no captured binding under their block: the fixpoint would buy a
3947
+ # type nothing reads, and `sum = 0; xs.each { |x| sum += x }` would pay it on top of the ADR-56
3948
+ # write-back's own. The #853 break-arm scan still lays it, since a `break` value is the call's.
3949
+ BLOCK_VALUE_DISCARDING = Set[
3950
+ :each, :each_with_index, :each_with_object, :each_pair, :each_key, :each_value, :each_index,
3951
+ :reverse_each, :each_entry, :each_slice, :each_cons, :each_char, :each_byte, :each_line,
3952
+ :each_codepoint, :times, :upto, :downto, :step
3953
+ ].freeze
3954
+ private_constant :BLOCK_VALUE_DISCARDING
3955
+
3956
+ # Whether the call may run its block more than once, so a later run reads a captured binding an earlier
3957
+ # run rebound — the premise of laying the #587 (b) binding ({#captured_block_bindings}) under the block.
3958
+ # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412).
3959
+ def block_may_repeat?(call_node, receiver_type)
3960
+ BlockRepetition.may_repeat?(method_name: call_node.name, receiver_type: receiver_type, scope: scope)
3961
+ end
3962
+
3451
3963
  EMPTY_BREAK_ARMS = [].freeze
3452
3964
  private_constant :EMPTY_BREAK_ARMS
3453
3965
 
@@ -3459,13 +3971,15 @@ module Rigor
3459
3971
  # nested block, lambda, `def`, or loop targets THAT construct instead; {JUMP_BOUNDARY_NODES} stops the
3460
3972
  # scan there, and the identity filter drops the ones the sink still collects because the nested
3461
3973
  # construct is walked under the same installation. `break` with no argument carries nil, so the call
3462
- # becomes optional — which is what Ruby does.
3974
+ # becomes optional — which is what Ruby does. A call that only stores its block ({StoredBlockCall}: `lambda`,
3975
+ # `define_method`, `Thread.new`, …) never runs it, so no arm of that block is the call's value.
3463
3976
  #
3464
3977
  # A failure yields no arms rather than propagating, matching {#block_return_type_for}: a call typed
3465
3978
  # without its break arms is the pre-#853 answer, while a raise here would take out the whole call.
3466
3979
  def call_break_arm_types(node, receiver_override: nil)
3467
3980
  block_node = node.block
3468
3981
  return EMPTY_BREAK_ARMS unless block_node.is_a?(Prism::BlockNode)
3982
+ return EMPTY_BREAK_ARMS if StoredBlockCall.stores_block?(node)
3469
3983
 
3470
3984
  body = block_node.body
3471
3985
  return EMPTY_BREAK_ARMS if body.nil? || !block_level_jump?(body, Prism::BreakNode)
@@ -3484,11 +3998,12 @@ module Rigor
3484
3998
  receiver = receiver_override || call_receiver_type_for(call_node)
3485
3999
  return EMPTY_BREAK_ARMS if receiver.nil?
3486
4000
 
3487
- narrowed_self = MacroBlockSelfType.narrow_self_type_for(
3488
- scope: scope, call_node: call_node, receiver_type: receiver
3489
- )
4001
+ param_types = break_arm_param_types(call_node, receiver)
3490
4002
  block_scope = block_entry_scope(
3491
- block_node, break_arm_param_types(call_node, receiver), narrowed_self_type: narrowed_self
4003
+ block_node, param_types,
4004
+ call_node: call_node,
4005
+ narrowed_self_type: block_body_self_narrowing(call_node, receiver),
4006
+ captured: repeating_captured_bindings(block_node, param_types, block_may_repeat?(call_node, receiver))
3492
4007
  )
3493
4008
  _result, collected = StatementEvaluator.with_break_value_sink do
3494
4009
  without_block_body_threading { block_scope.evaluate(body) }
@@ -3496,39 +4011,78 @@ module Rigor
3496
4011
  collected.filter_map { |jump, type| type if targets.key?(jump) }
3497
4012
  end
3498
4013
 
4014
+ # The block body's narrowed `self_type`, or `nil` to leave the scope contract unchanged.
4015
+ #
4016
+ # ADR-16 Tier A: a registered plugin's `block_as_methods` entry matching `(receiver_type,
4017
+ # call_node.name)` narrows to the receiver class's instance type.
4018
+ #
4019
+ # Issue #963: `define_method(:name) { ... }` installs its block as an instance method and runs it with
4020
+ # `self` bound to the receiving instance, so the block body's `self` is the INSTANCE side of a class body's
4021
+ # `Singleton[X]`. Both block-entry paths narrow it, and both decline on the same `class << ...` bodies,
4022
+ # because the whole distinction rides on `Scope#singleton_class_body?` rather than on the statement
4023
+ # evaluator's frame stack. This pass therefore cannot compute a carrier the evaluator disagrees with — it
4024
+ # matters wherever the block's value is observable, e.g. a project-declared generic `define_method`
4025
+ # signature that returns the block's own type.
4026
+ def block_body_self_narrowing(call_node, receiver_type)
4027
+ MacroBlockSelfType.narrow_self_type_for(
4028
+ scope: scope, call_node: call_node, receiver_type: receiver_type
4029
+ ) || DefineMethodBlockSelf.narrow_self_type_for(scope: scope, call_node: call_node)
4030
+ end
4031
+
3499
4032
  def break_arm_param_types(call_node, receiver)
3500
4033
  MethodDispatcher.expected_block_param_types(
3501
4034
  receiver_type: receiver,
3502
4035
  method_name: call_node.name,
3503
4036
  arg_types: call_arg_types(call_node),
3504
- environment: scope.environment
4037
+ environment: scope.environment,
4038
+ scope: scope
3505
4039
  )
3506
4040
  end
3507
4041
 
3508
- def block_return_for(block_arg, expected, narrowed_self_type: nil)
4042
+ def block_return_for(block_arg, expected, call_node: nil, narrowed_self_type: nil, repeats: false)
3509
4043
  case block_arg
3510
4044
  when Prism::BlockNode
3511
- type_block_body(block_arg, block_entry_scope(block_arg, expected, narrowed_self_type: narrowed_self_type))
4045
+ captured = repeating_captured_bindings(block_arg, expected, repeats)
4046
+ entry = block_entry_scope(
4047
+ block_arg, expected, call_node: call_node, narrowed_self_type: narrowed_self_type, captured: captured
4048
+ )
4049
+ type_block_body(block_arg, entry, captured: captured)
3512
4050
  when Prism::BlockArgumentNode
3513
4051
  symbol_block_return_type(block_arg, expected)
3514
4052
  end
3515
4053
  end
3516
4054
 
3517
4055
  # The scope a block body is typed under: the surrounding scope plus the parameter bindings the receiving
3518
- # method's signature implies.
4056
+ # method's signature implies, with the #587 (b) `captured` binding ({#repeating_captured_bindings}) laid
4057
+ # between the two, so the body reads what a captured local, instance variable, class variable or global
4058
+ # holds on ANY run rather than the first.
3519
4059
  #
3520
4060
  # 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
4061
+ # yielding method's business, so the return-typing pass must see the same unmodelled-self mark. Issue #1358
4062
+ # — and the same match-global view ({MatchRebinding.block_entry}), which reads the owning `call_node`.
4063
+ def block_entry_scope(block_node, expected, call_node: nil, narrowed_self_type: nil, captured: nil)
4064
+ entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
4065
+ entry = captured.lay(entry) if captured
4066
+ block_scope = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
3527
4067
  return block_scope unless narrowed_self_type
3528
4068
 
3529
4069
  block_scope.with_self_type(narrowed_self_type)
3530
4070
  end
3531
4071
 
4072
+ # The #587 (b) captured binding when the call may run the block more than once ({#block_may_repeat?}), and
4073
+ # nil — no binding — for a block it runs at most once, whose captures no earlier run can have moved.
4074
+ def repeating_captured_bindings(block_node, expected, repeats)
4075
+ repeats ? generic_captured_bindings(block_node, expected) : nil
4076
+ end
4077
+
4078
+ # The #587 (b) captured binding for the generic pass — or nil, which keeps the entry scope, when computing
4079
+ # it fails: a raise here would otherwise take out the whole block-return pass.
4080
+ def generic_captured_bindings(block_node, expected)
4081
+ captured_block_bindings(block_node, expected)
4082
+ rescue StandardError
4083
+ nil
4084
+ end
4085
+
3532
4086
  # `&:symbol` desugars to a one-arg Proc that dispatches `symbol` against its argument. When the param
3533
4087
  # type is known and the resulting inner dispatch is precise, this returns the precise carrier;
3534
4088
  # otherwise it returns `Dynamic[Top]` (still non-nil) so the outer dispatcher selects the
@@ -3568,14 +4122,114 @@ module Rigor
3568
4122
  # join, `ops.all? { |o| next false unless o; true }` read as `Constant[true]` and
3569
4123
  # {MethodDispatcher::BlockFolding} folded the call to always-truthy on a program that really can answer
3570
4124
  # false — a warning on correct code.
3571
- def type_block_body(block_node, block_scope)
4125
+ #
4126
+ # `captured` is the #587 (b) binding `block_scope` was laid over, if any; the names it answers are left to it
4127
+ # ({#tail_only_block_body_type}).
4128
+ def type_block_body(block_node, block_scope, captured: nil)
3572
4129
  body = block_node.body
3573
4130
  return Type::Combinator.constant_of(nil) if body.nil?
3574
4131
 
3575
4132
  arms = block_level_next_arms(body)
3576
4133
  return block_body_type_joining_nexts(body, block_scope, arms) if arms
3577
4134
 
3578
- threaded_block_body_type(body, block_scope) || block_scope.type_of(body)
4135
+ threaded_block_body_type(body, block_scope) || tail_only_block_body_type(body, block_scope, captured)
4136
+ end
4137
+
4138
+ # The tail typed in the entry scope — except under {#block_body_threading_suppressed?}, where a tail that
4139
+ # reads what its own prefix changed would get the ENTRY binding back, a stale answer rather than a wider
4140
+ # one. There the tail is typed over {#prefix_answered_scope}, which re-answers exactly those names without
4141
+ # evaluating the prefix. The threading's other declines keep the plain tail-only answer: a body with no
4142
+ # prefix, or whose tail ignores it, is not stale, while a `rescue` body and a prefix that can `break`
4143
+ # ({JUMP_NODES}) are typed tail-only with or without the suppression, and neither path re-answers them.
4144
+ #
4145
+ # A failure in the re-answer falls back to the plain tail-only answer, for the reason
4146
+ # {#threaded_block_body_type} gives: a raise reaching `block_return_type_for` would report "no block".
4147
+ def tail_only_block_body_type(body, block_scope, captured)
4148
+ return block_scope.type_of(body) unless block_body_threading_suppressed? && body.is_a?(Prism::StatementsNode)
4149
+
4150
+ answered =
4151
+ begin
4152
+ prefix_answered_scope(body.body, block_scope, captured)
4153
+ rescue StandardError
4154
+ block_scope
4155
+ end
4156
+ answered.type_of(body)
4157
+ end
4158
+
4159
+ # `block_scope` with every name the tail reads and the prefix changed ({#tail_dependent_body_names}) bound to
4160
+ # what the prefix can have left in it, answered without evaluating the prefix — which is the cost the
4161
+ # suppression refuses — on the terms the per-element fold's captured binding answers a capture under the same
4162
+ # suppression:
4163
+ #
4164
+ # - a name the prefix REBINDS reads `Dynamic[top]` ({#captured_floor}'s answer). `i += w; i` inside a block
4165
+ # the call runs once read `i` at its entry `0`, and `k == 0` then fired always-truthy on a `k` Ruby holds
4166
+ # as `1`.
4167
+ # - a name the prefix only MUTATES IN PLACE reads its unknown-store widening ({UnknownStoreWidening.widen}),
4168
+ # the binding {#stored_capture_bindings} lays: `|_k, a| a << w; a` over `{ x: [], y: [] }` read `a` as its
4169
+ # entry `[]` and the call as `Array[[]]`; it now reads `Array[Array[Dynamic[top]]]`.
4170
+ #
4171
+ # That is a floor per NAME, not per block, so the structure around a floored name survives (`e = e.to_s; [e, w]`
4172
+ # keeps its Tuple), and it is limited to the names tail-only answers stale. A name the entry scope does not bind —
4173
+ # a body-local, or an instance variable, class variable or global nothing bound yet — already reads
4174
+ # `Dynamic[top]`. An instance variable on its ADR-58 class-wide seed takes no floor for a rebind, since the seed
4175
+ # is the union of every write in the class, this prefix's included, though its mutation sites still widen it
4176
+ # ({#class_seeded_ivar?}). A name whose widening declines keeps its entry binding, because the threaded body
4177
+ # would have kept it as well: `s = String.new; … { s << "x"; s }` is `String` either way, and a precise nominal
4178
+ # `Array[String]` is a claim the widening may not grow on either path. And a name the #587 (b) `captured` binding
4179
+ # answers is left to it, as {#unanswered_tail_dependency?} leaves it: the per-element fold computes that binding
4180
+ # before it suppresses the threading above its cap, so it can hold the fixpoint's converged `Integer` for
4181
+ # `total += e; total`, which a floor here would throw away.
4182
+ def prefix_answered_scope(statements, block_scope, captured)
4183
+ return block_scope if statements.size < 2
4184
+
4185
+ names = tail_dependent_body_names(statements) - (captured&.names || EMPTY_NAME_SET)
4186
+ return block_scope if names.empty?
4187
+
4188
+ rebound, sites = prefix_changes(statements)
4189
+ names.reduce(block_scope) do |acc, name|
4190
+ answer = prefix_left_binding(block_scope, name, rebound, sites)
4191
+ answer ? CapturedLocals.bind(acc, name, answer) : acc
4192
+ end
4193
+ end
4194
+
4195
+ def prefix_left_binding(block_scope, name, rebound, sites)
4196
+ entry = CapturedLocals.bound_type(block_scope, name)
4197
+ return nil if entry.nil?
4198
+
4199
+ return Type::Combinator.untyped if rebound.include?(name) && !class_seeded_ivar?(block_scope, name)
4200
+
4201
+ widened = UnknownStoreWidening.widen(entry, sites.fetch(name, NO_MUTATION_SITES))
4202
+ widened == entry ? nil : widened
4203
+ end
4204
+
4205
+ # An instance variable still on its ADR-58 class-wide seed: the union of every WRITE in the class, so a rebind
4206
+ # in the prefix is already in it and needs no floor. An in-place mutation is no write — `@out << w.to_s` leaves
4207
+ # the seed `"k"` while the object holds `"k1"` — so the mutation sites still widen the seed, as they widen any
4208
+ # other entry binding.
4209
+ def class_seeded_ivar?(block_scope, name)
4210
+ CapturedLocals.ivar_name?(name) && block_scope.declaration_sourced?(:ivar, name)
4211
+ end
4212
+
4213
+ NO_MUTATION_SITES = [].freeze
4214
+ private_constant :NO_MUTATION_SITES
4215
+
4216
+ # The prefix's changes split the way {#prefix_left_binding} answers them: the names a write node rebinds,
4217
+ # and each in-place mutation site filed under every variable its receiver can evaluate to. The walk and both
4218
+ # predicates are {#prefix_statement_jump_free?}'s, so a name {#tail_dependent_body_names} reports is always
4219
+ # filed here under one of the two. Only a suppressed tail-only body with a dependent tail pays it.
4220
+ def prefix_changes(statements)
4221
+ rebound = Set.new
4222
+ sites = {}
4223
+ statements[0...-1].each do |statement|
4224
+ Source::NodeWalker.each_with_ancestors(statement) do |node, ancestors|
4225
+ rebound << node.name if VARIABLE_WRITE_NODES.include?(node.class)
4226
+ next unless in_place_mutation?(node)
4227
+
4228
+ nested = ancestors.any? { |ancestor| CLOSURE_NODES.include?(ancestor.class) }
4229
+ each_mutated_name(node, nested) { |name| (sites[name] ||= []) << node }
4230
+ end
4231
+ end
4232
+ [rebound, sites]
3579
4233
  end
3580
4234
 
3581
4235
  # Evaluates the body once under a `next` sink and joins the arms that leave THIS block with the
@@ -3616,39 +4270,14 @@ module Rigor
3616
4270
  block_level_jump_nodes(body, Prism::NextNode)
3617
4271
  end
3618
4272
 
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)
3628
-
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)
4273
+ # True when a `klass` jump is reachable from `node` without crossing a construct that retargets it
4274
+ # ({JumpTargets.any?}, allocation-free and early-exiting: this is the scan every block body pays).
4275
+ def block_level_jump?(node, klass) = JumpTargets.any?(node, klass)
3648
4276
 
3649
- collect_block_level_jumps(child, klass, found)
3650
- end
3651
- end
4277
+ # The identity-keyed set of `klass` jumps that target THIS block ({JumpTargets.of}). The sinks in
4278
+ # `StatementEvaluator` also collect jumps from nested blocks / loops / defs evaluated under the same
4279
+ # installation, so the consumer filters against this set by node identity.
4280
+ def block_level_jump_nodes(body, klass) = JumpTargets.of(body, klass)
3652
4281
 
3653
4282
  # Re-typing the whole body would be wrong to do unconditionally: this path runs for EVERY block-bearing
3654
4283
  # call, and the statements ahead of the tail are pure cost whenever the tail does not depend on them.
@@ -3663,8 +4292,11 @@ module Rigor
3663
4292
  # - the fold is not re-entrant. `StatementEvaluator#eval_call` already evaluates each nested block body
3664
4293
  # once, plus up to three more times under the ADR-56 `BodyFixpoint` when the block rebinds a captured
3665
4294
  # 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.
4295
+ # threaded body a nested block-bearing call reverts to the tail-only path. That is a wider answer only
4296
+ # while the nested tail ignores its own prefix: a tail reading a parameter or captured local the prefix
4297
+ # mutated gets the ENTRY binding back, so the per-element and per-pair folds floor that shape
4298
+ # ({#tail_only_body_floor}), and the generic block-return pass re-answers each stale name on its own
4299
+ # ({#prefix_answered_scope}).
3668
4300
  #
3669
4301
  # ADR-56 interaction: the fold cannot double-apply or fight the captured-local write-back. That
3670
4302
  # write-back is `StatementEvaluator#write_back_block_captures`, computed from the CALLER's scope into
@@ -3728,10 +4360,11 @@ module Rigor
3728
4360
  private_constant :VARIABLE_WRITE_NODES
3729
4361
 
3730
4362
  # 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`).
4363
+ # their target before rebinding it (`v += 1` in the tail depends on an earlier `v = 0`). An `it` read
4364
+ # observes the local `:it` ({ReceiverAlias.read_name}); it has no `name` of its own.
3732
4365
  VARIABLE_READ_NODES = (
3733
4366
  VARIABLE_WRITE_NODES | [
3734
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
4367
+ Prism::LocalVariableReadNode, Prism::ItLocalVariableReadNode, Prism::InstanceVariableReadNode,
3735
4368
  Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
3736
4369
  ]
3737
4370
  ).freeze
@@ -3758,11 +4391,9 @@ module Rigor
3758
4391
  # nothing about our block's value — it neither triggers the decline nor joins as an arm. A nested
3759
4392
  # `BlockNode` / `LambdaNode` is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads
3760
4393
  # 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
4394
+ # end` continues the loop); a `DefNode` body is a different method entirely. The set is
4395
+ # {JumpTargets::BOUNDARY_NODES}, shared with `StatementEvaluator`'s loop and block joins.
4396
+ JUMP_BOUNDARY_NODES = JumpTargets::BOUNDARY_NODES
3766
4397
  private_constant :JUMP_BOUNDARY_NODES
3767
4398
 
3768
4399
  # True when the tail statement observes a variable name one of the earlier statements binds OR mutates
@@ -3777,9 +4408,13 @@ module Rigor
3777
4408
  # hands downstream rules a provably-empty array. Threading is the fix, not a cost: `StatementEvaluator`
3778
4409
  # runs `MutationWidening.widen_after_call` on the `push`, so the threaded tail reads the widened
3779
4410
  # `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.
4411
+ # ({ReceiverAlias.mutated_reads} — the ternary-selected receiver of issue #277 included) whenever its
4412
+ # name is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
4413
+ # tables and receiver answer is what keeps "the scan says thread" and "threading changes something" the
4414
+ # same predicate. The index-write nodes ({INDEX_WRITE_NODES}) store through `[]=` without being a call,
4415
+ # so a name-keyed scan missed them and `h[:a] += 1; h[:a]` kept the literal's `0`; they contribute their
4416
+ # receiver the same way. A scan that read only local and instance-variable receivers missed `$g << w; $g`
4417
+ # and `it << w; it` the same way, while the tail kept the entry `"k"` / `[]`.
3783
4418
  #
3784
4419
  # Cost is two walks of the body, the second only when the first found a write and no jump — the same
3785
4420
  # order of cost `StatementEvaluator`'s own per-call captured-write scan already pays, and far below
@@ -3792,22 +4427,25 @@ module Rigor
3792
4427
  return false if written.nil?
3793
4428
 
3794
4429
  Source::NodeWalker.each(statements.last) do |node|
3795
- return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
4430
+ return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(ReceiverAlias.read_name(node))
3796
4431
  end
3797
4432
  false
3798
4433
  end
3799
4434
 
3800
4435
  # 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
4436
+ # or mutates in place. Only the tail-only floor ({#unanswered_tail_dependency?}) needs them, which is
3802
4437
  # 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
4438
+ # body and short-circuits on the first hit, the floor runs only for a walk typed tail-only. Sharing
3804
4439
  # {#prefix_written_names} is what keeps the two from drifting about what the prefix binds.
3805
4440
  def tail_dependent_body_names(statements)
3806
4441
  written = prefix_written_names(statements)
3807
4442
  return EMPTY_NAME_SET if written.nil?
3808
4443
 
3809
4444
  Source::NodeWalker.each(statements.last).filter_map do |node|
3810
- node.name if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
4445
+ next unless VARIABLE_READ_NODES.include?(node.class)
4446
+
4447
+ name = ReceiverAlias.read_name(node)
4448
+ name if written.include?(name)
3811
4449
  end.to_set
3812
4450
  end
3813
4451
 
@@ -3816,7 +4454,7 @@ module Rigor
3816
4454
  def prefix_written_names(statements)
3817
4455
  written = Set.new
3818
4456
  statements[0...-1].each do |statement|
3819
- return nil unless prefix_statement_jump_free?(statement, written, false)
4457
+ return nil unless prefix_statement_jump_free?(statement, written, false, false)
3820
4458
  end
3821
4459
  return nil if written.empty?
3822
4460
 
@@ -3827,40 +4465,73 @@ module Rigor
3827
4465
  private_constant :EMPTY_NAME_SET
3828
4466
 
3829
4467
  # 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.
4468
+ # binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call or an
4469
+ # {INDEX_WRITE_NODES} store, through every variable its receiver can evaluate to) on the way down.
4470
+ # `retargeted` is true once the descent has passed a boundary, and `nested` once it has passed a block or
4471
+ # lambda ({CLOSURE_NODES}), whose `it` is not the body's.
3833
4472
  #
3834
4473
  # A `Prism::DefinedNode`'s operand is never evaluated, so it is not descended into — the same rule
3835
4474
  # {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)
4475
+ def prefix_statement_jump_free?(node, written, retargeted, nested)
3837
4476
  return false if !retargeted && JUMP_NODES.include?(node.class)
3838
4477
 
3839
4478
  written << node.name if VARIABLE_WRITE_NODES.include?(node.class)
3840
- collect_mutated_receivers(node, written) if node.is_a?(Prism::CallNode)
4479
+ each_mutated_name(node, nested) { |name| written << name } if in_place_mutation?(node)
3841
4480
  return true if node.is_a?(Prism::DefinedNode)
3842
4481
 
3843
4482
  child_retargeted = retargeted || JUMP_BOUNDARY_NODES.include?(node.class)
4483
+ child_nested = nested || CLOSURE_NODES.include?(node.class)
3844
4484
  node.rigor_each_child do |child|
3845
- return false unless prefix_statement_jump_free?(child, written, child_retargeted)
4485
+ return false unless prefix_statement_jump_free?(child, written, child_retargeted, child_nested)
3846
4486
  end
3847
4487
  true
3848
4488
  end
3849
4489
 
3850
- def collect_mutated_receivers(call_node, written)
3851
- return unless MutationWidening::SHAPE_MUTATORS.include?(call_node.name)
4490
+ # The forms that store through `[]=` without being a `[]=` call ({IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES}).
4491
+ # `StatementEvaluator` widens the three compound writes in straight-line code, and its captured-local
4492
+ # write-back widens all four when a nested block stores through one. A straight-line index TARGET widens
4493
+ # too, at the multi-assign, `for` index or `rescue =>` reference that owns it (`eval_multi_write`,
4494
+ # `bind_for_index`, `bind_rescue_reference`).
4495
+ INDEX_WRITE_NODES = Set.new(IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES).freeze
4496
+ private_constant :INDEX_WRITE_NODES
4497
+
4498
+ def in_place_mutation?(node)
4499
+ return MutationWidening::SHAPE_MUTATORS.include?(node.name) if node.is_a?(Prism::CallNode)
4500
+
4501
+ INDEX_WRITE_NODES.include?(node.class)
4502
+ end
4503
+
4504
+ # Yields the name of every variable the in-place mutation `node` changes: its receiver's
4505
+ # {ReceiverAlias.mutated_reads}, the answer the straight-line widening the threaded body runs reads too, so a
4506
+ # global or class variable counts (`$g << w; $g`) as well as a local, an instance variable and the `it`
4507
+ # parameter. An `it` read `nested` under a block or lambda inside the prefix is that closure's own parameter,
4508
+ # never the body's `it`, so it names nothing here: `[[]].each { it << w }; it` leaves the body's `it` as it was.
4509
+ def each_mutated_name(node, nested)
4510
+ ReceiverAlias.mutated_reads(node.receiver).each do |read|
4511
+ next if nested && read.is_a?(Prism::ItLocalVariableReadNode)
3852
4512
 
3853
- ReceiverAlias.candidates(call_node.receiver).each { |read| written << read.name }
4513
+ yield ReceiverAlias.read_name(read)
4514
+ end
3854
4515
  end
3855
4516
 
4517
+ # The closures whose `it` is their own: a nested block or lambda always binds `it` to its own parameter.
4518
+ CLOSURE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
4519
+ private_constant :CLOSURE_NODES
4520
+
3856
4521
  # v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
3857
4522
  # Tuple position, binds the block parameter to that element's type, and re-types the block body. The
3858
4523
  # per-position results are assembled into `Tuple[U_1..U_n]`, strictly tighter than the RBS-projected
3859
4524
  # `Array[union]`.
3860
4525
  #
3861
4526
  # 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.
4527
+ # has no `Prism::BlockNode`, when the method is outside the supported set, when the call carries an
4528
+ # argument, when block typing raises mid-loop, or when the block has no body. The decline path leaves the
4529
+ # dispatch chain untouched.
4530
+ #
4531
+ # The walk reads only the block, so an argument declines: `index(value)` / `find_index(value)` search by
4532
+ # `==` without running the block, and `find(ifnone)` / `detect(ifnone)` answer `ifnone.call` when no
4533
+ # position matches. `r = [1, 2].find(-> { 0 }) { |e| e > 5 }` is `0` at runtime; the walk answered `nil`,
4534
+ # and `r + 1` then reported a nil receiver on correct code. The other supported methods take no argument.
3864
4535
  PER_ELEMENT_TUPLE_METHODS = Set[
3865
4536
  :map, :collect, :filter_map, :flat_map,
3866
4537
  :select, :filter, :reject,
@@ -3874,6 +4545,9 @@ module Rigor
3874
4545
  ].freeze
3875
4546
  private_constant :HASH_SHAPE_TRANSFORM_METHODS
3876
4547
 
4548
+ IN_PLACE_HASH_SHAPE_TRANSFORMS = Set[:transform_keys!, :transform_values!].freeze
4549
+ private_constant :IN_PLACE_HASH_SHAPE_TRANSFORMS
4550
+
3877
4551
  # Cardinality cap for per-element block fold over finite-bound `Constant<Range>` receivers. Walking
3878
4552
  # `(1..1_000_000).map { … }` element-wise would balloon block-typing cost and explode the resulting
3879
4553
  # Tuple, so only short ranges expand into per-position folds. Larger ranges decline so the RBS tier
@@ -3883,7 +4557,7 @@ module Rigor
3883
4557
 
3884
4558
  def try_per_element_block_fold(call_node, receiver_type)
3885
4559
  return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
3886
- return nil if find_family_with_args?(call_node)
4560
+ return nil unless call_node.arguments.nil?
3887
4561
 
3888
4562
  element_types = per_element_elements_of(receiver_type)
3889
4563
  return nil if element_types.nil? || element_types.empty?
@@ -3892,12 +4566,13 @@ module Rigor
3892
4566
  return nil if per_position.nil? || per_position.any?(&:nil?)
3893
4567
 
3894
4568
  assemble_per_element_result(call_node.name, per_position, element_types) ||
3895
- find_family_floor(call_node.name, element_types)
4569
+ undecided_fold_floor(call_node.name, element_types, receiver_type)
3896
4570
  end
3897
4571
 
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.
4572
+ # The honest answer for the find family (`find` / `detect` / `find_index` / `index`) and the filter family
4573
+ # (`select` / `filter` / `reject`) when this fold walked every position and the assembler still could not
4574
+ # decide — which happens for exactly one reason: some position's predicate is not a `Constant`, so "the
4575
+ # first matching one" or "the ones kept" is not a static fact.
3901
4576
  #
3902
4577
  # Falling through to the dispatcher was WRONG for this family, and issue #617 residue (1) is the bill:
3903
4578
  # `seen = 0; [1, 2].find do |e| seen += 1; seen == 2 end` answered `nil` where the runtime answers `2`.
@@ -3912,19 +4587,39 @@ module Rigor
3912
4587
  # back an element, it does not compute one. `find_index` / `index` answer a position in the receiver, so
3913
4588
  # `Integer?` is their floor.
3914
4589
  #
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)
4590
+ # `select` / `filter` / `reject` take a floor for the same reason. Their fall-through is not merely
4591
+ # wider: `BlockFolding`'s filter folds DO answer on a `Constant` block, and the entry-scope pin hands
4592
+ # them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2 end` answered a provably-empty `[]`
4593
+ # where Ruby answers `[2]`. Nested inside a threaded body, where a position can be typed tail-only
4594
+ # ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end` did the same. Their floor is an
4595
+ # Array of the receiver's own elements: which of them survive is undecided, and what they are is not.
4596
+ # A range receiver's elements are widened first ({#filter_family_floor}).
4597
+ #
4598
+ # Nothing else in {PER_ELEMENT_TUPLE_METHODS} takes a floor. `map`'s assembler cannot decline, and
4599
+ # `BlockFolding` never folds `filter_map` / `flat_map`, so their fall-through is the RBS `Array[U]`
4600
+ # projection.
4601
+ def undecided_fold_floor(method_name, element_types, receiver_type)
3920
4602
  case method_name
3921
4603
  when :find, :detect
3922
4604
  Type::Combinator.union(*element_types, Type::Combinator.constant_of(nil))
4605
+ when :select, :filter, :reject
4606
+ filter_family_floor(element_types, receiver_type)
3923
4607
  when :find_index, :index
3924
4608
  Type::Combinator.union(Type::Combinator.nominal_of("Integer"), Type::Combinator.constant_of(nil))
3925
4609
  end
3926
4610
  end
3927
4611
 
4612
+ # A `Constant<Range>` receiver's elements are values this walk enumerated, not ones the program wrote, and
4613
+ # the RBS answer the floor replaces carried no pin. Kept, the pins would make `(1..4).filter { … }` an
4614
+ # `Array[1 | 2 | 3 | 4]` that a later `q << 9` cannot widen (#580 leaves a precise Array without a
4615
+ # gradual arm alone), so `q.last == 9` would fold always-falsey on correct code. A Tuple receiver's pins
4616
+ # are the literal's own and stay, as the RBS projection kept them.
4617
+ def filter_family_floor(element_types, receiver_type)
4618
+ element = Type::Combinator.union(*element_types)
4619
+ element = Type::Combinator.widen_value_pinned(element) if receiver_type.is_a?(Type::Constant)
4620
+ Type::Combinator.nominal_of("Array", type_args: [element])
4621
+ end
4622
+
3928
4623
  # Evaluates the call's block once per receiver element. Two block shapes are supported:
3929
4624
  #
3930
4625
  # - `Prism::BlockNode` — a full `do … end` / `{ … }` block; the body is re-typed per position with the
@@ -3957,49 +4652,84 @@ module Rigor
3957
4652
  def per_element_body_results(block, element_types)
3958
4653
  captured = per_element_captured_bindings(block, element_types)
3959
4654
  results = lambda do
3960
- element_types.map { |element_type| type_block_body_with_param(block, [element_type], captured: captured) }
4655
+ element_types.each_with_index.map do |element_type, position|
4656
+ type_block_body_with_param(block, [element_type], captured: captured, position: position)
4657
+ end
3961
4658
  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)
4659
+ return results.call unless tail_only_walk?(element_types)
4660
+ return tail_only_body_floor(element_types) if unanswered_tail_dependency?(block, captured)
3964
4661
 
3965
4662
  without_block_body_threading(&results)
3966
4663
  end
3967
4664
 
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.
4665
+ # A position is typed tail-only in two cases: above {PER_ELEMENT_THREADING_LIMIT}, where this walk
4666
+ # suppresses the threading itself, and anywhere the walk runs nested inside a body some other pass is
4667
+ # already evaluating whole. {#threaded_block_body_type}, the `next` join
4668
+ # ({#block_body_type_joining_nexts}), the break-arm collection ({#collect_break_arm_types}), the
4669
+ # captured-local fixpoint ({#captured_exit_bindings}) and an enclosing walk of this fold above the cap
4670
+ # all suppress the threading while they run, so a fold never re-enters. The second case has no arity in
4671
+ # it: `[[], []].map do |a| a << w; a end` inside a threaded `m.synchronize do w = v; … end` is typed
4672
+ # tail-only at two positions.
4673
+ def tail_only_walk?(element_types)
4674
+ element_types.size > PER_ELEMENT_THREADING_LIMIT || block_body_threading_suppressed?
4675
+ end
4676
+
4677
+ # Wherever a position is typed tail-only ({#tail_only_walk?}), a tail that reads what the body's own
4678
+ # prefix wrote or mutated does not get a wider answer. It gets the ENTRY binding, which the prefix has
4679
+ # already falsified.
3971
4680
  #
3972
4681
  # #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)
4682
+ # 9].map do v = e; v end` has no entry binding for `v`, so tail-only lands on `Dynamic[top]` by itself,
4683
+ # which is why a body-local alone does not call for the floor. A mutated PARAMETER has one, and issue
4684
+ # #617 residue (2) is what it buys: `([[]] * 9).map do |a| a << 1; a end` answered nine stale `[]`, a
4685
+ # provably-empty array at every position of a result whose slots all hold the one array the nine `<<`
4686
+ # filled. Nested under the suppression the same body did it at two positions, and a `transform_values`
4687
+ # over `{ x: [], y: [] }` did it at every pair ({#tail_only_pairs_floored?}). Flooring every position
4688
+ # restores the promise for the positions; the assembler still decides the call from them, and a method
4689
+ # whose fall-through could read a pin again takes its own floor ({#undecided_fold_floor}). What the cap
4690
+ # and the suppression refuse to pay is the per-position body evaluation, and refusing to pay it means
4691
+ # declining to know, not answering the pre-state.
4692
+ #
4693
+ # The names come from the scan the threading gate uses ({#tail_depends_on_body_binding?}), so "would
4694
+ # threading have changed this tail" and "is tail-only untrustworthy here" stay one question. A body
4695
+ # {#unanswered_tail_dependency?} answers false for keeps its exact fold: every single-statement block,
4696
+ # and every multi-statement block whose tail ignores its prefix, reads only its own body-locals, or
4697
+ # leaves through a `next` (the last is evaluated whole, never tail-only).
4698
+ def tail_only_body_floor(element_types)
3985
4699
  Array.new(element_types.size) { Type::Combinator.untyped }
3986
4700
  end
3987
4701
 
3988
4702
  # True when the tail reads something the prefix changed that NOTHING has re-answered for this walk.
3989
4703
  #
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.
4704
+ # `captured` is the #587 (b) any-iteration binding of every outer local and instance variable the block
4705
+ # rebinds or mutates in place ({#per_element_captured_bindings}), and its cost is independent of the
4706
+ # arity, so it keeps working above the cap: `total = 0; [1, …, 9].map do total += e; total end` reads
4707
+ # `total` as the fixpoint's `Integer` at every position and needs no floor, and `out = []; … do out << e;
4708
+ # out.size end` reads `out` as the widened `Array[Dynamic[top]]`, whose content carries a gradual arm and
4709
+ # whose arity is open. Under the nesting suppression a rebound name takes the escaping-block floor name by
4710
+ # name, so `[total, e]` keeps its `Tuple` rather than collapsing whole, and the in-place widening, which
4711
+ # evaluates no body, applies as it does anywhere. What a tail-only walk withholds is the per-position body
4712
+ # evaluation, so the names left unanswered are the ones neither binding covers — a mutated block
4713
+ # PARAMETER (issue #617 residue (2)'s `|a| a << 1; a`), and a captured local whose in-place widening
4714
+ # declined, which is left out of `captured` for exactly this reason.
4715
+ #
4716
+ # Two tails are answered without it. A name with no ENTRY binding — a body-local such as `key = k.to_s;
4717
+ # [key, n + w]` — reads as `Dynamic[top]` under tail-only by itself, which is sound, so flooring the
4718
+ # whole position would only erase the structure around it. Instance and global variables always count,
4719
+ # since the scope may still hold a pre-state for them. And a body with a block-level `next` is never
4720
+ # typed tail-only at all: {#block_body_type_joining_nexts} evaluates the whole body whatever the
4721
+ # suppression says.
3996
4722
  def unanswered_tail_dependency?(block, captured)
3997
4723
  body = block.body
3998
4724
  return false unless body.is_a?(Prism::StatementsNode)
3999
4725
  return false if body.body.size < 2
4726
+ return false if block_level_jump?(body, Prism::NextNode)
4727
+
4728
+ names = tail_dependent_body_names(body.body) - (captured&.names || EMPTY_NAME_SET)
4729
+ return false if names.empty?
4000
4730
 
4001
- answered = captured&.keys || []
4002
- tail_dependent_body_names(body.body).any? { |name| !answered.include?(name) }
4731
+ entry = BlockParameterBinder.new.bind_onto(block, scope)
4732
+ names.any? { |name| name.start_with?("@", "$") || !entry.local(name).nil? }
4003
4733
  end
4004
4734
 
4005
4735
  # Issue #587 (b) — first-iteration pinning. Every position of this fold is typed from the SAME entry
@@ -4014,12 +4744,12 @@ module Rigor
4014
4744
  # position's entry scope, so a position answers what the local can be in ANY iteration
4015
4745
  # (`[Integer, Integer]`), never what it was in the first.
4016
4746
  #
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`.
4747
+ # Only the names the body changes move. A position whose tail reads an untouched captured local or a
4748
+ # block-local keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound
4749
+ # counter still decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would
4750
+ # have lost all three for nothing. The fixpoint binds the block parameter to the union of the elements,
4751
+ # so its cost is independent of the arity — which is why the per-element threading cap is NOT a reason to
4752
+ # floor: a ninth element keeps `Integer` where it would otherwise keep the stale `0`.
4023
4753
  #
4024
4754
  # Under threading suppression — this fold nested inside another threaded body — the fixpoint's body
4025
4755
  # evaluations are exactly the re-entrant cost the suppression exists to refuse, so the names take the
@@ -4027,74 +4757,256 @@ module Rigor
4027
4757
  # fixpoint takes the same floor rather than the seed — a seed that reaches a position is the pin this
4028
4758
  # exists to remove.
4029
4759
  #
4030
- # Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds nothing captured.
4760
+ # An instance variable pins the same way — the block shares the caller's `self`, so `@t = 0; [1,
4761
+ # 2].map { @t += 1 }` folded to `[1, 1]` too — and so do a class variable and a global. Each takes the
4762
+ # same treatment under every rule above: the ones the body rebinds ({CapturedLocals.writes} with
4763
+ # `non_locals: true`, which also counts `self.w = …` as a rebind of `@w`) join the name set. They keep
4764
+ # their sigil, so the one map cannot confuse `@t` with a local `t`, and the arity-cap floor
4765
+ # ({#unanswered_tail_dependency?}), which compares names sigil-and-all, counts a rebound ivar as answered.
4766
+ #
4767
+ # A name the body rebinds where the fixpoint's pass cannot see it ({UnthreadedRebinds}) takes the floor
4768
+ # outright; see {#converged_captured_bindings}. The optimistic nil-freeness mark a pass's exit binding
4769
+ # carries is kept with the converged type ({CapturedLocals::Bindings}).
4770
+ #
4771
+ # The same pin has a CONTENT half the rebind set cannot see: a captured receiver the body mutates in place
4772
+ # is never rebound, so `h = { a: 0 }; [:a, :a].map { |k| h[k] = h[k] + 1 }` read `h` at its entry
4773
+ # contents at every position and folded to `[1, 1]` (runtime `[1, 2]`). Each such local
4774
+ # ({CapturedLocals.content_mutations}) is bound as if every mutation site in the body had already stored
4775
+ # an unknown value ({UnknownStoreWidening.widen}): `Hash[Dynamic[top] | Symbol, Dynamic[top] |
4776
+ # Integer]`, an open arity with a gradual arm on every content parameter a storing site touches. A local
4777
+ # whose widening declines (a precise nominal) gets no binding at all, so it keeps today's answer. The
4778
+ # binding evaluates no body, so it applies under threading suppression too; the rebind fixpoint runs
4779
+ # over it, and a local the body both rebinds and mutates takes the same widening over its converged
4780
+ # type — the rebind can bring a fresh literal back, which the next iteration then mutates. A local mutated
4781
+ # through an element read (`a[0] << e`) or passed to a self-call whose callee content-mutates that
4782
+ # parameter (`add_to(a, e)`) takes it too, widened as straight-line code widens it after such a site. An
4783
+ # unmutated captured local keeps its exact binding (`h = { a: 0 }; [:a, :a].map { |k| h[k] }` still folds
4784
+ # to `[0, 0]`). An instance variable the body mutates in place takes the same binding
4785
+ # ({CapturedLocals.content_mutations} with `non_locals: true`, on the rebind set's terms): once the #587
4786
+ # (a) gate threads an index write, `@cache[:first] ||= e` pins the same way. So do a class variable and a
4787
+ # global.
4788
+ #
4789
+ # The generic block-return pass lays the same binding ({#block_entry_scope}), with the fixpoint's block
4790
+ # parameters bound to the signature's instead of to the elements' union.
4791
+ #
4792
+ # The binding also marks the index `||=` sites whose slot an earlier position may have filled
4793
+ # ({RepeatedOrWrites}), so the memoizing `||=` reading (`StatementEvaluator#index_compound_write_value`)
4794
+ # does not take such a slot's lone `Dynamic` for "no evidence about the slot" and answer one position's
4795
+ # rvalue: `cache = {}; [1, 2].find { |e| (cache[:first] ||= e) == 2 }` answered `2 == 2` at the second
4796
+ # position and folded `find` to `2` where Ruby, keeping the first iteration's `1`, answers `nil`.
4797
+ #
4798
+ # Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds and mutates nothing
4799
+ # captured and holds no such site.
4031
4800
  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?
4801
+ captured_block_bindings(block, [Type::Combinator.union(*element_types)], element_types: element_types)
4802
+ end
4803
+
4804
+ # The #587 (b) binding for `block`, with the fixpoint's block parameters bound to `param_types`.
4805
+ # `element_types` is the per-element fold's positions, which {RepeatedOrWrites} reads; nil for the generic
4806
+ # block-return pass.
4807
+ def captured_block_bindings(block, param_types, element_types: nil)
4808
+ stores = CapturedLocals.content_mutations(block, scope, non_locals: true)
4809
+ names = CapturedLocals.writes(block, scope, non_locals: true)
4810
+ repeated = RepeatedOrWrites.sites(block, stores, scope, element_types: element_types)
4811
+ return nil if stores.empty? && names.empty? && repeated.empty?
4812
+
4813
+ stored = stored_capture_bindings(stores)
4814
+ types = stored.dup
4815
+ marks = {}
4816
+ unless names.empty?
4817
+ rebound_capture_bindings(block, names, param_types, stored, marks).each do |name, converged|
4818
+ types[name] = stores.key?(name) ? UnknownStoreWidening.widen(converged, stores[name]) : converged
4819
+ end
4820
+ end
4821
+ return nil if types.empty? && repeated.empty?
4035
4822
 
4036
- begin
4037
- converged_captured_bindings(block, names, element_types)
4038
- rescue StandardError
4039
- captured_floor(names)
4823
+ CapturedLocals::Bindings.new(types: types, marks: marks, repeated: repeated)
4824
+ end
4825
+
4826
+ # Only a binding the widening MOVED is recorded. One it declined (a precise nominal, a refinement under
4827
+ # `sort!`) is still the entry binding, which says nothing about later iterations; recording it would make the
4828
+ # arity-cap floor ({#unanswered_tail_dependency?}) treat the name as answered and type the tail from that
4829
+ # stale binding. Left out, the name keeps the entry binding below the cap and
4830
+ # the floor above it, which is the fold's answer without this pass.
4831
+ def stored_capture_bindings(stores)
4832
+ stores.each_with_object({}) do |(name, sites), bindings|
4833
+ seed = CapturedLocals.bound_type(scope, name)
4834
+ next if seed.nil?
4835
+
4836
+ widened = UnknownStoreWidening.widen(seed, sites)
4837
+ bindings[name] = widened unless widened == seed
4040
4838
  end
4041
4839
  end
4042
4840
 
4841
+ # `stored` is laid under every fixpoint pass, so the rebind converges over the widened contents rather
4842
+ # than the entry ones. `marks` collects the optimistic nil-freeness mark a pass's exit binding carries.
4843
+ def rebound_capture_bindings(block, names, param_types, stored, marks)
4844
+ return captured_floor(names) if block_body_threading_suppressed?
4845
+
4846
+ converged_captured_bindings(block, names, param_types, stored, marks)
4847
+ rescue StandardError
4848
+ captured_floor(names)
4849
+ end
4850
+
4043
4851
  def captured_floor(names)
4044
4852
  names.to_h { |name| [name, Type::Combinator.untyped] }
4045
4853
  end
4046
4854
 
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)] }
4855
+ # A name the body rebinds somewhere the pass's exit scope never sees ({UnthreadedRebinds}) takes the floor
4856
+ # without running the fixpoint, and is bound to it under every pass, so a name that reads it converges over
4857
+ # the floor rather than over the pin: `t = s` after an unthreaded `s` rebind would otherwise converge on
4858
+ # the first iteration's `s`. The unmoved-pin test below still runs over every other name.
4859
+ def converged_captured_bindings(block, names, param_types, stored, marks)
4860
+ floored = UnthreadedRebinds.names(block, names)
4861
+ moving = names.reject { |name| floored.include?(name) }
4862
+ # Issue #1358 — each pass is an iteration, so it reads the match globals the block entry does.
4863
+ base = stored.reduce(MatchRebinding.block_entry(scope, block)) do |acc, (name, type)|
4864
+ CapturedLocals.bind(acc, name, type)
4865
+ end
4866
+ base = floored.reduce(base) { |acc, name| CapturedLocals.bind(acc, name, Type::Combinator.untyped) }
4867
+ seeds = moving.to_h { |name| [name, CapturedLocals.bound_type(base, name)] }
4868
+ moved = {}
4050
4869
  converged = BodyFixpoint.converge(
4051
- names: names,
4870
+ names: moving,
4052
4871
  seed_bindings: seeds,
4053
4872
  widen: Type::Combinator.method(:widen_value_pinned),
4054
- evaluate_body: ->(bindings) { captured_exit_bindings(block, param_types, bindings, names) }
4873
+ evaluate_body: lambda do |bindings|
4874
+ captured_exit_bindings(block, param_types, base, bindings, moved, marks)
4875
+ end
4055
4876
  )
4056
- unmoved_pins_floored(converged, seeds)
4877
+ unmoved_pins_floored(converged, seeds, moved).merge(captured_floor(floored))
4057
4878
  end
4058
4879
 
4059
4880
  # A name the write scan says this block REBINDS, whose fixpoint came back on exactly its value-pinned
4060
4881
  # seed, is floored rather than believed.
4061
4882
  #
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.
4883
+ # The fixpoint reads each pass's exit binding out of `StatementEvaluator`, and that evaluator does not
4884
+ # thread a write from every position: a rebind nested where it types a pure value leaves the exit scope
4885
+ # holding the entry binding, so the fixpoint converges on the seed and every position of the fold answers
4886
+ # the first iteration again. That is issue #617 residue (1)'s one-liner, `seen = 0; [1, 2].find { |e|
4887
+ # (seen += 1) == 2 }` answering `nil` where Ruby answers `2`: the pinned `Constant[0]` made both
4888
+ # predicates `Constant[false]`, and `find` short-circuits on a provably-falsey block. The evaluator has
4889
+ # threaded a call's receiver since #1223, but a `when` condition or a `yield` argument is still such a
4890
+ # position.
4069
4891
  #
4070
4892
  # An unmoved pin cannot be distinguished from a write that genuinely restores its own entry value
4071
4893
  # (`x = 5; xs.each { x = 5 }`), so the floor gives that shape up too. It is the far cheaper side: a
4072
4894
  # value-pinned seed the block rebinds is the exact pre-state this fold exists to stop trusting, and
4073
4895
  # `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
4896
+ # pinning are left alone — there is no first-iteration value or shape in them to remove, and widening a
4075
4897
  # `Nominal` here would only lose a class for nothing.
4076
- def unmoved_pins_floored(converged, seeds)
4898
+ #
4899
+ # A second route reaches this test with nothing hidden from the evaluator. The fixpoint stops after
4900
+ # {BodyFixpoint::CAP} passes, so a rebind a counter guards past the third iteration (`row = [v, v] if
4901
+ # count > 3`) runs in none of them, and the widen on the capped pass erases a `Constant`'s value but leaves
4902
+ # a `Tuple` / `HashShape` as it is. `row = []` therefore came back on its seed, and `sizes.last == 2`
4903
+ # folded always-falsey on a runtime `2`, so a shape carrier counts as pinned ({#value_pinned?}). For a
4904
+ # shape the unmoved test only asks whether the seed already covers the converged type, so any rebind the
4905
+ # seed covers is floored with it, not only one restoring the entry value: `qr = n.divmod(3)` rebound to
4906
+ # another `divmod` reads `Dynamic[top]` too.
4907
+ #
4908
+ # The pin test reads a binding as a whole and nothing else. A `0 | Integer` seed is floored too, although a
4909
+ # threaded `x += 1` only joins back into it: an unthreaded write storing another class (`log(x = nil)`)
4910
+ # converges on the same seed, and only the floor keeps `x.nil?` from folding to `false`. Nor does a pass
4911
+ # whose exit binding moved prove the rebind was threaded — a narrowing (`next false unless x`) or a
4912
+ # threaded prefix (`x ||= 0`) moves it while `(x += 1) == 2` stays unthreaded.
4913
+ #
4914
+ # "Unmoved" compares against the fixpoint's own seed, but "pinned" is asked of the CALL-SITE binding. For a
4915
+ # name the body also mutates in place the seed is already the in-place widening of that binding
4916
+ # ({#stored_capture_bindings}), and the widening erases exactly the pin this test looks for: `s = +"ab"`
4917
+ # seeds `String`, not `"ab"`. Asking the seed would let a nested rebind the evaluator cannot see (`(s &&=
4918
+ # s.to_sym)` inside an expression) converge on `String` and be believed, where the local really holds a
4919
+ # Symbol from the second iteration on. For every other name the two bindings are the same. The price is
4920
+ # the trade the paragraph above already makes: a name the body mutates and VISIBLY rebinds to the widened
4921
+ # class (`t << "c"; t = t.strip`) converges on that seed as well, and is floored with the hidden case.
4922
+ #
4923
+ # A statement rebind does not buy that case back, because converging on the widened seed is not evidence
4924
+ # that the rebind was all there was. The capped pass above runs no rebind a counter guards past the third
4925
+ # iteration (`s = s.dup; n += 1; s = [e] if n > 3`), and the scans see only write nodes in the body, so a
4926
+ # lambda defined outside it (`close = -> { cur = nil }; … close.call`) or `binding.local_variable_set`
4927
+ # rebinds the local where no pass looks. Both converge on the seed next to a statement rebind, and
4928
+ # believing `String` there reports `undefined method` or an always-falsey `cur.nil?` on correct code.
4929
+ #
4930
+ # A pass's exit binding also joins every block-level `next` ({StatementEvaluator#evaluate_invocation}), and
4931
+ # a rebind on a `next` arm moves the converged binding off its seed while an unthreaded write on the
4932
+ # fall-through stays invisible: `if c; seen = 100; next false; end; (seen += 1) == 2` converged on `0 |
4933
+ # 100` and folded `find` to `nil`. So `moved` ({#captured_exit_bindings}) records which path moved each
4934
+ # name. One the FALL-THROUGH moved is believed; one only a `next` arm moved is unmoved for this test, and
4935
+ # floored with the hidden case; one neither moved is judged against its seed as before, so a block without
4936
+ # a block-level `next` is judged exactly as it always was.
4937
+ #
4938
+ # {UnthreadedRebinds} floors every name it can show is rebound out of the pass's sight before this test
4939
+ # runs, whatever the fixpoint converged to — the threaded `||=` that moves the fall-through beside an
4940
+ # unthreaded `(seen += 1) == 2` included — so this test only ever sees a name whose rebinds the scan found
4941
+ # threaded. It stays as the backstop for what the scan cannot see, and so the `0 | Integer` seed above
4942
+ # keeps its floor: skipping it for a scan-clean name would trade that backstop for precision.
4943
+ def unmoved_pins_floored(converged, seeds, moved)
4077
4944
  converged.to_h do |name, type|
4078
- seed = seeds[name]
4079
- next [name, type] unless type == seed && value_pinned?(seed)
4945
+ unmoved =
4946
+ case moved[name]
4947
+ when :fall_through then false
4948
+ when :jump then true
4949
+ else type == seeds[name]
4950
+ end
4951
+ next [name, type] unless unmoved && value_pinned?(CapturedLocals.bound_type(scope, name))
4080
4952
 
4081
4953
  [name, Type::Combinator.untyped]
4082
4954
  end
4083
4955
  end
4084
4956
 
4957
+ # A binding carries a pin when widening its values changes it, or when it is a `Tuple` / `HashShape` —
4958
+ # alone or as one member of a union. A shape carrier is pinned whatever its elements are: its arity and
4959
+ # key set are one assignment's, and the fixpoint's final widen erases a value but never a shape.
4085
4960
  def value_pinned?(type)
4086
- !type.nil? && Type::Combinator.widen_value_pinned(type) != type
4961
+ case type
4962
+ when nil then false
4963
+ when Type::Tuple, Type::HashShape then true
4964
+ when Type::Union then type.members.any? { |member| value_pinned?(member) }
4965
+ else Type::Combinator.widen_value_pinned(type) != type
4966
+ end
4087
4967
  end
4088
4968
 
4089
4969
  # 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)] }
4970
+ # same layering as {#type_block_body_with_param}), returning the per-name exit binding — the invocation's
4971
+ # exit, which joins every block-level `next` ({StatementEvaluator#evaluate_invocation}), so a rebind on a
4972
+ # jumping path reaches the fixpoint. `moved` records, per name, whether this pass's FALL-THROUGH moved it
4973
+ # off its entry (`:fall_through`) or only the joined `next` arms did (`:jump`), for {#unmoved_pins_floored}.
4974
+ # An exit mark is recorded into `marks` and carried into later passes, since the next iteration enters on
4975
+ # that binding. Threading is suppressed for the pass, as it is for every full body evaluation the
4976
+ # block-return pass runs.
4977
+ def captured_exit_bindings(block, param_types, base, bindings, moved, marks)
4978
+ entry = bindings.reduce(base) do |acc, (name, type)|
4979
+ CapturedLocals.bind(acc, name, type, optimistic: marks[name])
4980
+ end
4981
+ entry = BlockParameterBinder.new(expected_param_types: param_types).bind_onto(block, entry)
4982
+ _type, fall_through, exit_scope = without_block_body_threading do
4983
+ StatementEvaluator.new(scope: entry).evaluate_invocation(block)
4984
+ end
4985
+ bindings.to_h do |name, entry_type|
4986
+ exit_type = CapturedLocals.bound_type(exit_scope, name)
4987
+ record_capture_move(moved, name, entry_type, fall_through, exit_type)
4988
+ mark = CapturedLocals.optimistic_mark(exit_scope, name)
4989
+ marks[name] ||= mark if mark
4990
+ [name, exit_type]
4991
+ end
4992
+ end
4993
+
4994
+ # Which path of one pass moved `name` off its `entry_type`: the fall-through outranks a `next` arm, and a
4995
+ # name the fall-through moved in any pass stays moved.
4996
+ def record_capture_move(moved, name, entry_type, fall_through, exit_type)
4997
+ if moved_off?(entry_type, CapturedLocals.bound_type(fall_through, name))
4998
+ moved[name] = :fall_through
4999
+ elsif moved_off?(entry_type, exit_type)
5000
+ moved[name] ||= :jump
5001
+ end
5002
+ end
5003
+
5004
+ # True when `after` is not already contained in `before` — the same stability test `BodyFixpoint` applies.
5005
+ def moved_off?(before, after)
5006
+ return false if after.nil?
5007
+ return true if before.nil?
5008
+
5009
+ Type::Combinator.union(before, after) != before
4098
5010
  end
4099
5011
 
4100
5012
  def per_element_symbol_results(block_arg, element_types)
@@ -4318,15 +5230,6 @@ module Rigor
4318
5230
  converged[:__inject_acc__] || seed_acc
4319
5231
  end
4320
5232
 
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
5233
  def assemble_per_element_result(method_name, per_position, element_types)
4331
5234
  case method_name
4332
5235
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
@@ -4394,28 +5297,40 @@ module Rigor
4394
5297
  # `find` / `detect`: returns the first receiver element whose block result is Ruby-truthy, or `nil`
4395
5298
  # when no position folds to truthy.
4396
5299
  #
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]`.
5300
+ # When the first decisive truthy position is found, the answer is the corresponding receiver element,
5301
+ # joined with the element of every earlier position whose result is not a `Type::Constant`: such a
5302
+ # position may match first, but the search ends at the truthy one whatever it answers, so the call
5303
+ # never returns `nil`. When every position folds to falsey, the answer is `Constant[nil]`. With no
5304
+ # decisive truthy position and some undecided one, it declines ({#undecided_fold_floor}).
4401
5305
  def assemble_find_result(per_position, element_types)
4402
- return nil unless per_position.all?(Type::Constant)
5306
+ candidates = first_match_candidates(per_position)
5307
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
4403
5308
 
4404
- first_truthy_index = per_position.index { |type| truthy_constant?(type) }
4405
- return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
4406
-
4407
- element_types[first_truthy_index]
5309
+ Type::Combinator.union(*candidates.map { |index| element_types[index] })
4408
5310
  end
4409
5311
 
4410
5312
  # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
4411
- # nothing matches.
5313
+ # nothing matches, on {#assemble_find_result}'s terms.
4412
5314
  def assemble_find_index_result(per_position)
4413
- return nil unless per_position.all?(Type::Constant)
5315
+ candidates = first_match_candidates(per_position)
5316
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
5317
+
5318
+ Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
5319
+ end
5320
+
5321
+ NO_MATCH = Type::Combinator.constant_of(nil)
5322
+ private_constant :NO_MATCH
4414
5323
 
5324
+ # The positions `find` may stop at — the first decisive truthy one and every undecided one before it —
5325
+ # `NO_MATCH` when every position folds falsey, or nil when an undecided position has no truthy one after it.
5326
+ def first_match_candidates(per_position)
4415
5327
  first_truthy_index = per_position.index { |type| truthy_constant?(type) }
4416
- return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
5328
+ if first_truthy_index.nil?
5329
+ return per_position.all?(Type::Constant) ? NO_MATCH : nil
5330
+ end
4417
5331
 
4418
- Type::Combinator.constant_of(first_truthy_index)
5332
+ undecided = (0...first_truthy_index).reject { |index| per_position[index].is_a?(Type::Constant) }
5333
+ undecided + [first_truthy_index]
4419
5334
  end
4420
5335
 
4421
5336
  def truthy_constant?(type)
@@ -4436,28 +5351,47 @@ module Rigor
4436
5351
  # `Constant[Symbol | String]` — otherwise the tier declines (the new key cannot be used as a static
4437
5352
  # HashShape index). Collisions (two old keys mapping to the same new key) also decline.
4438
5353
  #
5354
+ # Two runtime behaviours fall outside this model, and both decline:
5355
+ #
5356
+ # - A call with arguments. `transform_keys(mapping)` renames the keys `mapping` names and yields only
5357
+ # the rest to the block, and the fold never reads the mapping. `transform_values` takes none.
5358
+ # - An in-place form whose block could see the receiver ({ReceiverBlindBlock}). The bang forms rewrite
5359
+ # the receiver pair by pair, so the block can see pairs it has already rewritten, while the fold types
5360
+ # every pair against the pre-call shape. The non-bang forms build a new hash and keep the exact fold.
5361
+ #
4439
5362
  # Returns `nil` on any decline so the dispatcher falls through to `RbsDispatch` and gets the widened
4440
5363
  # `Hash[K, V]` answer.
4441
5364
  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?
5365
+ return nil unless hash_shape_fold_applicable?(call_node, receiver_type)
4446
5366
 
4447
5367
  block_arg = call_node.block
4448
- return nil if block_arg.nil?
5368
+ return nil if in_place_block_sees_receiver?(call_node.name, block_arg)
4449
5369
 
4450
5370
  if %i[transform_values transform_values!].include?(call_node.name)
4451
5371
  fold_hash_shape_transform_values(receiver_type, block_arg)
4452
5372
  else
4453
- fold_hash_shape_transform_keys(receiver_type, block_arg)
5373
+ fold_hash_shape_transform_keys(receiver_type, block_arg, in_place: call_node.name == :transform_keys!)
4454
5374
  end
4455
5375
  end
4456
5376
 
5377
+ def hash_shape_fold_applicable?(call_node, receiver_type)
5378
+ HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name) &&
5379
+ receiver_type.is_a?(Type::HashShape) && receiver_type.closed? && receiver_type.optional_keys.empty? &&
5380
+ call_node.arguments.nil? && !call_node.block.nil?
5381
+ end
5382
+
5383
+ def in_place_block_sees_receiver?(method_name, block_arg)
5384
+ IN_PLACE_HASH_SHAPE_TRANSFORMS.include?(method_name) &&
5385
+ block_arg.is_a?(Prism::BlockNode) && !ReceiverBlindBlock.blind?(block_arg, scope)
5386
+ end
5387
+
4457
5388
  def fold_hash_shape_transform_values(shape, block_arg)
5389
+ captured = hash_block_captured_bindings(block_arg, shape.pairs.values)
5390
+ return hash_shape_values_floor(shape) if tail_only_pairs_floored?(shape, block_arg, captured)
5391
+
4458
5392
  new_pairs = {}
4459
- shape.pairs.each do |key, value|
4460
- new_value = apply_hash_block(block_arg, value)
5393
+ shape.pairs.each_with_index do |(key, value), position|
5394
+ new_value = apply_hash_block(block_arg, value, captured: captured, position: position)
4461
5395
  return nil if new_value.nil?
4462
5396
 
4463
5397
  new_pairs[key] = new_value
@@ -4465,28 +5399,125 @@ module Rigor
4465
5399
  Type::Combinator.hash_shape_of(new_pairs)
4466
5400
  end
4467
5401
 
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)
5402
+ def fold_hash_shape_transform_keys(shape, block_arg, in_place: false)
5403
+ key_types = shape.pairs.keys.map { |key| Type::Combinator.constant_of(key) }
5404
+ captured = hash_block_captured_bindings(block_arg, key_types)
5405
+ return hash_keys_floor(shape) if tail_only_pairs_floored?(shape, block_arg, captured)
4474
5406
 
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)
5407
+ new_pairs = {}
5408
+ key_types.zip(shape.pairs.values).each_with_index do |(key_type, value), position|
5409
+ typed_key = apply_hash_block(block_arg, key_type, captured: captured, position: position)
5410
+ new_key = new_shape_key(typed_key, new_pairs)
5411
+ return undecided_keys_floor(shape, block_arg, key_types, captured) if new_key.nil?
4478
5412
 
4479
5413
  new_pairs[new_key] = value
4480
5414
  end
4481
- Type::Combinator.hash_shape_of(new_pairs)
5415
+ Type::Combinator.hash_shape_of(in_place ? in_place_key_order(shape.pairs, new_pairs) : new_pairs)
5416
+ end
5417
+
5418
+ # `transform_keys!` rewrites the receiver in place, pair by pair, so its pairs end in a different order from
5419
+ # the new hash `transform_keys` builds. For each old pair, CRuby's `rb_hash_transform_keys_bang` deletes the
5420
+ # old key unless an earlier pair already produced it as a new key, then stores the new key: in place when the
5421
+ # new key is an old key not yet reached, appended otherwise. `{ a: 1, b: 2, c: 3 }.transform_keys! { |k| k ==
5422
+ # :b ? :c : (k == :c ? :b : k) }` is `{ c: 2, a: 1, b: 3 }`, not `{ a: 1, c: 2, b: 3 }`, and `r.keys.first ==
5423
+ # :a` folded always-truthy on the wrong order. Replaying those steps over a Ruby `Hash`, which orders its keys
5424
+ # the same way, gives the same order. `new_pairs` is collision-free and in the old pairs' order, so every
5425
+ # old key is either deleted or overwritten, and no old value survives.
5426
+ def in_place_key_order(old_pairs, new_pairs)
5427
+ produced = {}
5428
+ old_pairs.keys.zip(new_pairs).each_with_object(old_pairs.dup) do |(old_key, (new_key, value)), result|
5429
+ result.delete(old_key) unless produced.key?(old_key)
5430
+ result[new_key] = value
5431
+ produced[new_key] = true
5432
+ end
5433
+ end
5434
+
5435
+ # The key a pair's block result can index the new `HashShape` with — a `Constant` Symbol or String that no
5436
+ # earlier pair already took — or nil.
5437
+ def new_shape_key(new_key_type, new_pairs)
5438
+ return nil unless new_key_type.is_a?(Type::Constant)
5439
+
5440
+ new_key = new_key_type.value
5441
+ return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
5442
+
5443
+ new_pairs.key?(new_key) ? nil : new_key
5444
+ end
5445
+
5446
+ # A key fold that cannot build a `HashShape` declines to the dispatcher — except under the suppression, where
5447
+ # the dispatcher's block-return pass is typed tail-only too and read a captured value the body mutates in
5448
+ # place at its entry contents: `buf = +"k"; … { a: 1 }.transform_keys { |k| buf << w.to_s; buf }` answered
5449
+ # `Hash["k", 1]` for `{ "k1" => 1 }`, though the pairs themselves were typed over the #587 (b) binding that
5450
+ # answers `buf`. There the fold answers from the keys it typed, `Hash[union(<new keys>), union(<values>)]`,
5451
+ # and takes the plain floor ({#hash_keys_floor}) when a pair could not be typed at all. The pairs are
5452
+ # re-typed for it, which only a suppressed, undecided key fold pays. That pass now re-answers such a name
5453
+ # itself ({#prefix_answered_scope}), so a decline would no longer read `"k"`; the fold still answers from
5454
+ # the pairs it has typed.
5455
+ def undecided_keys_floor(shape, block_arg, key_types, captured)
5456
+ return nil unless block_body_threading_suppressed?
5457
+
5458
+ new_key_types = key_types.each_with_index.map do |key_type, position|
5459
+ apply_hash_block(block_arg, key_type, captured: captured, position: position)
5460
+ end
5461
+ return hash_keys_floor(shape) if new_key_types.any?(&:nil?)
5462
+
5463
+ Type::Combinator.nominal_of(
5464
+ "Hash", type_args: [Type::Combinator.union(*new_key_types), Type::Combinator.union(*shape.pairs.values)]
5465
+ )
5466
+ end
5467
+
5468
+ # The per-pair twin of issue #587 (b)'s first-iteration pin. Every pair is typed from the SAME entry scope,
5469
+ # so a body that rebinds a captured outer local answered the first pair's value at every pair: `total = 0;
5470
+ # { x: 1, y: 2 }.transform_values { total += 1 }` folded to `{ x: 1, y: 1 }` (runtime `{ x: 1, y: 2 }`),
5471
+ # and `r[:y] == 1` then fired always-truthy on correct code. The pairs take the per-element fold's
5472
+ # captured-local entry binding ({#per_element_captured_bindings}) — the fixpoint's block parameter bound
5473
+ # to the union of the values, or of the `Constant` keys — rather than a copy of it: issue #1198 is where
5474
+ # the block-entry models consolidate. A captured local the body does not change keeps its exact per-pair
5475
+ # fold, and a `&:symbol` block captures nothing.
5476
+ #
5477
+ # An empty shape has no pair to type, so it does not pay for the fixpoint.
5478
+ def hash_block_captured_bindings(block_arg, param_types)
5479
+ return nil unless block_arg.is_a?(Prism::BlockNode)
5480
+ return nil if param_types.empty?
5481
+
5482
+ per_element_captured_bindings(block_arg, param_types)
5483
+ end
5484
+
5485
+ # The per-pair fold has no arity cap, so running nested inside a body another pass is evaluating whole
5486
+ # ({#tail_only_walk?}'s second case) is the one way it reaches a tail-only pair the dependency predicate
5487
+ # can flag, and that pair holds the same pre-state {#tail_only_body_floor} describes:
5488
+ # `{ x: [], y: [] }.transform_values do |a| a << w; a end` inside a threaded `m.synchronize do w = v; …
5489
+ # end` answered `{ x: [], y: [] }` for a hash whose values each hold `[1]`, and `r[:x].first + 1` was
5490
+ # then reported on correct code. The floor is the Tuple fold's, applied per pair and over the same
5491
+ # `captured` names, with the fold answering it rather than declining: a decline reached the dispatcher,
5492
+ # whose block-return pass the same suppression typed tail-only, so `k = "#{k}#{w}"; k` read its keys
5493
+ # as the entry `:a | :b` until that pass re-answered the stale name itself ({#prefix_answered_scope}).
5494
+ # An empty shape types no pair, so it keeps its exact `{}`.
5495
+ def tail_only_pairs_floored?(shape, block_arg, captured)
5496
+ !shape.pairs.empty? && block_arg.is_a?(Prism::BlockNode) && block_body_threading_suppressed? &&
5497
+ unanswered_tail_dependency?(block_arg, captured)
5498
+ end
5499
+
5500
+ # `transform_values` keeps the keys and answers every value `Dynamic[top]`.
5501
+ def hash_shape_values_floor(shape)
5502
+ Type::Combinator.hash_shape_of(shape.pairs.transform_values { Type::Combinator.untyped })
5503
+ end
5504
+
5505
+ # `transform_keys` keeps the values, but a `Dynamic[top]` key cannot index a `HashShape`, and two keys may
5506
+ # collide, so the floor is the plain `Hash` over them.
5507
+ def hash_keys_floor(shape)
5508
+ Type::Combinator.nominal_of(
5509
+ "Hash", type_args: [Type::Combinator.untyped, Type::Combinator.union(*shape.pairs.values)]
5510
+ )
4482
5511
  end
4483
5512
 
4484
5513
  # 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)
5514
+ # `param_type` and returns the resulting type, or `nil` on failure. `captured:` is the pair-independent
5515
+ # entry binding from {#hash_block_captured_bindings}, and `position:` the pair's index, which picks the
5516
+ # index `||=` sites that binding marks there.
5517
+ def apply_hash_block(block_arg, param_type, captured: nil, position: nil)
4487
5518
  case block_arg
4488
5519
  when Prism::BlockNode
4489
- type_block_body_with_param(block_arg, [param_type])
5520
+ type_block_body_with_param(block_arg, [param_type], captured: captured, position: position)
4490
5521
  when Prism::BlockArgumentNode
4491
5522
  expression = block_arg.expression
4492
5523
  return nil unless expression.is_a?(Prism::SymbolNode)
@@ -4503,13 +5534,18 @@ module Rigor
4503
5534
  end
4504
5535
  end
4505
5536
 
4506
- # `captured:` — issue #587 (b): the per-name entry binding of every captured outer local the body rebinds
5537
+ # `captured:` — issue #587 (b): the per-name entry binding of every captured outer local and instance
5538
+ # variable the body rebinds, and of every captured local it mutates in place
4507
5539
  # ({#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)
5540
+ # `position:` is the element's index in the fold; the binding marks the index `||=` sites an earlier
5541
+ # position may have filled there ({RepeatedOrWrites::Marks}).
5542
+ def type_block_body_with_param(block_node, expected_param_types, captured: nil, position: nil)
5543
+ # Issue #1358 — a position past the first runs after an earlier one may have rebound the match globals.
5544
+ entry = MatchRebinding.block_entry(scope, block_node)
5545
+ block_scope = captured ? captured.lay(entry, position: position) : entry
5546
+ block_scope = BlockParameterBinder.new(expected_param_types: expected_param_types)
5547
+ .bind_onto(block_node, block_scope)
5548
+ type_block_body(block_node, block_scope, captured: captured)
4513
5549
  rescue StandardError
4514
5550
  nil
4515
5551
  end