rigortype 0.3.8 → 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 (634) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/capability_roles/capability_roles.rbs +36 -0
  4. data/data/core_overlay/enumerable.rbs +51 -0
  5. data/data/core_overlay/enumerator.rbs +84 -0
  6. data/data/core_overlay/hash_rbs3.rbs +41 -0
  7. data/data/core_overlay/process.rbs +40 -0
  8. data/data/core_overlay/string_io.rbs +33 -0
  9. data/data/effects/core.yml +3 -3
  10. data/data/gem_overlay/activesupport/core_ext.rbs +236 -17
  11. data/docs/handbook/02-everyday-types.md +17 -14
  12. data/docs/handbook/03-narrowing.md +132 -14
  13. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  14. data/docs/handbook/05-methods-and-blocks.md +1 -1
  15. data/docs/handbook/06-classes.md +2 -2
  16. data/docs/handbook/07-rbs-and-extended.md +19 -1
  17. data/docs/handbook/10-sorbet.md +9 -10
  18. data/docs/handbook/11-sig-gen.md +525 -13
  19. data/docs/handbook/README.md +1 -1
  20. data/docs/handbook/appendix-elixir.md +2 -2
  21. data/docs/handbook/appendix-go.md +2 -2
  22. data/docs/handbook/appendix-java-csharp.md +2 -2
  23. data/docs/handbook/appendix-mypy.md +2 -2
  24. data/docs/handbook/appendix-phpstan.md +1 -1
  25. data/docs/handbook/appendix-rust.md +1 -1
  26. data/docs/handbook/appendix-type-theory.md +4 -4
  27. data/docs/handbook/appendix-typescript.md +1 -1
  28. data/docs/llms.txt +2 -0
  29. data/docs/manual/02-cli-reference.md +95 -6
  30. data/docs/manual/03-configuration.md +18 -0
  31. data/docs/manual/04-diagnostics.md +15 -0
  32. data/docs/manual/07-plugins.md +15 -5
  33. data/docs/manual/08-skills.md +23 -2
  34. data/docs/manual/10-mcp-server.md +3 -2
  35. data/docs/manual/11-ci.md +9 -0
  36. data/docs/manual/15-type-protection-coverage.md +8 -0
  37. data/docs/manual/16-rbs-extended-annotations.md +46 -8
  38. data/docs/manual/18-removing-dead-code.md +10 -8
  39. data/docs/manual/19-effect-labels.md +10 -0
  40. data/docs/manual/plugins/README.md +7 -0
  41. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  42. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  43. data/docs/manual/plugins/rigor-actionpack.md +261 -0
  44. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  45. data/docs/manual/plugins/rigor-activejob.md +7 -0
  46. data/docs/manual/plugins/rigor-activerecord.md +138 -4
  47. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  48. data/docs/manual/plugins/rigor-activesupport-core-ext.md +43 -7
  49. data/docs/manual/plugins/rigor-grape.md +106 -0
  50. data/docs/manual/plugins/rigor-graphql.md +23 -2
  51. data/docs/manual/plugins/rigor-pundit.md +8 -1
  52. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  53. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  54. data/docs/manual/plugins/rigor-rbs-inline.md +229 -1
  55. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  56. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  57. data/exe/rigor +19 -4
  58. data/lib/rigor/analysis/baseline.rb +3 -3
  59. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +1 -1
  60. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +1 -5
  61. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  62. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +10 -2
  63. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  64. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +6 -4
  65. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +15 -6
  66. data/lib/rigor/analysis/check_rules/rule_ids.rb +21 -6
  67. data/lib/rigor/analysis/check_rules/rule_walk.rb +41 -5
  68. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +1 -1
  69. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +1 -1
  70. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  71. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  72. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +38 -4
  73. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +0 -1
  74. data/lib/rigor/analysis/check_rules.rb +718 -79
  75. data/lib/rigor/analysis/crash_signature.rb +8 -24
  76. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  77. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +1 -1
  78. data/lib/rigor/analysis/dependency_source_inference/builder.rb +0 -2
  79. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +0 -2
  80. data/lib/rigor/analysis/dependency_source_inference/index.rb +5 -5
  81. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +1 -2
  82. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  83. data/lib/rigor/analysis/effects_cache_probe.rb +3 -4
  84. data/lib/rigor/analysis/erb_template_detector.rb +1 -2
  85. data/lib/rigor/analysis/fact_store.rb +15 -1
  86. data/lib/rigor/analysis/incremental.rb +42 -0
  87. data/lib/rigor/analysis/incremental_session.rb +117 -26
  88. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +0 -1
  89. data/lib/rigor/analysis/project_scan.rb +11 -1
  90. data/lib/rigor/analysis/reachability/graph.rb +3 -5
  91. data/lib/rigor/analysis/reachability/plugin_roots.rb +5 -5
  92. data/lib/rigor/analysis/reachability/project_files.rb +2 -2
  93. data/lib/rigor/analysis/reachability/scan.rb +5 -5
  94. data/lib/rigor/analysis/reachability/scan_cache.rb +3 -2
  95. data/lib/rigor/analysis/reachability/signature_scan.rb +2 -2
  96. data/lib/rigor/analysis/result.rb +1 -3
  97. data/lib/rigor/analysis/rule_catalog.rb +131 -1
  98. data/lib/rigor/analysis/run_cache_key.rb +21 -12
  99. data/lib/rigor/analysis/run_cache_probe.rb +3 -5
  100. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -8
  101. data/lib/rigor/analysis/runner/declaration_position.rb +1 -2
  102. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +340 -33
  103. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +2 -3
  104. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +18 -14
  105. data/lib/rigor/analysis/runner/pool_coordinator.rb +357 -97
  106. data/lib/rigor/analysis/runner/project_pre_passes.rb +38 -13
  107. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  108. data/lib/rigor/analysis/runner.rb +407 -51
  109. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  110. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  111. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  112. data/lib/rigor/analysis/template_units.rb +399 -0
  113. data/lib/rigor/analysis/worker_session.rb +62 -20
  114. data/lib/rigor/bleeding_edge.rb +10 -24
  115. data/lib/rigor/builtins/hkt_builtins.rb +3 -2
  116. data/lib/rigor/builtins/imported_refinements.rb +159 -21
  117. data/lib/rigor/builtins/predefined_constant_refinements.rb +5 -7
  118. data/lib/rigor/builtins/regex_refinement.rb +21 -13
  119. data/lib/rigor/builtins/static_return_refinements.rb +7 -6
  120. data/lib/rigor/cache/annotation_location.rb +2 -4
  121. data/lib/rigor/cache/descriptor.rb +63 -20
  122. data/lib/rigor/cache/engine_source.rb +33 -5
  123. data/lib/rigor/cache/file_digest.rb +9 -1
  124. data/lib/rigor/cache/incremental_snapshot.rb +72 -6
  125. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  126. data/lib/rigor/cache/rbs_class_ancestor_table.rb +0 -3
  127. data/lib/rigor/cache/rbs_class_type_param_names.rb +0 -3
  128. data/lib/rigor/cache/rbs_constant_table.rb +0 -3
  129. data/lib/rigor/cache/rbs_descriptor.rb +112 -14
  130. data/lib/rigor/cache/rbs_environment.rb +8 -4
  131. data/lib/rigor/cache/rbs_known_class_names.rb +0 -3
  132. data/lib/rigor/cache/store.rb +76 -32
  133. data/lib/rigor/ci_detector.rb +1 -0
  134. data/lib/rigor/cli/annotate_command.rb +5 -6
  135. data/lib/rigor/cli/check_command.rb +57 -10
  136. data/lib/rigor/cli/check_invocation.rb +6 -11
  137. data/lib/rigor/cli/check_runner_factory.rb +1 -5
  138. data/lib/rigor/cli/coverage_command.rb +1 -1
  139. data/lib/rigor/cli/coverage_mutation.rb +12 -3
  140. data/lib/rigor/cli/coverage_scan.rb +1 -5
  141. data/lib/rigor/cli/diff_command.rb +1 -1
  142. data/lib/rigor/cli/doc_links.rb +4 -4
  143. data/lib/rigor/cli/docs_command.rb +6 -6
  144. data/lib/rigor/cli/doctor_command.rb +85 -38
  145. data/lib/rigor/cli/effects_command.rb +2 -2
  146. data/lib/rigor/cli/effects_diff_renderer.rb +3 -3
  147. data/lib/rigor/cli/effects_snapshot_command.rb +1 -1
  148. data/lib/rigor/cli/explain_command.rb +34 -1
  149. data/lib/rigor/cli/fused_protection_report.rb +7 -1
  150. data/lib/rigor/cli/lsp_command.rb +1 -1
  151. data/lib/rigor/cli/mcp_command.rb +1 -1
  152. data/lib/rigor/cli/measurement_integrity_warning.rb +4 -5
  153. data/lib/rigor/cli/mutation_fork_scan.rb +6 -6
  154. data/lib/rigor/cli/mutation_protection_report.rb +7 -1
  155. data/lib/rigor/cli/plugin_command.rb +4 -4
  156. data/lib/rigor/cli/plugins_command.rb +2 -1
  157. data/lib/rigor/cli/prism_colorizer.rb +1 -2
  158. data/lib/rigor/cli/protection_fork_scan.rb +6 -6
  159. data/lib/rigor/cli/show_bleedingedge_command.rb +1 -1
  160. data/lib/rigor/cli/sig_gen_command.rb +212 -31
  161. data/lib/rigor/cli/skill_command.rb +2 -2
  162. data/lib/rigor/cli/skill_deep_probe.rb +4 -4
  163. data/lib/rigor/cli/skill_describe.rb +35 -35
  164. data/lib/rigor/cli/trace_command.rb +4 -4
  165. data/lib/rigor/cli/trace_renderer.rb +5 -6
  166. data/lib/rigor/cli/triage_command.rb +1 -1
  167. data/lib/rigor/cli/type_of_command.rb +29 -11
  168. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  169. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  170. data/lib/rigor/cli/type_scan_command.rb +4 -4
  171. data/lib/rigor/cli/unused_command.rb +11 -3
  172. data/lib/rigor/cli/upgrade_command.rb +1 -1
  173. data/lib/rigor/cli.rb +89 -9
  174. data/lib/rigor/config_audit.rb +30 -5
  175. data/lib/rigor/configuration/severity_profile.rb +25 -9
  176. data/lib/rigor/configuration.rb +109 -7
  177. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  178. data/lib/rigor/effects/attribution.rb +12 -5
  179. data/lib/rigor/effects/callee_rule.rb +368 -0
  180. data/lib/rigor/effects/catalog.rb +7 -4
  181. data/lib/rigor/effects/collector.rb +11 -5
  182. data/lib/rigor/effects/config_envelopes.rb +15 -10
  183. data/lib/rigor/effects/definition_context.rb +179 -0
  184. data/lib/rigor/effects/definition_lines.rb +25 -6
  185. data/lib/rigor/effects/effect_table.rb +11 -4
  186. data/lib/rigor/effects/entry_points.rb +0 -2
  187. data/lib/rigor/effects/envelope_check.rb +9 -9
  188. data/lib/rigor/effects/envelope_index.rb +20 -8
  189. data/lib/rigor/effects/file_collection.rb +59 -5
  190. data/lib/rigor/effects/framework_units.rb +5 -7
  191. data/lib/rigor/effects/identity.rb +20 -6
  192. data/lib/rigor/effects/inline_anchor.rb +7 -7
  193. data/lib/rigor/effects/label_intent.rb +3 -4
  194. data/lib/rigor/effects/liskov_check.rb +8 -8
  195. data/lib/rigor/effects/local_ownership.rb +38 -12
  196. data/lib/rigor/effects/method_key.rb +22 -1
  197. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  198. data/lib/rigor/effects/plugin_facts.rb +47 -37
  199. data/lib/rigor/effects/propagator.rb +297 -19
  200. data/lib/rigor/effects/registry.rb +1 -1
  201. data/lib/rigor/effects/scanner.rb +122 -73
  202. data/lib/rigor/effects/signature_sources.rb +4 -6
  203. data/lib/rigor/effects/snapshot.rb +12 -11
  204. data/lib/rigor/effects/snapshot_diff.rb +1 -1
  205. data/lib/rigor/effects/summary.rb +27 -4
  206. data/lib/rigor/effects/unit_scan.rb +393 -38
  207. data/lib/rigor/effects/unknown_label_check.rb +3 -8
  208. data/lib/rigor/effects/unknown_label_report.rb +3 -4
  209. data/lib/rigor/effects/visibility.rb +101 -0
  210. data/lib/rigor/environment/bundle_sig_discovery.rb +6 -6
  211. data/lib/rigor/environment/class_registry.rb +3 -1
  212. data/lib/rigor/environment/failure_slot.rb +2 -2
  213. data/lib/rigor/environment/installed_gem_set.rb +85 -0
  214. data/lib/rigor/environment/lockfile_resolver.rb +68 -4
  215. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  216. data/lib/rigor/environment/member_consistency.rb +298 -0
  217. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -4
  218. data/lib/rigor/environment/rbs_collection_discovery.rb +5 -5
  219. data/lib/rigor/environment/rbs_coverage_report.rb +5 -5
  220. data/lib/rigor/environment/rbs_hierarchy.rb +3 -1
  221. data/lib/rigor/environment/rbs_loader.rb +674 -79
  222. data/lib/rigor/environment.rb +174 -24
  223. data/lib/rigor/flow_contribution/merger.rb +0 -2
  224. data/lib/rigor/flow_contribution.rb +11 -13
  225. data/lib/rigor/hashing/xxh3.rb +264 -0
  226. data/lib/rigor/inference/acceptance.rb +292 -17
  227. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  228. data/lib/rigor/inference/block_call_timing.rb +338 -0
  229. data/lib/rigor/inference/block_parameter_binder.rb +75 -30
  230. data/lib/rigor/inference/block_repetition.rb +71 -0
  231. data/lib/rigor/inference/body_fixpoint.rb +7 -6
  232. data/lib/rigor/inference/budget_trace.rb +3 -6
  233. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  234. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  235. data/lib/rigor/inference/captured_locals.rb +389 -18
  236. data/lib/rigor/inference/closure_escape_analyzer.rb +159 -17
  237. data/lib/rigor/inference/content_join.rb +200 -27
  238. data/lib/rigor/inference/coverage_scanner.rb +4 -6
  239. data/lib/rigor/inference/def_return_typer.rb +11 -7
  240. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  241. data/lib/rigor/inference/dynamic_origin.rb +1 -1
  242. data/lib/rigor/inference/element_read_widening.rb +180 -0
  243. data/lib/rigor/inference/error_info.rb +196 -0
  244. data/lib/rigor/inference/expression_typer.rb +2124 -468
  245. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  246. data/lib/rigor/inference/fork_map.rb +5 -7
  247. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  248. data/lib/rigor/inference/global_write_census.rb +239 -0
  249. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  250. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  251. data/lib/rigor/inference/hkt_reducer.rb +2 -3
  252. data/lib/rigor/inference/hkt_registry.rb +4 -7
  253. data/lib/rigor/inference/index_write_widening.rb +16 -7
  254. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  255. data/lib/rigor/inference/jump_targets.rb +82 -0
  256. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  257. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  258. data/lib/rigor/inference/last_line.rb +340 -0
  259. data/lib/rigor/inference/last_status.rb +144 -0
  260. data/lib/rigor/inference/macro_block_self_type.rb +168 -21
  261. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  262. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  263. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  264. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  265. data/lib/rigor/inference/match_rebinding.rb +392 -0
  266. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  267. data/lib/rigor/inference/method_dispatcher/block_folding.rb +87 -33
  268. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +1 -1
  269. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +305 -19
  270. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  271. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  272. data/lib/rigor/inference/method_dispatcher/file_folding.rb +1 -1
  273. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  274. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  275. data/lib/rigor/inference/method_dispatcher/json_folding.rb +1 -1
  276. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -1
  277. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  278. data/lib/rigor/inference/method_dispatcher/math_folding.rb +41 -3
  279. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -5
  280. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +165 -111
  281. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  282. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  283. data/lib/rigor/inference/method_dispatcher/random_folding.rb +82 -0
  284. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +1046 -69
  285. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +25 -4
  286. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +2 -8
  287. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +1 -1
  288. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  289. data/lib/rigor/inference/method_dispatcher/set_folding.rb +1 -1
  290. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +129 -54
  291. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +1 -1
  292. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +1 -2
  293. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -6
  294. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +85 -4
  295. data/lib/rigor/inference/method_dispatcher/time_folding.rb +1 -1
  296. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +1 -2
  297. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  298. data/lib/rigor/inference/method_dispatcher.rb +225 -25
  299. data/lib/rigor/inference/method_parameter_binder.rb +12 -9
  300. data/lib/rigor/inference/multi_target_binder.rb +341 -55
  301. data/lib/rigor/inference/mutation_rejoin.rb +171 -0
  302. data/lib/rigor/inference/mutation_widening.rb +156 -85
  303. data/lib/rigor/inference/narrowing.rb +835 -162
  304. data/lib/rigor/inference/operand_effects.rb +167 -0
  305. data/lib/rigor/inference/operand_walk.rb +88 -0
  306. data/lib/rigor/inference/optimistic_origin.rb +153 -16
  307. data/lib/rigor/inference/origin_lookup.rb +2 -3
  308. data/lib/rigor/inference/parameter_inference_collector.rb +7 -7
  309. data/lib/rigor/inference/pre_eval_constants.rb +6 -5
  310. data/lib/rigor/inference/precision_scanner.rb +3 -4
  311. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  312. data/lib/rigor/inference/project_patched_methods.rb +9 -4
  313. data/lib/rigor/inference/project_patched_scanner.rb +10 -6
  314. data/lib/rigor/inference/protection_scanner.rb +1 -2
  315. data/lib/rigor/inference/range_constant.rb +57 -0
  316. data/lib/rigor/inference/rbs_type_translator.rb +49 -11
  317. data/lib/rigor/inference/receiver_alias.rb +93 -4
  318. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  319. data/lib/rigor/inference/refinement_mutation.rb +76 -0
  320. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  321. data/lib/rigor/inference/return_barrier.rb +54 -0
  322. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  323. data/lib/rigor/inference/scope_indexer.rb +5228 -627
  324. data/lib/rigor/inference/statement_evaluator.rb +3452 -595
  325. data/lib/rigor/inference/stored_block_call.rb +54 -0
  326. data/lib/rigor/inference/string_mutation.rb +97 -0
  327. data/lib/rigor/inference/struct_fold_safety.rb +6 -7
  328. data/lib/rigor/inference/synthetic_method_index.rb +1 -2
  329. data/lib/rigor/inference/synthetic_method_scanner.rb +4 -6
  330. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  331. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  332. data/lib/rigor/inference/version_guard.rb +28 -21
  333. data/lib/rigor/inference/void_origin.rb +3 -3
  334. data/lib/rigor/inference/void_tail_summary.rb +2 -4
  335. data/lib/rigor/language_server/buffer_table.rb +4 -4
  336. data/lib/rigor/language_server/completion_provider.rb +1 -1
  337. data/lib/rigor/language_server/debouncer.rb +0 -1
  338. data/lib/rigor/language_server/diagnostic_publisher.rb +7 -6
  339. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  340. data/lib/rigor/language_server/folding_range_provider.rb +1 -1
  341. data/lib/rigor/language_server/hover_provider.rb +1 -1
  342. data/lib/rigor/language_server/hover_renderer.rb +4 -4
  343. data/lib/rigor/language_server/incremental_sync.rb +6 -6
  344. data/lib/rigor/language_server/project_context.rb +7 -8
  345. data/lib/rigor/language_server/publish_batcher.rb +2 -2
  346. data/lib/rigor/language_server/selection_range_provider.rb +2 -2
  347. data/lib/rigor/language_server/server.rb +9 -9
  348. data/lib/rigor/language_server/signature_help_provider.rb +1 -1
  349. data/lib/rigor/language_server/uri.rb +1 -1
  350. data/lib/rigor/mcp/server.rb +2 -1
  351. data/lib/rigor/plugin/base.rb +258 -26
  352. data/lib/rigor/plugin/box_probe.rb +91 -0
  353. data/lib/rigor/plugin/bundled_catalog.rb +167 -0
  354. data/lib/rigor/plugin/effect_attribution.rb +65 -11
  355. data/lib/rigor/plugin/effect_entry_points.rb +3 -3
  356. data/lib/rigor/plugin/fact_store.rb +6 -6
  357. data/lib/rigor/plugin/io_boundary.rb +93 -8
  358. data/lib/rigor/plugin/isolation.rb +29 -6
  359. data/lib/rigor/plugin/loader.rb +33 -13
  360. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  361. data/lib/rigor/plugin/macro/heredoc_template.rb +1 -1
  362. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  363. data/lib/rigor/plugin/manifest.rb +94 -11
  364. data/lib/rigor/plugin/registry.rb +76 -12
  365. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +21 -4
  366. data/lib/rigor/plugin/template_unit.rb +196 -0
  367. data/lib/rigor/plugin/trust_policy.rb +2 -4
  368. data/lib/rigor/plugin/type_node_resolver.rb +3 -3
  369. data/lib/rigor/plugin.rb +1 -0
  370. data/lib/rigor/plugin_gap_advisory.rb +96 -0
  371. data/lib/rigor/project_environment.rb +138 -0
  372. data/lib/rigor/protection/analysis_guard.rb +93 -14
  373. data/lib/rigor/protection/closure_kill_oracle.rb +67 -23
  374. data/lib/rigor/protection/dependency_closure.rb +5 -8
  375. data/lib/rigor/protection/diagnostic_oracle.rb +2 -2
  376. data/lib/rigor/protection/discovery_seed.rb +14 -12
  377. data/lib/rigor/protection/kill_signature.rb +1 -4
  378. data/lib/rigor/protection/measurement_integrity.rb +1 -3
  379. data/lib/rigor/protection/mutation_cache.rb +7 -11
  380. data/lib/rigor/protection/mutation_scanner.rb +12 -15
  381. data/lib/rigor/protection/mutator.rb +2 -1
  382. data/lib/rigor/protection/test_suite_oracle.rb +5 -5
  383. data/lib/rigor/rbs_extended/conformance_checker.rb +4 -3
  384. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  385. data/lib/rigor/rbs_extended/reporter.rb +36 -10
  386. data/lib/rigor/rbs_extended.rb +88 -21
  387. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  388. data/lib/rigor/reflection/constant_path.rb +139 -0
  389. data/lib/rigor/reflection.rb +93 -110
  390. data/lib/rigor/runtime/jit.rb +6 -8
  391. data/lib/rigor/scope/discovery_index.rb +165 -2
  392. data/lib/rigor/scope.rb +1041 -32
  393. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  394. data/lib/rigor/sig_gen/classification.rb +22 -5
  395. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  396. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  397. data/lib/rigor/sig_gen/generator.rb +673 -53
  398. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  399. data/lib/rigor/sig_gen/layout_index.rb +3 -4
  400. data/lib/rigor/sig_gen/meta_class_shape.rb +3 -5
  401. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  402. data/lib/rigor/sig_gen/observation_collector.rb +18 -16
  403. data/lib/rigor/sig_gen/path_mapper.rb +4 -6
  404. data/lib/rigor/sig_gen/rbs_validity.rb +4 -4
  405. data/lib/rigor/sig_gen/renderer.rb +128 -14
  406. data/lib/rigor/sig_gen/skip_reason_catalog.rb +154 -0
  407. data/lib/rigor/sig_gen/superclass_spelling.rb +27 -0
  408. data/lib/rigor/sig_gen/type_elaborator.rb +1 -3
  409. data/lib/rigor/sig_gen/write_result.rb +19 -3
  410. data/lib/rigor/sig_gen/writer.rb +189 -41
  411. data/lib/rigor/sig_gen.rb +3 -0
  412. data/lib/rigor/signature_path_audit.rb +153 -6
  413. data/lib/rigor/source/literals.rb +0 -23
  414. data/lib/rigor/source/node_children.rb +0 -2
  415. data/lib/rigor/source/node_locator.rb +5 -11
  416. data/lib/rigor/source/node_walker.rb +0 -6
  417. data/lib/rigor/source/parameter_envelope.rb +72 -0
  418. data/lib/rigor/source.rb +1 -0
  419. data/lib/rigor/triage/catalogue.rb +1 -3
  420. data/lib/rigor/triage.rb +3 -5
  421. data/lib/rigor/type/accepts_result.rb +24 -6
  422. data/lib/rigor/type/combinator.rb +144 -5
  423. data/lib/rigor/type/data_class.rb +2 -2
  424. data/lib/rigor/type/data_instance.rb +4 -4
  425. data/lib/rigor/type/difference.rb +1 -0
  426. data/lib/rigor/type/float_range.rb +128 -0
  427. data/lib/rigor/type/hash_shape.rb +6 -6
  428. data/lib/rigor/type/integer_range.rb +13 -8
  429. data/lib/rigor/type/nominal.rb +8 -2
  430. data/lib/rigor/type/refined.rb +4 -3
  431. data/lib/rigor/type/struct_class.rb +3 -3
  432. data/lib/rigor/type/struct_instance.rb +4 -4
  433. data/lib/rigor/type.rb +1 -0
  434. data/lib/rigor/type_node/generic.rb +1 -1
  435. data/lib/rigor/type_node/range_literal.rb +25 -0
  436. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  437. data/lib/rigor/type_node.rb +1 -0
  438. data/lib/rigor/version.rb +1 -1
  439. data/lib/rigor.rb +1 -0
  440. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +0 -4
  441. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +0 -1
  442. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +0 -2
  443. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  444. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +10 -5
  445. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +0 -4
  446. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +3 -5
  447. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +1 -4
  448. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +14 -4
  449. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +32 -30
  450. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +0 -1
  451. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -3
  452. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  453. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  454. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  455. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  456. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  457. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  458. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +264 -3
  459. data/plugins/rigor-actionpack/sig/action_controller.rbs +71 -0
  460. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  461. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  462. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  463. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  464. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  465. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  466. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +0 -4
  467. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +2 -1
  468. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +0 -1
  469. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +0 -2
  470. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +1 -1
  471. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +10 -5
  472. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  473. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  474. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +383 -7
  475. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  476. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +1 -2
  477. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +1 -2
  478. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +322 -41
  479. data/plugins/rigor-activerecord/sig/active_record/framework.rbs +123 -0
  480. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  481. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +26 -14
  482. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  483. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -2
  484. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +290 -19
  485. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +1 -0
  486. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +3 -0
  487. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +9 -7
  488. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +7 -3
  489. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +1 -0
  490. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +8 -6
  491. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +11 -7
  492. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -11
  493. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +9 -4
  494. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +2 -0
  495. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +0 -5
  496. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +0 -1
  497. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +0 -1
  498. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +8 -1
  499. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  500. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +36 -4
  501. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +1 -0
  502. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  503. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  504. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  505. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +8 -6
  506. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +78 -5
  507. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  508. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +1 -0
  509. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -1
  510. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -3
  511. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -0
  512. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +0 -5
  513. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -2
  514. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +0 -1
  515. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +0 -1
  516. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +12 -7
  517. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +0 -12
  518. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +1 -3
  519. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +0 -1
  520. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +36 -37
  521. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +5 -5
  522. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -10
  523. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +5 -3
  524. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +3 -2
  525. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +2 -2
  526. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +2 -2
  527. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +9 -10
  528. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +2 -3
  529. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +10 -17
  530. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +1 -0
  531. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +13 -1
  532. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  533. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +324 -18
  534. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +0 -3
  535. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +0 -3
  536. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -5
  537. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +2 -3
  538. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -0
  539. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +0 -3
  540. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +1 -0
  541. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +1 -0
  542. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +1 -9
  543. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +1 -0
  544. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +0 -4
  545. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +1 -1
  546. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +2 -1
  547. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +0 -1
  548. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +0 -2
  549. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +10 -5
  550. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -0
  551. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +2 -5
  552. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +1 -4
  553. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +3 -9
  554. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +4 -4
  555. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +1 -2
  556. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +3 -5
  557. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +7 -5
  558. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +51 -6
  559. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  560. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +1 -0
  561. data/sig/rigor/analysis/baseline.rbs +69 -7
  562. data/sig/rigor/analysis/fact_store.rbs +1 -1
  563. data/sig/rigor/analysis/project_scan.rbs +74 -0
  564. data/sig/rigor/analysis/reachability/scan_cache.rbs +12 -0
  565. data/sig/rigor/cache.rbs +2 -3
  566. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  567. data/sig/rigor/effects/effect_table.rbs +60 -0
  568. data/sig/rigor/effects/envelope.rbs +97 -0
  569. data/sig/rigor/effects/envelope_index.rbs +33 -0
  570. data/sig/rigor/effects/file_collection.rbs +81 -0
  571. data/sig/rigor/effects/label.rbs +26 -0
  572. data/sig/rigor/effects/label_set.rbs +46 -0
  573. data/sig/rigor/effects/method_key.rbs +24 -0
  574. data/sig/rigor/effects/origin.rbs +50 -0
  575. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  576. data/sig/rigor/effects/registry.rbs +68 -0
  577. data/sig/rigor/effects/summary.rbs +50 -0
  578. data/sig/rigor/effects/taint_cause.rbs +12 -0
  579. data/sig/rigor/environment.rbs +17 -10
  580. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  581. data/sig/rigor/inference.rbs +9 -14
  582. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  583. data/sig/rigor/plugin/base.rbs +30 -7
  584. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  585. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  586. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  587. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  588. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  589. data/sig/rigor/plugin/loader.rbs +3 -3
  590. data/sig/rigor/plugin/manifest.rbs +51 -11
  591. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  592. data/sig/rigor/plugin/registry.rbs +62 -1
  593. data/sig/rigor/plugin.rbs +1 -1
  594. data/sig/rigor/rbs_extended.rbs +1 -1
  595. data/sig/rigor/reflection.rbs +9 -6
  596. data/sig/rigor/scope.rbs +148 -12
  597. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +21 -0
  598. data/sig/rigor/source.rbs +4 -4
  599. data/sig/rigor/testing.rbs +10 -4
  600. data/sig/rigor/type.rbs +33 -1
  601. data/sig/rigor.rbs +39 -20
  602. data/skills/rigor-ask/SKILL.md +16 -11
  603. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  604. data/skills/rigor-ci-setup/SKILL.md +17 -21
  605. data/skills/rigor-doctor/SKILL.md +24 -22
  606. data/skills/rigor-doctor/references/01-checks.md +97 -33
  607. data/skills/rigor-editor-setup/SKILL.md +6 -4
  608. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  609. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  610. data/skills/rigor-next-steps/SKILL.md +4 -2
  611. data/skills/rigor-plugin-author/SKILL.md +19 -23
  612. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  613. data/skills/rigor-plugin-author/references/02-walker-and-types.md +25 -27
  614. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  615. data/skills/rigor-plugin-review/SKILL.md +6 -4
  616. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  617. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  618. data/skills/rigor-project-init/SKILL.md +15 -10
  619. data/skills/rigor-project-init/references/01-detect.md +13 -9
  620. data/skills/rigor-project-init/references/02-configure.md +43 -14
  621. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  622. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  623. data/skills/rigor-project-init/references/06-agent-contract.md +67 -0
  624. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  625. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  626. data/skills/rigor-type-oracle/SKILL.md +226 -0
  627. data/skills/rigor-type-oracle/references/01-oracle-commands.md +265 -0
  628. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +52 -0
  629. data/skills/rigor-type-oracle/references/03-gap-protocol.md +125 -0
  630. data/skills/rigor-unused-adjudicate/SKILL.md +12 -7
  631. data/skills/rigor-upgrade/SKILL.md +13 -8
  632. metadata +133 -3
  633. data/lib/rigor/cli/probe_environment.rb +0 -85
  634. data/sig/rigor/inference/builtins/numeric_catalog.rbs +0 -3
@@ -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,36 +113,36 @@ 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,
111
125
  Prism::InstanceVariableWriteNode => :type_of_assignment_write,
112
- Prism::InstanceVariableOperatorWriteNode => :type_of_assignment_write,
113
- Prism::InstanceVariableOrWriteNode => :type_of_assignment_write,
114
- Prism::InstanceVariableAndWriteNode => :type_of_assignment_write,
126
+ Prism::InstanceVariableOperatorWriteNode => :type_of_compound_variable_write,
127
+ Prism::InstanceVariableOrWriteNode => :type_of_compound_variable_write,
128
+ Prism::InstanceVariableAndWriteNode => :type_of_compound_variable_write,
115
129
  Prism::ClassVariableReadNode => :type_of_class_variable_read,
116
130
  Prism::ClassVariableWriteNode => :type_of_assignment_write,
117
- Prism::ClassVariableOperatorWriteNode => :type_of_assignment_write,
118
- Prism::ClassVariableOrWriteNode => :type_of_assignment_write,
119
- Prism::ClassVariableAndWriteNode => :type_of_assignment_write,
131
+ Prism::ClassVariableOperatorWriteNode => :type_of_compound_variable_write,
132
+ Prism::ClassVariableOrWriteNode => :type_of_compound_variable_write,
133
+ Prism::ClassVariableAndWriteNode => :type_of_compound_variable_write,
120
134
  Prism::GlobalVariableReadNode => :type_of_global_variable_read,
121
135
  Prism::GlobalVariableWriteNode => :type_of_assignment_write,
122
- Prism::GlobalVariableOperatorWriteNode => :type_of_assignment_write,
123
- Prism::GlobalVariableOrWriteNode => :type_of_assignment_write,
124
- Prism::GlobalVariableAndWriteNode => :type_of_assignment_write,
136
+ Prism::GlobalVariableOperatorWriteNode => :type_of_compound_variable_write,
137
+ Prism::GlobalVariableOrWriteNode => :type_of_compound_variable_write,
138
+ Prism::GlobalVariableAndWriteNode => :type_of_compound_variable_write,
125
139
  # Compound writes that share the `.value` rvalue accessor
126
- Prism::LocalVariableOperatorWriteNode => :type_of_assignment_write,
127
- Prism::LocalVariableOrWriteNode => :type_of_assignment_write,
128
- Prism::LocalVariableAndWriteNode => :type_of_assignment_write,
129
- Prism::IndexOperatorWriteNode => :type_of_assignment_write,
130
- Prism::IndexOrWriteNode => :type_of_assignment_write,
131
- Prism::IndexAndWriteNode => :type_of_assignment_write,
140
+ Prism::LocalVariableOperatorWriteNode => :type_of_compound_variable_write,
141
+ Prism::LocalVariableOrWriteNode => :type_of_compound_variable_write,
142
+ Prism::LocalVariableAndWriteNode => :type_of_compound_variable_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,
@@ -201,7 +215,7 @@ module Rigor
201
215
  Prism::NextNode => :type_of_jump,
202
216
  Prism::RetryNode => :type_of_jump,
203
217
  Prism::RedoNode => :type_of_jump,
204
- Prism::YieldNode => :type_of_dynamic_top,
218
+ Prism::YieldNode => :type_of_yield,
205
219
  Prism::SuperNode => :type_of_dynamic_top,
206
220
  Prism::ForwardingArgumentsNode => :type_of_non_value,
207
221
  Prism::WhileNode => :type_of_loop,
@@ -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
@@ -287,6 +331,70 @@ module Rigor
287
331
  slot[1].transform_values(&:values)
288
332
  end
289
333
 
334
+ # Issue #720 — the value the block passed by the call site currently being re-typed evaluates to, or
335
+ # `nil` outside such a frame. Read by {#type_of_yield}.
336
+ #
337
+ # A thread-local rather than a `Scope` field for the same reason the `return` / `next` / `break` sinks
338
+ # are: `yield` names the frame's caller, not a binding, and the body is walked through scopes the
339
+ # inference rebuilds freely. It is installed unconditionally — with `nil` — at every user-method
340
+ # inference frame, so a callee reached WITHOUT a block from inside a yielding body cannot read its
341
+ # caller's block.
342
+ YIELD_VALUE_KEY = :__rigor_yield_value_type__
343
+ private_constant :YIELD_VALUE_KEY
344
+
345
+ def self.current_yield_value_type
346
+ Thread.current[YIELD_VALUE_KEY]
347
+ end
348
+
349
+ # Runs `block` with `type` installed as the frame's yield value, restoring the previous frame on exit.
350
+ def self.with_yield_value_type(type)
351
+ previous = Thread.current[YIELD_VALUE_KEY]
352
+ Thread.current[YIELD_VALUE_KEY] = type
353
+ begin
354
+ yield
355
+ ensure
356
+ Thread.current[YIELD_VALUE_KEY] = previous
357
+ end
358
+ end
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
+
290
398
  private
291
399
 
292
400
  attr_reader :scope, :tracer
@@ -318,6 +426,182 @@ module Rigor
318
426
  type_of(node.value)
319
427
  end
320
428
 
429
+ # `v += 1` / `@v ||= x` / `$v &&= x` as an EXPRESSION. The value of a compound write is the value it
430
+ # stores, which is a function of the variable's CURRENT binding — not of the rvalue alone. Typing it as
431
+ # `type_of(node.value)` answered `1` for `total += 1` whatever `total` held.
432
+ #
433
+ # `StatementEvaluator#compound_eval` already computes the right answer on the straight-line path, so the
434
+ # gap only showed where a body is typed WITHOUT that evaluator: the block-return pass types a
435
+ # single-statement body by expression alone, and issue #617 residue (3) is the live consequence —
436
+ # `total = 0; r = [1, 2].map { total += 1 }` folded to `[1, 1]`, and `r.last == 1` fired a false
437
+ # `flow.always-truthy-condition` on a program where it is `2`. Under the per-element fold the captured
438
+ # local is already bound to its converged `Integer` (issue #587 (b)); reading that binding here is what
439
+ # turns the stale pin into the honest `Integer`.
440
+ #
441
+ # The algebra is `StatementEvaluator#compound_result_type`'s, deliberately: the two must not disagree
442
+ # about what a compound write evaluates to. An unbound target is `Dynamic[top]` — the same fallback the
443
+ # evaluator takes — and an operator the receiver does not answer widens to `Dynamic[top]` rather than
444
+ # inventing the rvalue.
445
+ #
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}.
448
+ def type_of_compound_variable_write(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)
466
+ rhs = type_of(node.value)
467
+ compound_write_value(node, compound_write_constant_binding(node), rhs)
468
+ end
469
+
470
+ def compound_write_value(node, current, rhs)
471
+ case node
472
+ when Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
473
+ Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
474
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode
475
+ # An UNBOUND target is the memoization idiom (`def self.default = @default ||= new`): nothing
476
+ # has written the variable on any path the analyzer saw, so the stored value is the rvalue.
477
+ # Reading it as `Dynamic[top] | rhs` would skip every memoized singleton in `sig-gen`
478
+ # (ADR-5 optimism; three `.default` readers went `sig.skipped.untyped-return` without this).
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
491
+
492
+ Type::Combinator.union(Narrowing.narrow_truthy(current), rhs)
493
+ when Prism::LocalVariableAndWriteNode, Prism::InstanceVariableAndWriteNode,
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)
503
+ else
504
+ compound_operator_result(current || dynamic_top, rhs, node.binary_operator)
505
+ end
506
+ end
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
+
526
+ def compound_write_current_binding(node)
527
+ case node
528
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
529
+ Prism::LocalVariableAndWriteNode then scope.local(node.name)
530
+ when Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode,
531
+ Prism::InstanceVariableAndWriteNode then scope.ivar(node.name)
532
+ when Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode,
533
+ Prism::ClassVariableAndWriteNode then scope.cvar(node.name)
534
+ else scope.global(node.name)
535
+ end
536
+ end
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
+
596
+ def compound_operator_result(current, rhs, operator)
597
+ MethodDispatcher.dispatch(
598
+ receiver_type: current,
599
+ method_name: operator.to_sym,
600
+ arg_types: [rhs],
601
+ environment: scope.environment
602
+ ) || dynamic_top
603
+ end
604
+
321
605
  # Slice 7 phase 1 — instance/class/global variable reads. Each lookup returns the type currently bound
322
606
  # in the surrounding scope's per-kind binding map (populated by `StatementEvaluator` write handlers
323
607
  # within the same method body), falling through to `Dynamic[Top]` when no binding is recorded.
@@ -372,6 +656,19 @@ module Rigor
372
656
  dynamic_top
373
657
  end
374
658
 
659
+ # Issue #720 — `yield` evaluates the block the CALLER passed, so its value is that block's value. The
660
+ # caller is only known when this body is being re-typed on behalf of one call site
661
+ # ({#infer_user_method_return} installs the frame); on the main walk over a `def` no caller exists and
662
+ # the answer stays `Dynamic[top]`.
663
+ #
664
+ # Nothing about the yield's arguments is checked here. The block's return type was computed at the call
665
+ # site under the parameter bindings the call implies, so it already answers "what does this block
666
+ # evaluate to for this caller"; a `yield` that passes different arguments than the block's parameters
667
+ # accept is a separate (arity) question this frame does not decide.
668
+ def type_of_yield(_node)
669
+ ExpressionTyper.current_yield_value_type || dynamic_top
670
+ end
671
+
375
672
  # `defined?(expr)` returns `String | nil` per Ruby semantics — a description of the expression's
376
673
  # category (`"local-variable"`, `"method"`, ...) when defined, or `nil` when not. The argument is not
377
674
  # evaluated (it is statically inspected by the runtime), so the typer does not recurse into it.
@@ -428,7 +725,17 @@ module Rigor
428
725
  # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
429
726
  # `Rigor::Inference::FallbackTracer`.
430
727
  def type_of_constant_read(node)
431
- 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)
432
739
  end
433
740
 
434
741
  # A leading `::` (`::Rails`, `::Rails::Application`) is Ruby's escape hatch out of the lexical ladder:
@@ -436,6 +743,9 @@ module Rigor
436
743
  # deliberately un-rooted (the discovery tables are keyed that way), so the marker rides alongside it
437
744
  # into the resolver (#614).
438
745
  def type_of_constant_path(node)
746
+ narrowed = guard_narrowed_constant(node)
747
+ return narrowed if narrowed
748
+
439
749
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
440
750
  return fallback_for(node, family: :prism) if full_name.nil?
441
751
 
@@ -504,16 +814,17 @@ module Rigor
504
814
  # in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
505
815
  # `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
506
816
  # `Rigor::Type` or nil; the caller decides whether to fall back.
507
- def resolve_constant_name(name, rooted: false)
508
- 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)
509
819
  end
510
820
 
511
821
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
512
822
  # whose key is a value-pinned scalar literal — Symbol, plain String, Integer, Float, `true`, `false`,
513
823
  # or `nil` (covering `{ a: 1, "b" => 2 }` and `{ 1 => 2, 1.0 => 4 }` alike) — falling back to the
514
824
  # generic `Hash[K, V]` form otherwise. Splatted entries (`{ **other }`) and dynamic keys widen to the
515
- # underlying `Hash[K, V]` form by unioning the types each entry exposes; when no concrete pair
516
- # 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.
517
828
  def type_of_hash(node)
518
829
  elements = node.respond_to?(:elements) ? node.elements : []
519
830
  # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
@@ -525,8 +836,6 @@ module Rigor
525
836
  return shape if shape
526
837
 
527
838
  keys, values = generic_hash_pairs_for(elements)
528
- return Type::Combinator.nominal_of(Hash) if keys.empty? || values.empty?
529
-
530
839
  Type::Combinator.nominal_of(
531
840
  Hash,
532
841
  type_args: [Type::Combinator.union(*keys), Type::Combinator.union(*values)]
@@ -580,14 +889,64 @@ module Rigor
580
889
  keys = []
581
890
  values = []
582
891
  elements.each do |entry|
583
- next unless entry.is_a?(Prism::AssocNode)
584
-
585
- keys << type_of(entry.key)
586
- 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
587
900
  end
588
901
  [keys, values]
589
902
  end
590
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
+
591
950
  # An interpolated string `"#{a}b#{c}"` is `literal-string` when every part contributes literal-bearing
592
951
  # material: plain text segments are literal by construction, embedded expressions count when their type
593
952
  # is itself literal-string-compatible (a `Constant<String>`, the `literal-string` carrier, an
@@ -642,132 +1001,29 @@ module Rigor
642
1001
  Type::Combinator.constant_of(nil)
643
1002
  end
644
1003
 
645
- # `if c; t; (elsif c2; ...; )* else; e; end`. Prism nests `elsif` branches as `IfNode#subsequent`. Slice
646
- # 3 phase 1 types both branches in the receiver scope and returns their union; scope rebinding is the
647
- # StatementEvaluator's job (Slice 3 phase 2). Without an else clause the branch's implicit value is nil,
648
- # which is included in the union.
649
- #
650
- # v0.0.6 — when the predicate folds to a `Type::Constant` whose value is Ruby-truthy (resp.
651
- # Ruby-falsey), the unreachable branch is elided so the if-expression's type is the live branch alone.
652
- # Statement-level branch elision lives in `StatementEvaluator#eval_if`; this handler covers the
653
- # expression-position ternary form (`a ? b : c`) and any `if`/`unless` reached through `type_of`.
654
- def type_of_if(node)
655
- then_type = statements_or_nil(node.statements)
656
- else_type = if_else_type(node.subsequent)
657
- elide_or_union(node.predicate, then_type, else_type)
658
- end
659
-
660
- # `unless c; t; else; e; end`. Prism uses `else_clause` here (no `elsif` chain). Branch-elision logic
661
- # mirrors `type_of_if`, inverted: a truthy predicate selects the else branch.
662
- def type_of_unless(node)
663
- then_type = statements_or_nil(node.statements)
664
- else_type = if_else_type(node.else_clause)
665
- elide_or_union(node.predicate, else_type, then_type)
666
- end
667
-
668
- # Issue #286 — the effective optimistic-nil-free cause of an expression. {OptimisticOrigin.resolve} owns
669
- # the judgment, shared verbatim with `StatementEvaluator#optimistic_origin_for` and the
670
- # `flow.always-truthy-condition` collector.
671
- def optimistic_origin_for(node)
672
- OptimisticOrigin.resolve(node, scope)
673
- end
674
-
675
- def if_else_type(subsequent)
676
- return Type::Combinator.constant_of(nil) if subsequent.nil?
677
-
678
- type_of(subsequent)
679
- end
680
-
681
- # Routes the predicate's typed value through branch elision. `live_when_truthy` and `live_when_falsey`
682
- # are the branch types selected by the predicate's polarity; the names match `IfNode` semantics
683
- # directly and invert at the `type_of_unless` call site.
684
- def elide_or_union(predicate, live_when_truthy, live_when_falsey)
685
- case constant_predicate_polarity(predicate)
686
- when :truthy then live_when_truthy
687
- when :falsey then live_when_falsey
688
- else Type::Combinator.union(live_when_truthy, live_when_falsey)
689
- end
690
- end
691
-
692
- # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary predicate expression under three-valued logic.
693
- # {Narrowing.predicate_certainty} owns the judgment (the same one `StatementEvaluator#live_branch_for_if`
694
- # reads on the scope side): `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`, and
695
- # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
696
- def constant_predicate_polarity(predicate)
697
- return nil if predicate.nil?
698
-
699
- # ADR-47 WD5 — a decidable version guard (#627) answers first, exactly as it does on the scope side
700
- # in `StatementEvaluator#branch_certainty`. Both readers ask the same pure function of the AST, so
701
- # the expression form (`RUBY_VERSION >= "3.1" ? a : b`) and the statement form cannot disagree about
702
- # which arm survives. The verdict rests on literals, so the ADR-101 optimistic-carrier decline below
703
- # — which guards an RBS-derived judgment — does not apply to it.
704
- guard = VersionGuard.verdict(predicate)
705
- return guard if guard
706
- # ADR-101 — decline on an optimistically nil-free carrier; see
707
- # `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
708
- return nil unless optimistic_origin_for(predicate).nil?
709
-
710
- 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
711
1015
  end
712
1016
 
713
1017
  def type_of_else(node)
714
1018
  statements_or_nil(node.statements)
715
1019
  end
716
1020
 
717
- # `a && b` and `a || b` short-circuit at the value level: `a && b` returns `a` when `a` is falsey, else
718
- # `b`. `a || b` returns `a` when `a` is truthy, else `b`.
719
- #
720
- # v0.0.6 — when the left operand folds to a `Type::Constant`, we know which side actually flows
721
- # through, so the result is one operand's type instead of a union. Otherwise the union-of-both-operands
722
- # 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.
723
1025
  def type_of_and_or(node)
724
- left_type = type_of(node.left)
725
- polarity = left_operand_polarity(node.left, left_type)
726
- return short_circuit_for(node, left_type, polarity) if polarity
727
-
728
- # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
729
- # when `a` is truthy, so its falsey constituents (`nil` / `false`) can never be the value of the
730
- # OrNode (they hand off to `b`); `a && b` yields `a` solely when `a` is falsey. Narrow the surviving
731
- # left edge before the union so `s || full` (with `s : String?`) types `String | <full>` rather than
732
- # re-admitting the stripped `nil`. Mirrors `StatementEvaluator#eval_and_or`'s `skipped_type`.
733
- surviving_left =
734
- if node.is_a?(Prism::AndNode)
735
- Narrowing.narrow_falsey(left_type)
736
- else
737
- Narrowing.narrow_truthy(left_type)
738
- end
739
- Type::Combinator.union(surviving_left, type_of(node.right))
740
- end
741
-
742
- def short_circuit_for(node, left_type, polarity)
743
- and_node = node.is_a?(Prism::AndNode)
744
- if polarity == :truthy
745
- and_node ? type_of(node.right) : left_type
746
- else
747
- and_node ? left_type : type_of(node.right)
748
- end
749
- end
750
-
751
- # Issue #313 — the node-aware wrapper the `&&` / `||` short-circuit reads. The spec's exclusion binds
752
- # this gate as much as it binds `flow.always-truthy-condition`, and a `Constant`-only gate is not by
753
- # itself enough to honour it: a literal hash whose values share one type reads as a lone `Constant`
754
- # (`UNIFORM[key]` → `Constant[1]`), so the gate would judge the left operand of `UNIFORM[key] || key`
755
- # provably truthy and discard the author's fallback — the counter-example the spec names verbatim.
756
- # Declining returns the union of both operands, which is what `StatementEvaluator#eval_and_or` produces
757
- # anyway, so the two `&&` / `||` typers stay in agreement.
758
- def left_operand_polarity(left_node, left_type)
759
- return nil unless optimistic_origin_for(left_node).nil?
760
-
761
- constant_value_polarity(left_type)
762
- end
763
-
764
- # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
765
- # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
766
- # node, so the same predicate analysis can be reused in both contexts.
767
- def constant_value_polarity(type)
768
- return nil unless type.is_a?(Type::Constant)
769
-
770
- type.value ? :truthy : :falsey
1026
+ scope.evaluate(node, tracer: tracer).first
771
1027
  end
772
1028
 
773
1029
  # Three-valued evaluation of `case predicate when pattern` dispatch. For each `when` clause we ask:
@@ -785,29 +1041,41 @@ module Rigor
785
1041
  # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
786
1042
  # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
787
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.
788
1047
  def type_of_case(node)
789
1048
  return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
790
1049
 
791
1050
  subject_type = type_of(node.predicate)
792
1051
  candidates = []
793
1052
  reached_yes = false
1053
+ clause_scope = scope
794
1054
 
795
1055
  node.conditions.each do |when_node|
796
- case case_when_branch_certainty(subject_type, when_node)
797
- when :yes
798
- 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
799
1062
  reached_yes = true
800
1063
  break
801
- when :maybe
802
- candidates << type_of(when_node)
803
- # :no — drop the branch
804
1064
  end
1065
+ clause_scope = next_scope
805
1066
  end
806
1067
 
807
- candidates << type_of_case_else(node) unless reached_yes
1068
+ candidates << case_arm_type(node.else_clause, clause_scope) unless reached_yes
808
1069
  Type::Combinator.union(*candidates)
809
1070
  end
810
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
+
811
1079
  def type_of_case_simple_union(node)
812
1080
  branch_types = node.conditions.map { |branch| type_of(branch) }
813
1081
  Type::Combinator.union(*branch_types, type_of_case_else(node))
@@ -843,9 +1111,21 @@ module Rigor
843
1111
  #
844
1112
  # Other pattern shapes (Range, Regexp, custom `===`) stay `:maybe` — the existing union fallback
845
1113
  # handles them.
1114
+ #
1115
+ # The class reference is resolved through {Narrowing.lexical_class_name}, the walk the narrowing
1116
+ # side of the same `case` already uses (#655). Matching on the as-written spelling instead made a
1117
+ # SHADOWED name confidently wrong rather than merely unresolved: `when Random` inside
1118
+ # `Bar::Nested` names `Bar::Nested::Random`, the environment answered the core `::Random` the
1119
+ # spelling matches at top level, and a subject that is a core `Random` then made the arm certain
1120
+ # — dropping the `else` Ruby actually takes and typing the expression as a branch that never
1121
+ # runs. The documented argument that an unresolvable pattern can only lose certainty holds for an
1122
+ # unresolvable name and not for a shadowed one.
846
1123
  def case_when_pattern_certainty(subject_type, pattern_node)
847
- class_name = Source::ConstantPath.qualified_name_or_nil(pattern_node)
848
- return Narrowing.class_pattern_certainty(subject_type, class_name, environment: scope.environment) if class_name
1124
+ class_name = Narrowing.lexical_class_name(pattern_node, scope)
1125
+ if class_name
1126
+ return Narrowing.class_pattern_certainty(subject_type, class_name, environment: scope.environment,
1127
+ scope: scope)
1128
+ end
849
1129
 
850
1130
  literal = literal_pattern_value(pattern_node)
851
1131
  return Narrowing.value_pattern_certainty(subject_type, literal[:value]) if literal
@@ -891,24 +1171,46 @@ module Rigor
891
1171
  Type::Combinator.union(primary_type, *rescue_types)
892
1172
  end
893
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.
894
1176
  def rescue_chain_types(rescue_node)
1177
+ arm_typer = typer_under(rescue_arm_scope)
895
1178
  types = []
896
1179
  current = rescue_node
897
1180
  while current
898
- types << statements_or_nil(current.statements)
1181
+ types << arm_typer.send(:statements_or_nil, current.statements)
899
1182
  current = current.subsequent
900
1183
  end
901
1184
  types
902
1185
  end
903
1186
 
904
1187
  def type_of_rescue(node)
905
- statements_or_nil(node.statements)
1188
+ typer_under(rescue_arm_scope).send(:statements_or_nil, node.statements)
906
1189
  end
907
1190
 
1191
+ def rescue_arm_scope = scope.forget_error_info.forget_last_status
1192
+
908
1193
  # `expr rescue fallback` is RescueModifierNode in Prism. The result is `expr`'s type when no exception
909
- # 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}).
910
1196
  def type_of_rescue_modifier(node)
911
- 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
+ )
912
1214
  end
913
1215
 
914
1216
  def type_of_ensure(node)
@@ -963,6 +1265,8 @@ module Rigor
963
1265
  Type::Combinator.nominal_of(value.class.name)
964
1266
  when Type::IntegerRange
965
1267
  Type::Combinator.nominal_of("Integer")
1268
+ when Type::FloatRange
1269
+ Type::Combinator.nominal_of("Float")
966
1270
  else
967
1271
  type
968
1272
  end
@@ -1177,12 +1481,18 @@ module Rigor
1177
1481
  # and fired `undefined method 'upcase' for nil` on correct code. The candidate is still looked up
1178
1482
  # first — that lookup is a hash probe and owns the ADR-46 cross-file dependency edge — and
1179
1483
  # {#self_type_answers?} then vetoes the bind for a name the enclosing class answers itself.
1180
- def try_local_def_dispatch(node, receiver, arg_types)
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.
1489
+ def try_local_def_dispatch(node, receiver, arg_types, block_type = nil)
1181
1490
  local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1182
1491
  return nil unless local_def
1183
1492
  return nil if self_type_answers?(node.name)
1493
+ return nil if plugin_member_answers?(node, receiver)
1184
1494
 
1185
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1495
+ local_inference = infer_top_level_user_method(local_def, receiver, arg_types, block_type)
1186
1496
  return local_inference if local_inference
1187
1497
 
1188
1498
  # The local def matches by name but the inference was disqualified — the parameter shape is too
@@ -1194,6 +1504,82 @@ module Rigor
1194
1504
  dynamic_top
1195
1505
  end
1196
1506
 
1507
+ # ADR-110 WD1 / #856 — the receiver's own `def` outranks a declaration that only an ANCESTOR carries.
1508
+ #
1509
+ # RBS resolves through the fully built method table, so `RbsDispatch` answers a subclass call with a
1510
+ # signature written about the base. redmine's `FieldFormat::Base#target_class` honestly declares
1511
+ # `-> nil`; `RecordList` overrides it with a real lookup, and every call on a `RecordList` receiver
1512
+ # typed as `nil` and fired `undefined method ... for nil` on the subclass's own working code (#744).
1513
+ # A signature is authoritative for a receiver only where someone wrote it about that receiver;
1514
+ # reaching it through an ancestor is a lookup convenience, not an act of authorship.
1515
+ #
1516
+ # This is {#try_local_def_dispatch}'s shape one level up — there the enclosing class shadows a
1517
+ # top-level `def`, here the receiver's class shadows an ancestor's declaration — and it answers the
1518
+ # same way: re-type the override's body, and when that is not possible answer `Dynamic[Top]` rather
1519
+ # than let the inherited declaration speak for a method it does not describe.
1520
+ #
1521
+ # Three conditions, and the third is the blast radius. Clause A of the ADR (an own source `def`, no
1522
+ # own declaration) does NOT by itself stay clear of [ADR-43]: `class Foo; def each; end` inheriting
1523
+ # `Enumerable#each` satisfies it, and disqualifying bundled declarations that way is the blanket fix
1524
+ # ADR-43 rejected — the same reason {#instance_self_answers?}'s RBS arm is own-class only. So the
1525
+ # ancestor whose declaration is being disqualified must itself be PROJECT-declared: a project sidecar
1526
+ # describes the source under analysis, while a bundled signature describes a class the project does
1527
+ # not own, where a project `def` is a monkey-patch and [ADR-17] owns the question.
1528
+ # `project_declared_class?` fail-softs to false, so an unattributable environment changes nothing.
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)
1539
+
1540
+ class_name = receiver.class_name
1541
+ return false if class_name.nil?
1542
+ # `Scope#user_def_for`, not `discovered_method?`: the cross-file table deliberately withholds a
1543
+ # plain instance `def` under the ADR-17 monkey-patch contract, and this gate must see one.
1544
+ return false if scope.user_def_for(class_name, method_name).nil?
1545
+
1546
+ definition = safe_rbs_method_definition(class_name, method_name, :instance)
1547
+ return false if definition.nil?
1548
+ return false if rbs_declared_on_class?(definition, class_name)
1549
+
1550
+ project_declared_owner?(definition)
1551
+ end
1552
+
1553
+ # {#implicit_index_read_type}'s dispatcher tier, with the write node as the call context.
1554
+ def index_read_dispatch(node, receiver, arg_types)
1555
+ MethodDispatcher.dispatch(
1556
+ receiver_type: receiver, method_name: :[], arg_types: arg_types,
1557
+ environment: scope.environment, call_node: node, scope: scope
1558
+ )
1559
+ end
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)
1571
+ end
1572
+
1573
+ # Whether the class an inherited declaration was written about is one the project declares itself.
1574
+ def project_declared_owner?(definition)
1575
+ defined_in = definition.defined_in
1576
+ return false if defined_in.nil?
1577
+
1578
+ Rigor::Reflection.project_declared_class?(defined_in.to_s, scope: scope)
1579
+ rescue StandardError
1580
+ false
1581
+ end
1582
+
1197
1583
  # Issue #618 — whether the call's enclosing `self` already answers `method_name`. Only a `self` whose
1198
1584
  # class is KNOWN participates: at genuine top level, and inside a block whose `self` is unmodelled,
1199
1585
  # `scope.self_type` is nil, the predicate is false, and the historical top-level binding stands — that
@@ -1212,30 +1598,71 @@ module Rigor
1212
1598
  end
1213
1599
  end
1214
1600
 
1215
- # The instance side of {#self_type_answers?}: the class's own discovered methods (`def`, `attr_*`,
1216
- # `define_method`, `alias`), its `Struct.new` / `Data.define` member accessors, a `def` reached through
1217
- # its project ancestors (superclass chain and included modules), and an RBS method declared on the
1218
- # class ITSELF.
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
+
1619
+ # The instance side of {#self_type_answers?}: the discovered methods (`def`, `attr_*`,
1620
+ # `define_method`, `alias`) of the class or any project ancestor, its `Struct.new` / `Data.define`
1621
+ # member accessors, a `def` reached through its project ancestors (superclass chain and included
1622
+ # modules), and an RBS method declared on an owner that PRECEDES `::Object` in the MRO.
1219
1623
  #
1220
- # The RBS arm is own-class only, deliberately. An inherited-declaration test would match every
1221
- # `Object` / `Kernel` / `Enumerable` name and retract the binding v0.0.3 A exists for — a `def
1222
- # select(...)` collocated with its DSL-block call site would route straight back through
1223
- # `Enumerable#select`.
1624
+ # Issue #633 — the discovery arm walks ancestors because inheritance does not distinguish how a
1625
+ # method got there: a superclass `attr_accessor :stext` and an included module's `attr_reader` bind
1626
+ # ahead of a top-level `def stext` exactly as an inherited `def` does, and only the latter had a walk.
1627
+ #
1628
+ # The RBS arm's cut-off is `::Object`, not the own class. A top-level `def` IS a private `Object`
1629
+ # instance method, the last link of every MRO, so any declared owner that comes before `::Object`
1630
+ # wins at runtime — `Exception#message` for a `StandardError` subclass, `Array#first`,
1631
+ # `Comparable#clamp`. What the cut-off keeps out is the tier v0.0.3 A exists for and #316 / #319
1632
+ # refined: a name owned by `Object` or `Kernel` themselves (`inspect`, `format`) is at or after the
1633
+ # top-level `def`'s own rung, so the historical binding still stands there.
1224
1634
  def instance_self_answers?(class_name, method_name)
1225
1635
  return false if class_name.nil?
1226
- return true if scope.discovered_method?(class_name, method_name, :instance)
1636
+ return true if scope.discovered_method_through_ancestors?(class_name, method_name, :instance)
1227
1637
  return true if meta_member?(class_name, method_name)
1228
1638
  return true if resolve_user_def_through_ancestors(class_name, method_name)
1229
1639
 
1230
- rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :instance), class_name)
1640
+ rbs_ancestor_answers?(class_name, method_name)
1641
+ end
1642
+
1643
+ # The RBS arm of {#instance_self_answers?}. A project class is usually absent from the RBS
1644
+ # environment, so the declaration that decides the question is written about an ancestor the project
1645
+ # does not declare (`StandardError`, `Array`, `Comparable`) — each is asked on its own terms, and
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.
1652
+ def rbs_ancestor_answers?(class_name, method_name)
1653
+ !ExternalAncestorResolution.resolve(class_name, method_name, :instance, scope: scope).nil?
1231
1654
  end
1232
1655
 
1233
1656
  # The singleton side: a class-body `self` is `Singleton[Foo]`, where an implicit-self call reaches
1234
1657
  # `Foo`'s own class methods before `Object`'s private top-level `def`.
1658
+ #
1659
+ # Issue #633 — both discovery arms follow the superclass chain, because a subclass inherits its
1660
+ # parent's class methods; `Scope` narrows the singleton walk to superclasses for the same reason
1661
+ # (an `include`d module's `def self.x` is not callable on the includer).
1235
1662
  def singleton_self_answers?(class_name, method_name)
1236
1663
  return false if class_name.nil?
1237
- return true if scope.discovered_method?(class_name, method_name, :singleton)
1238
- return true unless scope.singleton_def_for(class_name, method_name).nil?
1664
+ return true if scope.discovered_method_through_ancestors?(class_name, method_name, :singleton)
1665
+ return true unless scope.singleton_def_through_ancestors(class_name, method_name).first.nil?
1239
1666
 
1240
1667
  rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :singleton), class_name)
1241
1668
  end
@@ -1250,26 +1677,16 @@ module Rigor
1250
1677
  !members.nil? && members.include?(method_name.to_sym)
1251
1678
  end
1252
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.
1253
1684
  def safe_rbs_method_definition(class_name, method_name, kind)
1254
- if kind == :singleton
1255
- Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
1256
- else
1257
- Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
1258
- end
1259
- rescue StandardError
1260
- nil
1685
+ ExternalAncestorResolution.method_definition(class_name, method_name, kind, scope: scope)
1261
1686
  end
1262
1687
 
1263
- # True when the RBS declaration found for the name sits on `class_name` itself rather than on an
1264
- # ancestor; mirrors `CheckRules#defined_on?` and `SigGen::Generator#declared_on_class_itself?`.
1265
1688
  def rbs_declared_on_class?(definition, class_name)
1266
- return false if definition.nil?
1267
- return false unless definition.respond_to?(:defined_in)
1268
-
1269
- defined_in = definition.defined_in
1270
- return false if defined_in.nil?
1271
-
1272
- defined_in.to_s.delete_prefix("::") == class_name.to_s.delete_prefix("::")
1689
+ ExternalAncestorResolution.declared_on_class?(definition, class_name)
1273
1690
  end
1274
1691
 
1275
1692
  # Issue #520 — Ruby defines the value of an attribute / index assignment (`x.attr = v`, `h[k] = v`)
@@ -1376,7 +1793,97 @@ module Rigor
1376
1793
  # `receiver_override` substitutes the receiver type for the whole pipeline (folds, dispatch, the
1377
1794
  # inference tiers) without re-reading the receiver node — the safe-navigation path (#518) dispatches
1378
1795
  # on the nil-stripped fragment, and the optional-receiver retry (#519) re-runs the pipeline on it.
1796
+ # Issue #853: `break value` terminates the yielding CALL and is that call's value, so the answer every
1797
+ # tier below produces — an RBS return, a precision fold, an inter-procedural inference — describes only
1798
+ # the path where the block never breaks. The call's value is that result unioned with every `break` arm
1799
+ # the block can reach. Modelling it here rather than inside each fold is what keeps the folds honest:
1800
+ # `ops.all? { |o| break false unless o; true }` still folds the no-break path to `Constant[true]`, and
1801
+ # the union with the `false` arm makes the call `bool` — no `flow.always-truthy-condition` on a program
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.
1379
1813
  def call_dispatch_type_for(node, receiver_override: nil)
1814
+ result = loop_completion_type(node, call_result_type_for(node, receiver_override: receiver_override))
1815
+ arms = call_break_arm_types(node, receiver_override: receiver_override)
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)
1877
+
1878
+ receiver = receiver_override || call_receiver_type_for(node)
1879
+ return false unless BlockCallTiming.exactly_once_call?(
1880
+ receiver_type: receiver, method_name: node.name, scope: scope
1881
+ )
1882
+
1883
+ block_return_type_for(node, receiver, []).is_a?(Type::Bot)
1884
+ end
1885
+
1886
+ def call_result_type_for(node, receiver_override: nil)
1380
1887
  narrowed = indexed_narrowing_for(node)
1381
1888
  return narrowed if narrowed
1382
1889
 
@@ -1392,7 +1899,7 @@ module Rigor
1392
1899
  literal_send = try_literal_send(node, receiver)
1393
1900
  return literal_send if literal_send
1394
1901
 
1395
- local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1902
+ local_def_result = try_local_def_dispatch(node, receiver, arg_types, block_type)
1396
1903
  return local_def_result if local_def_result
1397
1904
 
1398
1905
  # v0.0.6 phase 2 — per-element block fold for Tuple receivers. When `[a, b, c].map { |x| f(x) }` and
@@ -1403,6 +1910,9 @@ module Rigor
1403
1910
  block_fold = try_receiver_block_folds(node, receiver, arg_types)
1404
1911
  return block_fold if block_fold
1405
1912
 
1913
+ overriding_def = try_overriding_def_dispatch(node, receiver, arg_types)
1914
+ return overriding_def if overriding_def
1915
+
1406
1916
  result = MethodDispatcher.dispatch(
1407
1917
  receiver_type: receiver,
1408
1918
  method_name: node.name,
@@ -1414,16 +1924,16 @@ module Rigor
1414
1924
  )
1415
1925
  return result if result
1416
1926
 
1417
- dispatch_miss_result(node, receiver, arg_types)
1927
+ dispatch_miss_result(node, receiver, arg_types, block_type)
1418
1928
  end
1419
1929
 
1420
1930
  # The post-dispatch tiers for a call `MethodDispatcher` could not answer, in their historical
1421
1931
  # order; extracted from {#call_dispatch_type_for} whole.
1422
- def dispatch_miss_result(node, receiver, arg_types)
1932
+ def dispatch_miss_result(node, receiver, arg_types, block_type = nil)
1423
1933
  # v0.0.2 #5 — inter-procedural inference for user-defined methods. When dispatch misses but the
1424
1934
  # receiver is a user class with a `def` body, re-type the body with the call's argument types bound
1425
1935
  # and return the body's last-expression type.
1426
- user_inference = try_user_method_inference(receiver, node, arg_types)
1936
+ user_inference = try_user_method_inference(receiver, node, arg_types, block_type: block_type)
1427
1937
  return user_inference if user_inference
1428
1938
 
1429
1939
  # Module-singleton call resolution (ADR-57 follow-up) — when the receiver is `Singleton[Foo]` (a
@@ -1431,7 +1941,7 @@ module Rigor
1431
1941
  # `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
1432
1942
  # tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
1433
1943
  # project-defined singleton methods reach here.
1434
- singleton_inference = try_project_singleton_inference(receiver, node, arg_types)
1944
+ singleton_inference = try_project_singleton_inference(receiver, node, arg_types, block_type: block_type)
1435
1945
  return singleton_inference if singleton_inference
1436
1946
 
1437
1947
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
@@ -1634,8 +2144,8 @@ module Rigor
1634
2144
  # `scope.self_type` (or implicit `Object`) as the receiver carrier so the body's own self is
1635
2145
  # consistent with the call site's. Returns nil when the parameter shape disqualifies the def, when the
1636
2146
  # body is empty, or when a recursion cycle is detected.
1637
- def infer_top_level_user_method(def_node, receiver, arg_types)
1638
- infer_user_method_return(def_node, receiver, arg_types)
2147
+ def infer_top_level_user_method(def_node, receiver, arg_types, block_type = nil)
2148
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
1639
2149
  rescue StandardError
1640
2150
  nil
1641
2151
  end
@@ -1706,6 +2216,9 @@ module Rigor
1706
2216
  # The three receiver-shaped block folds, in their historical order (extracted whole from
1707
2217
  # `call_dispatch_type_for` for method-length budget).
1708
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
+
1709
2222
  per_element = try_per_element_block_fold(node, receiver)
1710
2223
  return per_element if per_element
1711
2224
 
@@ -1715,6 +2228,25 @@ module Rigor
1715
2228
  try_hash_shape_block_fold(node, receiver)
1716
2229
  end
1717
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
+
1718
2250
  # Issue #533 — `x.send(:selector, args)` with a LITERAL symbol is statically `x.selector(args)`:
1719
2251
  # the private-boundary idiom (protobuf's `send(:get_file_descriptor)` at 84 sites) resolves through
1720
2252
  # the same dispatch + project-inference tiers as the direct call would. `send` legitimately crosses
@@ -1745,14 +2277,15 @@ module Rigor
1745
2277
  try_project_singleton_inference(receiver, node, inner_args, method_name: inner_name)
1746
2278
  end
1747
2279
 
1748
- def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
2280
+ def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name, block_type: nil)
1749
2281
  return nil unless user_inference_receiver?(receiver)
1750
2282
 
1751
2283
  def_node, owner = resolve_user_def_with_owner(receiver.class_name, method_name)
1752
2284
  return nil if def_node.nil?
1753
2285
 
1754
2286
  result = infer_user_method_return(def_node, receiver, arg_types,
1755
- self_fold_safe: fold_safe_call_receiver?(call_node, receiver))
2287
+ self_fold_safe: fold_safe_call_receiver?(call_node, receiver),
2288
+ yield_type: block_type)
1756
2289
  return result if result.nil?
1757
2290
 
1758
2291
  degrade_if_overridable(result, owner, method_name, :instance)
@@ -1823,13 +2356,14 @@ module Rigor
1823
2356
  # The OWNER — not the receiver class — is what the overridable gate keys on, exactly as the instance
1824
2357
  # side does: adopting `Base`'s literal return is unsound when a subclass redefines the method, and
1825
2358
  # after this change the resolved body is routinely not the receiver's own.
1826
- def try_singleton_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
2359
+ def try_singleton_method_inference(receiver, call_node, arg_types, method_name: call_node.name,
2360
+ block_type: nil)
1827
2361
  return nil unless receiver.is_a?(Type::Singleton)
1828
2362
 
1829
2363
  def_node, owner = resolve_singleton_def_with_owner(receiver.class_name, method_name)
1830
2364
  return nil if def_node.nil?
1831
2365
 
1832
- result = infer_user_method_return(def_node, receiver, arg_types)
2366
+ result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
1833
2367
  return result if result.nil?
1834
2368
 
1835
2369
  degrade_if_overridable(result, owner, method_name, :singleton)
@@ -1857,9 +2391,11 @@ module Rigor
1857
2391
  # body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
1858
2392
  # `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
1859
2393
  # object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
1860
- def try_project_singleton_inference(receiver, call_node, arg_types, method_name: call_node.name)
1861
- try_singleton_method_inference(receiver, call_node, arg_types, method_name: method_name) ||
1862
- try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: method_name)
2394
+ def try_project_singleton_inference(receiver, call_node, arg_types, method_name: call_node.name,
2395
+ block_type: nil)
2396
+ kwargs = { method_name: method_name, block_type: block_type }
2397
+ try_singleton_method_inference(receiver, call_node, arg_types, **kwargs) ||
2398
+ try_singleton_object_constant_inference(receiver, call_node, arg_types, **kwargs)
1863
2399
  end
1864
2400
 
1865
2401
  # #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
@@ -1867,13 +2403,14 @@ module Rigor
1867
2403
  # that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
1868
2404
  # name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
1869
2405
  # resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
1870
- def try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: call_node.name)
2406
+ def try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: call_node.name,
2407
+ block_type: nil)
1871
2408
  return nil unless receiver.is_a?(Type::Nominal)
1872
2409
 
1873
2410
  def_node = SingletonObjectConstant.def_node_for(call_node, receiver, method_name, scope)
1874
2411
  return nil if def_node.nil?
1875
2412
 
1876
- infer_user_method_return(def_node, receiver, arg_types)
2413
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
1877
2414
  rescue StandardError
1878
2415
  nil
1879
2416
  end
@@ -1889,25 +2426,49 @@ module Rigor
1889
2426
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
1890
2427
  private_constant :CLASS_GRAPH_CACHE_KEY
1891
2428
 
1892
- # Run-scoped memo for the static class-graph resolvers below. They are pure functions of the *frozen*
1893
- # project index trio (`discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`) —
1894
- # `user_def_for` / `superclass_of` / `includes_of` read nothing else, and never touch the current
1895
- # scope's locals or narrowings — so a result computed for one `(class, method)` is valid for every
1896
- # `Scope` that shares those tables. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the memo lives
1897
- # on `Thread.current` rather than on `self`. It is keyed by the *identity* of the three frozen tables
1898
- # (nested `compare_by_identity` stores): a new analysis generation, or any `Scope` that swaps an index
1899
- # via `with_discovered_*`, transparently lands in a fresh bucket while everything sharing the tables
1900
- # shares the memo. Steady-state cost is three identity-keyed hash reads and zero allocation — the `||=`
1901
- # chains only allocate on the first miss of a generation. (Pool mode forks per worker, so the
1902
- # `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.)
1903
2459
  def class_graph_buckets
1904
- store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
1905
- by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1906
- by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1907
- # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
1908
- # is a pure function of the same frozen index trio — the sibling resolver it walks reads nothing
1909
- # else.
1910
- by_super[scope.discovered_includes] ||= { name: {}, user_def: {}, self_pure: {}.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]
1911
2472
  end
1912
2473
 
1913
2474
  def resolve_user_def_through_ancestors(class_name, method_name)
@@ -1966,16 +2527,40 @@ module Rigor
1966
2527
  OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
1967
2528
  private_constant :OVERRIDE_GATE_CACHE_KEY
1968
2529
 
1969
- # Run-scoped memo for {#overridden_in_project?}, keyed (like `class_graph_buckets`) by the identity of
1970
- # the frozen discovery trio so a new analysis generation lands in a fresh bucket, then nested `kind →
1971
- # owner → method_name`. The predicate is a pure function of those tables. Nesting avoids allocating a
1972
- # composite cache key on the hot path (the gate runs on every adopted self-call return), so a
1973
- # 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.
1974
2556
  def override_gate_buckets
1975
- store = (Thread.current[OVERRIDE_GATE_CACHE_KEY] ||= {}.compare_by_identity)
1976
- by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1977
- by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1978
- 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]
1979
2564
  end
1980
2565
 
1981
2566
  # True when some discovered project class/module — distinct from `owner` — redefines `(method_name,
@@ -2015,14 +2600,65 @@ module Rigor
2015
2600
  METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
2016
2601
  private_constant :METHOD_DEFINERS_INDEX_KEY
2017
2602
 
2018
- # Per-generation `method_name (Symbol) → [owner names]` inverted index over the instance / singleton
2019
- # def tables, memoised by the identity of the def table it inverts (a new analysis generation lands in
2020
- # a fresh bucket). The toplevel sentinel is excluded — a toplevel `def` has no class ancestry and so
2021
- # 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.
2022
2649
  def method_definers_index(kind)
2023
- table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
2024
- store = (Thread.current[METHOD_DEFINERS_INDEX_KEY] ||= {}.compare_by_identity)
2025
- 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)
2026
2662
  end
2027
2663
 
2028
2664
  def build_method_definers_index(table)
@@ -2078,14 +2714,6 @@ module Rigor
2078
2714
  INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
2079
2715
  private_constant :INFERENCE_SUMMARY_KEY
2080
2716
 
2081
- # Monotonic per-thread counter, bumped once each time `consult_summary` actually reads an in-flight
2082
- # fixpoint assumption (ADR-55 slice 2). A method return computed across an interval in which this
2083
- # counter does NOT move depended on no transient Kleene iterate, so it is FINAL and safe to memoise —
2084
- # even when the `summaries` table is non-empty because some unrelated outermost frame merely *seeded*
2085
- # (but never consulted) its own entry. See `infer_user_method_return`'s post-hoc memo gate.
2086
- SUMMARY_CONSULT_COUNTER_KEY = :__rigor_user_method_summary_consults__
2087
- private_constant :SUMMARY_CONSULT_COUNTER_KEY
2088
-
2089
2717
  # Per-thread append-only log of the seed depths of every in-flight summary `consult_summary` read
2090
2718
  # (ADR-55 slice 2 mutual-recursion soundness fix, 2026-06-12). Each fixpoint owner records the guard
2091
2719
  # stack size at seed time on its entry (`depth:`); a consult appends the consulted entry's depth here.
@@ -2248,9 +2876,15 @@ module Rigor
2248
2876
  # the body both qualify. It does NOT need its own memo-key slot here — the bit is observable on the
2249
2877
  # built `body_scope`, which is where every downstream consumer (the memo key, the recursion context)
2250
2878
  # reads it from, so the two can never disagree.
2251
- def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false)
2879
+ #
2880
+ # `yield_type` (issue #720) is the OTHER call-site-varying input, and unlike `self_fold_safe` it is
2881
+ # not observable on the body scope, so it does need its own memo-key slot. It is dropped for a def
2882
+ # that cannot reach a `yield` — nearly all of them — which keeps the key shape constant for the
2883
+ # methods the memo actually carries and confines the extra dimension to yielding callees.
2884
+ def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false, yield_type: nil)
2252
2885
  return nil if def_node.body.nil?
2253
2886
 
2887
+ yield_type = nil unless yield_type && body_yields?(def_node)
2254
2888
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types,
2255
2889
  self_fold_safe: self_fold_safe)
2256
2890
  return nil if body_scope.nil?
@@ -2283,42 +2917,87 @@ module Rigor
2283
2917
  # outermost method and thereafter the old `summaries.empty?` gate disabled the memo for every nested
2284
2918
  # call, re-walking the shared sub-readers combinatorially (~932k body evaluations for ~20 tiny
2285
2919
  # methods). The computation itself lives in `compute_user_method_return`.
2286
- unless memo_candidate?(stack, plain_signature)
2287
- trace_memo_refusal(stack, plain_signature)
2288
- return compute_user_method_return(def_node, body_scope, stack, summaries,
2920
+ # Issue #720 — the frame the body's `yield`s read, installed for the whole inference (the memo key
2921
+ # below reads it back from here rather than taking a second parameter, so key and frame cannot
2922
+ # disagree). It is installed even when nil, which is what stops a blockless callee reached from
2923
+ # inside a yielding body from inheriting the outer caller's block.
2924
+ ExpressionTyper.with_call_site_frame(yield_type, forwarded_frame_types(def_node, arg_types)) do
2925
+ unless memo_candidate?(stack, plain_signature)
2926
+ trace_memo_refusal(stack, plain_signature)
2927
+ next compute_user_method_return(def_node, body_scope, stack, summaries,
2289
2928
  receiver, arg_types, plain_signature)
2929
+ end
2930
+
2931
+ # INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
2932
+ # reads a callee body's dispatches perform through the instrumented `Scope` accessors) are recorded,
2933
+ # per consumer, only as a side effect of evaluating that body, and a memo hit serves the return
2934
+ # WITHOUT re-evaluating it. Since the bucket is run-scoped, hits CROSS consumer-file boundaries, so
2935
+ # every cross-file hit is PAIRED WITH CACHE-AND-REPLAY of the callee's read-set: under recording,
2936
+ # the first evaluation of a key captures the recorder events of its body walk
2937
+ # (`DependencyRecorder.capture` — observe-and-forward, so the first consumer's own record is
2938
+ # untouched) onto the entry, and a hit replays that set into the current consumer's accumulator
2939
+ # (`DependencyRecorder.replay`, which re-applies the per-consumer self-read filter). A memo hit is
2940
+ # thereby edge-equivalent to a fresh body evaluation for EVERY consumer. The naive alternative —
2941
+ # bypassing the memo while the recorder is active — measured >200x wall on analyzer-shaped files
2942
+ # (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
2943
+ # spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
2944
+ # dependency_recorder_spec.rb's transitive deep-edge example.
2945
+ consult_and_store_return_memo(def_node, body_scope, stack, summaries,
2946
+ receiver, arg_types, plain_signature)
2290
2947
  end
2948
+ end
2949
+
2950
+ # Issue #720 — the constructs that own a `yield` written inside them, so a scan for "can this def's
2951
+ # body reach a `yield` that names THIS def's block?" must stop at them. A nested `def` / `class` /
2952
+ # `module` / `class << self` starts a new method-block binding; a block or a lambda does NOT — `yield`
2953
+ # inside `[1, 2].each { yield }` still calls the enclosing method's block, which is precisely the
2954
+ # `each`-wrapper idiom this fix has to keep seeing.
2955
+ YIELD_BOUNDARY_NODES = Set[
2956
+ Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
2957
+ ].freeze
2958
+ private_constant :YIELD_BOUNDARY_NODES
2959
+
2960
+ # Whether `def_node`'s body can reach a `yield`, memoised by def-node identity: it gates the
2961
+ # per-call-site memo dimension above and would otherwise be re-walked once per call site of every
2962
+ # user method. It rides in {#class_graph_buckets} for LIFETIME, not for correctness — a pure
2963
+ # function of the AST depends on none of the tables that bucket keys on, but a store that is
2964
+ # replaced with the analysis generation cannot outlive the nodes it holds.
2965
+ def body_yields?(def_node)
2966
+ cache = class_graph_buckets[:yields]
2967
+ return cache[def_node] if cache.key?(def_node)
2968
+
2969
+ cache[def_node] = yield_reachable?(def_node.body)
2970
+ end
2291
2971
 
2292
- # INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
2293
- # reads a callee body's dispatches perform through the instrumented `Scope` accessors) are recorded,
2294
- # per consumer, only as a side effect of evaluating that body, and a memo hit serves the return
2295
- # WITHOUT re-evaluating it. Since the bucket is run-scoped, hits CROSS consumer-file boundaries, so
2296
- # every cross-file hit is PAIRED WITH CACHE-AND-REPLAY of the callee's read-set: under recording,
2297
- # the first evaluation of a key captures the recorder events of its body walk
2298
- # (`DependencyRecorder.capture` — observe-and-forward, so the first consumer's own record is
2299
- # untouched) onto the entry, and a hit replays that set into the current consumer's accumulator
2300
- # (`DependencyRecorder.replay`, which re-applies the per-consumer self-read filter). A memo hit is
2301
- # thereby edge-equivalent to a fresh body evaluation for EVERY consumer. The naive alternative —
2302
- # bypassing the memo while the recorder is active — measured >200x wall on analyzer-shaped files
2303
- # (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
2304
- # spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
2305
- # dependency_recorder_spec.rb's transitive deep-edge example.
2306
- consult_and_store_return_memo(def_node, body_scope, stack, summaries,
2307
- receiver, arg_types, plain_signature)
2972
+ def yield_reachable?(node)
2973
+ return false if node.nil?
2974
+ return true if node.is_a?(Prism::YieldNode)
2975
+
2976
+ node.rigor_each_child do |child|
2977
+ next if YIELD_BOUNDARY_NODES.include?(child.class)
2978
+
2979
+ return true if yield_reachable?(child)
2980
+ end
2981
+ false
2308
2982
  end
2309
2983
 
2310
2984
  # The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
2311
2985
  # and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
2312
- # The store gate (ADR-84 WD3): a result is stored when the ADR-55 fixpoint consult counter did not
2313
- # move across the compute (the WD0 `MEMO_REFUSE_CONSULT_TAINTED` non-store) AND no transient-machinery
2314
- # event during the bracket referenced a stack frame BELOW the bracket's entry depth (see
2315
- # TRANSIENT_EVENT_DEPTHS_KEY). Below-entry events — an ancestor's in-flight Kleene iterate read by a
2316
- # guard hit, a shared-fuel exhaustion, a possibly-ancestor-caused WD1 clamp — mean the ancestor
2317
- # context influenced `result`, which a standalone recompute would not reproduce; at-or-above-entry
2318
- # events are the compute's own deterministic machinery (its own converged fixpoint, sub-cycles that
2319
- # opened and closed inside the bracket) and do not block the store. A top-of-stack compute (entry
2320
- # depth 0) is standalone by construction. A hit under ADR-46 recording replays the entry's captured
2321
- # read-set into the current consumer (see the INVARIANT comment at the call site).
2986
+ # The store gate (ADR-84 WD3): a result is stored when no transient-machinery event during the
2987
+ # bracket referenced a stack frame BELOW the bracket's entry depth (see TRANSIENT_EVENT_DEPTHS_KEY).
2988
+ # A separate ADR-55 fixpoint-consult bracket counter used to sit in front of this test; it is gone
2989
+ # (issue #875) because the event log already subsumes it — the sole `consult_summary` call site is
2990
+ # the in-cycle guard hit, which logs its event at the consulted owner's position first, so a consult
2991
+ # that could taint a bracket is a below-entry event by construction. Below-entry events — an
2992
+ # ancestor's in-flight Kleene iterate read by a guard hit, a shared-fuel exhaustion, a
2993
+ # possibly-ancestor-caused WD1 clamp — mean the ancestor context influenced `result`, which a
2994
+ # standalone recompute would not reproduce; at-or-above-entry events are the compute's own
2995
+ # deterministic machinery (its own converged fixpoint, sub-cycles that opened and closed inside the
2996
+ # bracket) and do not block the store. A top-of-stack compute (entry depth 0) is standalone by
2997
+ # construction. ADR-84 WD6 adds one exemption on top: a tainted result that
2998
+ # is already `Dynamic[top]` is stored anyway — see `top_result?`. A hit under ADR-46 recording
2999
+ # replays the entry's captured read-set into the current consumer (see the INVARIANT comment at the
3000
+ # call site).
2322
3001
  def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
2323
3002
  receiver, arg_types, plain_signature)
2324
3003
  per_def = (return_memo_bucket[def_node] ||= {})
@@ -2327,9 +3006,14 @@ module Rigor
2327
3006
  # was foldable and `Dynamic[top]` when it was not. Without it in the key the first call site to
2328
3007
  # reach a def would poison every later one with the other polarity. It is read off the body scope
2329
3008
  # rather than passed in, so the key cannot drift from the scope that produced the result.
3009
+ # Issue #720 — the FOURTH such dimension, and it is read off the installed frame rather than
3010
+ # passed in for the same reason: a yielding callee's return depends on the block THIS call site
3011
+ # hands it, so two call sites passing differently-typed blocks must not share an entry. It is nil
3012
+ # for every def that cannot reach a `yield`, which is where the memo's mass is.
2330
3013
  memo_key = [receiver.describe(:short),
2331
3014
  arg_types.map { |type| type.describe(:short) },
2332
- body_scope.struct_fold_safe?(:self)]
3015
+ body_scope.struct_fold_safe?(:self),
3016
+ ExpressionTyper.current_yield_value_type&.describe(:short)]
2333
3017
  if (entry = per_def[memo_key])
2334
3018
  BudgetTrace.hit(BudgetTrace::MEMO_HITS)
2335
3019
  Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
@@ -2340,13 +3024,10 @@ module Rigor
2340
3024
 
2341
3025
  entry_depth = stack.size
2342
3026
  event_mark = transient_event_mark
2343
- consults_before = summary_consult_count
2344
3027
  result, read_set = compute_with_read_capture(def_node, body_scope, stack, summaries,
2345
3028
  receiver, arg_types, plain_signature)
2346
3029
 
2347
- if summary_consult_count != consults_before
2348
- BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_CONSULT_TAINTED)
2349
- elsif context_tainted?(event_mark, entry_depth)
3030
+ if context_tainted?(event_mark, entry_depth) && !top_result?(result)
2350
3031
  BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_TRANSIENT)
2351
3032
  else
2352
3033
  per_def[memo_key] = MemoEntry.new(result: result, read_set: read_set,
@@ -2359,19 +3040,48 @@ module Rigor
2359
3040
  Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]&.size || 0
2360
3041
  end
2361
3042
 
2362
- def summary_consult_count
2363
- Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
2364
- end
2365
-
2366
3043
  # ADR-84 WD3 — true when a transient-machinery event logged during the bracket (entries past
2367
3044
  # `event_mark`) referenced a frame below `entry_depth`. The log is cleared when the guard stack drains
2368
3045
  # (only possible mid-bracket for a top-of-stack compute, whose events are deterministic anyway), so a
2369
3046
  # missing / shorter log reads as untainted.
3047
+ #
3048
+ # Scanned by index rather than over `log[event_mark..]`: the log is append-only for the whole
3049
+ # outermost entry, so the suffix slice allocated an array per candidate compute — a term quadratic in
3050
+ # the event count, and the `ary_ensure_room_for_push` / GC-sweep profile issue #870 sampled.
2370
3051
  def context_tainted?(event_mark, entry_depth)
2371
3052
  log = Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]
2372
3053
  return false if log.nil? || log.size <= event_mark
2373
3054
 
2374
- log[event_mark..].any? { |depth| depth < entry_depth }
3055
+ index = event_mark
3056
+ size = log.size
3057
+ while index < size
3058
+ return true if log[index] < entry_depth
3059
+
3060
+ index += 1
3061
+ end
3062
+ false
3063
+ end
3064
+
3065
+ # ADR-84 WD6 (issue #872) — the top-result exemption to the WD3 store gate.
3066
+ #
3067
+ # WD3 refuses a store whenever the bracket saw a below-entry transient event, because such a result
3068
+ # embeds an ancestor's in-flight state and a standalone recompute need not reproduce it. Inside a
3069
+ # strongly connected component of mutually recursive methods that holds for nearly every frame — each
3070
+ # nested compute re-enters a signature already on the guard stack and the guard site logs at the
3071
+ # ancestor's shallower position — so nothing in the component is ever memoised and every call edge
3072
+ # re-walks its whole callee subtree. Issue #870's fixture measured 18,412 of 18,436 memo misses
3073
+ # computing a result and discarding it, and rufo 0.18.2's `formatter.rb` did not finish in 25 minutes.
3074
+ #
3075
+ # The exemption: when the computed result is `Dynamic[top]` the entry carries no context-dependent
3076
+ # information to serve. `untyped` is the lattice top and the ADR-5 degradation floor — the only way a
3077
+ # standalone recompute can differ is by being MORE precise, so serving the stored top can never
3078
+ # surface a type the ancestor context invented, and can never raise a diagnostic that a fresh
3079
+ # evaluation would not (nothing is reported off `untyped`). The cost is a possible missed diagnostic
3080
+ # where the untainted recompute would have been precise; the corpus evidence that this does not
3081
+ # happen in practice is the byte-identical `--format json` runs over `lib`, `plugins/*/lib`,
3082
+ # `examples/*/lib` and the survey projects recorded on issue #872.
3083
+ def top_result?(result)
3084
+ result.equal?(Type::Combinator.untyped)
2375
3085
  end
2376
3086
 
2377
3087
  # ADR-84 WD2 — the memo-miss compute, wrapped in a `DependencyRecorder.capture` window when ADR-46
@@ -2440,14 +3150,36 @@ module Rigor
2440
3150
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2441
3151
  end
2442
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
+
2443
3175
  # True when this frame's result is a candidate for the return memo: the one structural precondition,
2444
3176
  # stable across the body walk, that is necessary (but not sufficient) for a FINAL result — this plain
2445
3177
  # signature must not itself be on the recursion guard stack (else we are inside its own cycle,
2446
3178
  # returning a Kleene iterate by construction). Sufficiency is decided post-hoc in
2447
- # `consult_and_store_return_memo` by the two bracket counters (fixpoint consults + ADR-84 WD3
2448
- # transient-machinery events) — so unlike the prior form this deliberately does NOT refuse while a
2449
- # constant-arg unroll is in flight: a nested frame whose compute finishes without a single transient
2450
- # event ran exactly as it would standalone (fuel consumption without exhaustion is invisible), and
3179
+ # `consult_and_store_return_memo` by the ADR-84 WD3 transient-machinery event bracket — so unlike
3180
+ # the prior form this deliberately does NOT refuse while a constant-arg unroll is in flight: a
3181
+ # nested frame whose compute finishes without a single transient event ran exactly as it would
3182
+ # standalone (fuel consumption without exhaustion is invisible), and
2451
3183
  # the blanket exclusion refused 82% of mail's body evaluations for such final results (ADR-84).
2452
3184
  def memo_candidate?(stack, plain_signature)
2453
3185
  stack.none? { |frame| plain_part(frame) == plain_signature }
@@ -2578,7 +3310,6 @@ module Rigor
2578
3310
  summaries = context.summaries
2579
3311
  depth = seed_fixpoint_summary(summaries, plain_signature)
2580
3312
  consult_depths = (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= [])
2581
- computed = nil
2582
3313
 
2583
3314
  RECURSION_FIXPOINT_CAP.times do |iteration|
2584
3315
  summaries[plain_signature][:consulted] = false
@@ -2612,9 +3343,15 @@ module Rigor
2612
3343
  return resolved unless resolved.nil?
2613
3344
  end
2614
3345
 
2615
- step = fixpoint_step(summaries, plain_signature, computed, iteration)
2616
- return step unless step == :continue
3346
+ converged = fixpoint_step(summaries, plain_signature, computed, iteration)
3347
+ return converged if converged
2617
3348
  end
3349
+
3350
+ # Out of iterations and still unstable. The collapse is the loop's own tail rather than a branch
3351
+ # inside `fixpoint_step`: `Integer#times` yields its receiver when no iteration returns, so a
3352
+ # tail-less loop reads as a path that returns an Integer summary (the `def.return-type-mismatch`
3353
+ # on `return_type_for` this shape retired), and the loop, not the step, owns its cap.
3354
+ collapse_fixpoint_cap(summaries, plain_signature)
2618
3355
  end
2619
3356
 
2620
3357
  # Seeds the thread-local summary entry for a fixpoint owner: the `bot` Kleene seed plus the
@@ -2650,8 +3387,10 @@ module Rigor
2650
3387
 
2651
3388
  # One Kleene-iteration step of the fixpoint loop. Joins `computed` into the running assumption
2652
3389
  # (widening value-pinned constituents on the final permitted iteration to force convergence) and
2653
- # either returns a final type — convergence, or the capped `untyped` collapse — or `:continue` to
2654
- # request another body evaluation, having advanced the stored assumption. ADR-55 WD2.
3390
+ # returns the converged type, or `nil` — no final type yet — having advanced the stored assumption
3391
+ # so the caller's next body evaluation reads the wider iterate. The step never decides that the
3392
+ # loop is over: whether another evaluation follows is the caller's cap, and a `nil` on the final
3393
+ # iteration lands in `collapse_fixpoint_cap`. ADR-55 WD2.
2655
3394
  def fixpoint_step(summaries, plain_signature, computed, iteration)
2656
3395
  assumption = summaries[plain_signature][:assumption]
2657
3396
  last_iteration = iteration == RECURSION_FIXPOINT_CAP - 1
@@ -2662,17 +3401,19 @@ module Rigor
2662
3401
  # (joining it back changes nothing).
2663
3402
  return candidate if joined == assumption
2664
3403
 
2665
- if last_iteration
2666
- # Out of iterations and still unstable — collapse to today's widening behaviour. ADR-84 WD3: the
2667
- # cap is a per-owner constant, so the event references the owner's own frame.
2668
- note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
2669
- scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
2670
- summaries[plain_signature][:assumption] = Type::Combinator.untyped
2671
- return Type::Combinator.untyped
2672
- end
2673
-
2674
3404
  summaries[plain_signature][:assumption] = joined
2675
- :continue
3405
+ nil
3406
+ end
3407
+
3408
+ # The capped collapse: `RECURSION_FIXPOINT_CAP` evaluations advanced the assumption without
3409
+ # converging, so the summary degrades to today's widening behaviour, `untyped`, parked in the
3410
+ # assumption for any consumer that still reads it. ADR-84 WD3: the cap is a per-owner constant, so
3411
+ # the event references the owner's own frame.
3412
+ def collapse_fixpoint_cap(summaries, plain_signature)
3413
+ note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
3414
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
3415
+ summaries[plain_signature][:assumption] = Type::Combinator.untyped
3416
+ Type::Combinator.untyped
2676
3417
  end
2677
3418
 
2678
3419
  # Rebuilds the user-method body scope with every bound positional parameter widened to its nominal
@@ -2859,7 +3600,8 @@ module Rigor
2859
3600
  locals = bind_params_from_call_types(params, arg_types)
2860
3601
  return nil if locals.nil?
2861
3602
 
2862
- # 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
2863
3605
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
2864
3606
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
2865
3607
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
@@ -2877,7 +3619,9 @@ module Rigor
2877
3619
  lexical_nesting: recorded_def_nesting(def_node),
2878
3620
  discovery: scope.discovery,
2879
3621
  struct_fold_safe_locals: body_fold_safe_locals(def_node, receiver, self_fold_safe),
2880
- 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)
2881
3625
  )
2882
3626
  end
2883
3627
 
@@ -2957,17 +3701,35 @@ module Rigor
2957
3701
  return nil if locals.nil?
2958
3702
 
2959
3703
  bind_keyword_params(params, kw_shape, locals)
2960
- 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
2961
3706
  locals[params.block.name.to_sym] = dynamic_top if params.block&.name
2962
3707
  locals
2963
3708
  end
2964
3709
 
2965
- # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence; `...`
2966
- # 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}).
2967
3715
  def bindable_param_shape?(params)
2968
- params.is_a?(Prism::ParametersNode) &&
2969
- params.posts.empty? &&
2970
- !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
2971
3733
  end
2972
3734
 
2973
3735
  def takes_keywords?(params)
@@ -2978,7 +3740,9 @@ module Rigor
2978
3740
  requireds = params.requireds
2979
3741
  optionals = params.optionals
2980
3742
  return nil if positional.size < requireds.size
2981
- 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)
2982
3746
 
2983
3747
  locals = {}
2984
3748
  requireds.each_with_index { |param, index| bind_positional_param(locals, param, positional[index]) }
@@ -3022,6 +3786,23 @@ module Rigor
3022
3786
  param.respond_to?(:value) && param.value ? literal_default_type(param.value) : dynamic_top
3023
3787
  end
3024
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
+
3025
3806
  # A default expression contributes its type only when it is lexically scope-free — a scalar literal
3026
3807
  # or an EMPTY collection literal (`options = {}` is the dominant Rails idiom). Anything that could
3027
3808
  # read the def's own lexical scope binds `Dynamic` instead of being mis-typed in the caller's scope.
@@ -3066,11 +3847,60 @@ module Rigor
3066
3847
  scope.environment.nominal_for_name("Object") || dynamic_top
3067
3848
  end
3068
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.
3069
3863
  def call_arg_types(node)
3070
3864
  arguments_node = node.arguments
3071
3865
  return [] if arguments_node.nil?
3072
3866
 
3073
- 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
3074
3904
  end
3075
3905
 
3076
3906
  # When the call carries a `Prism::BlockNode`, build the block's entry scope (outer locals plus
@@ -3092,39 +3922,167 @@ module Rigor
3092
3922
  return nil if block_arg.nil?
3093
3923
  return nil if receiver_type.nil?
3094
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
+
3095
3928
  expected = MethodDispatcher.expected_block_param_types(
3096
3929
  receiver_type: receiver_type,
3097
3930
  method_name: call_node.name,
3098
3931
  arg_types: arg_types,
3099
- environment: scope.environment
3932
+ environment: scope.environment,
3933
+ scope: scope
3100
3934
  )
3101
- # ADR-16 Tier A: when a registered plugin's `block_as_methods` entry matches `(receiver_type,
3102
- # call_node.name)`, narrow the block body's `self_type` to the receiver class's instance type. The
3103
- # narrowing is `nil` for unmatched calls, leaving the existing scope contract unchanged.
3104
- narrowed_self = MacroBlockSelfType.narrow_self_type_for(
3105
- 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)
3106
3940
  )
3107
- block_return_for(block_arg, expected, narrowed_self_type: narrowed_self)
3108
3941
  rescue StandardError
3109
3942
  nil
3110
3943
  end
3111
3944
 
3112
- def block_return_for(block_arg, expected, narrowed_self_type: nil)
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
+
3963
+ EMPTY_BREAK_ARMS = [].freeze
3964
+ private_constant :EMPTY_BREAK_ARMS
3965
+
3966
+ # The types the `break`s that terminate THIS call carry out of it, for {#call_dispatch_type_for} to union
3967
+ # with the result the dispatch tiers computed (issue #853).
3968
+ #
3969
+ # The syntactic scan runs first and answers "none" for every call whose block cannot reach a block-level
3970
+ # `break` — nearly all of them — so only that small minority pays the body evaluation. A `break` under a
3971
+ # nested block, lambda, `def`, or loop targets THAT construct instead; {JUMP_BOUNDARY_NODES} stops the
3972
+ # scan there, and the identity filter drops the ones the sink still collects because the nested
3973
+ # construct is walked under the same installation. `break` with no argument carries nil, so the call
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.
3976
+ #
3977
+ # A failure yields no arms rather than propagating, matching {#block_return_type_for}: a call typed
3978
+ # without its break arms is the pre-#853 answer, while a raise here would take out the whole call.
3979
+ def call_break_arm_types(node, receiver_override: nil)
3980
+ block_node = node.block
3981
+ return EMPTY_BREAK_ARMS unless block_node.is_a?(Prism::BlockNode)
3982
+ return EMPTY_BREAK_ARMS if StoredBlockCall.stores_block?(node)
3983
+
3984
+ body = block_node.body
3985
+ return EMPTY_BREAK_ARMS if body.nil? || !block_level_jump?(body, Prism::BreakNode)
3986
+
3987
+ collect_break_arm_types(node, block_node, body, receiver_override)
3988
+ rescue StandardError
3989
+ EMPTY_BREAK_ARMS
3990
+ end
3991
+
3992
+ # Evaluates the block body once under a `break`-value sink, in the same entry scope the block-return pass
3993
+ # uses, so each arm is typed in the scope that actually reaches it — a `break v` after `v = "s"`
3994
+ # contributes `"s"`, not the entry binding — and an arm on a branch the analysis proved dead is never
3995
+ # reached at all.
3996
+ def collect_break_arm_types(call_node, block_node, body, receiver_override)
3997
+ targets = block_level_jump_nodes(body, Prism::BreakNode)
3998
+ receiver = receiver_override || call_receiver_type_for(call_node)
3999
+ return EMPTY_BREAK_ARMS if receiver.nil?
4000
+
4001
+ param_types = break_arm_param_types(call_node, receiver)
4002
+ block_scope = block_entry_scope(
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))
4007
+ )
4008
+ _result, collected = StatementEvaluator.with_break_value_sink do
4009
+ without_block_body_threading { block_scope.evaluate(body) }
4010
+ end
4011
+ collected.filter_map { |jump, type| type if targets.key?(jump) }
4012
+ end
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
+
4032
+ def break_arm_param_types(call_node, receiver)
4033
+ MethodDispatcher.expected_block_param_types(
4034
+ receiver_type: receiver,
4035
+ method_name: call_node.name,
4036
+ arg_types: call_arg_types(call_node),
4037
+ environment: scope.environment,
4038
+ scope: scope
4039
+ )
4040
+ end
4041
+
4042
+ def block_return_for(block_arg, expected, call_node: nil, narrowed_self_type: nil, repeats: false)
3113
4043
  case block_arg
3114
4044
  when Prism::BlockNode
3115
- bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_arg)
3116
- # Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
3117
- # yielding method's business, so the return-typing pass must see the same unmodelled-self mark.
3118
- block_scope = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
3119
- acc.with_local(name, type)
3120
- end
3121
- block_scope = block_scope.with_self_type(narrowed_self_type) if narrowed_self_type
3122
- type_block_body(block_arg, block_scope)
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)
3123
4050
  when Prism::BlockArgumentNode
3124
4051
  symbol_block_return_type(block_arg, expected)
3125
4052
  end
3126
4053
  end
3127
4054
 
4055
+ # The scope a block body is typed under: the surrounding scope plus the parameter bindings the receiving
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.
4059
+ #
4060
+ # Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
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)
4067
+ return block_scope unless narrowed_self_type
4068
+
4069
+ block_scope.with_self_type(narrowed_self_type)
4070
+ end
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
+
3128
4086
  # `&:symbol` desugars to a one-arg Proc that dispatches `symbol` against its argument. When the param
3129
4087
  # type is known and the resulting inner dispatch is precise, this returns the precise carrier;
3130
4088
  # otherwise it returns `Dynamic[Top]` (still non-nil) so the outer dispatcher selects the
@@ -3157,13 +4115,170 @@ module Rigor
3157
4115
  # — which threads scope statement by statement through `StatementEvaluator` — held `42` for the very
3158
4116
  # same node. {#threaded_block_body_type} closes that gap by reusing the main pass's evaluator; a decline
3159
4117
  # keeps the tail-only answer verbatim.
3160
- def type_block_body(block_node, block_scope)
4118
+ #
4119
+ # Issue #841: the fall-through tail is only ONE of the block's exits. Every `next value` that leaves this
4120
+ # block makes `value` the block's result for that invocation, so the arms join into the value type the
4121
+ # same way {#evaluate_body_with_returns} joins a method's early `return`s with its tail. Without the
4122
+ # join, `ops.all? { |o| next false unless o; true }` read as `Constant[true]` and
4123
+ # {MethodDispatcher::BlockFolding} folded the call to always-truthy on a program that really can answer
4124
+ # false — a warning on correct code.
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)
3161
4129
  body = block_node.body
3162
4130
  return Type::Combinator.constant_of(nil) if body.nil?
3163
4131
 
3164
- threaded_block_body_type(body, block_scope) || block_scope.type_of(body)
4132
+ arms = block_level_next_arms(body)
4133
+ return block_body_type_joining_nexts(body, block_scope, arms) if arms
4134
+
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]
4233
+ end
4234
+
4235
+ # Evaluates the body once under a `next` sink and joins the arms that leave THIS block with the
4236
+ # fall-through tail the same evaluation produced. Both halves come from one `StatementEvaluator` run, so
4237
+ # each arm is typed in the scope that actually reaches it — a `next y` after `y = "s"` contributes
4238
+ # `"s"`, not the entry scope's stale binding.
4239
+ #
4240
+ # This run ignores the {#block_body_threading_suppressed?} flag that gates the precision fold: that flag
4241
+ # bounds the cost of an OPTIONAL widening, while skipping the join re-opens a false-positive class. It
4242
+ # still SETS the flag for the duration, so nothing folds below it and the cost stays one evaluation per
4243
+ # block-level `next` — a shape that is a small minority of blocks.
4244
+ #
4245
+ # A failure falls back to the tail-only answer rather than propagating, for the same reason
4246
+ # {#threaded_block_body_type} does: the enclosing `block_return_type_for` rescue would otherwise report
4247
+ # "no block" to the dispatcher, a far larger regression than a wide block return.
4248
+ def block_body_type_joining_nexts(body, block_scope, arms)
4249
+ (fall_through, _exit_scope), collected = StatementEvaluator.with_next_sink do
4250
+ without_block_body_threading { block_scope.evaluate(body) }
4251
+ end
4252
+ joined = collected.filter_map { |node, type| type if arms.key?(node) }
4253
+ joined.empty? ? fall_through : Type::Combinator.union(fall_through, *joined)
4254
+ rescue StandardError
4255
+ block_scope.type_of(body)
4256
+ end
4257
+
4258
+ # The identity-keyed set of `next` nodes whose value is THIS block's value — reachable from the body
4259
+ # without crossing a {JUMP_BOUNDARY_NODES} construct that retargets them — or nil for "do not join".
4260
+ #
4261
+ # nil means "do not join": a body with no block-level `next` keeps today's path exactly, and pays only
4262
+ # the allocation-free scan that answers so. A co-resident block-level `break` used to decline here as
4263
+ # well, because its value was dropped and joining the `next` arms would have dressed a still-incomplete
4264
+ # answer as a complete one; since issue #853 the `break` arms are unioned into the yielding call
4265
+ # ({#call_break_arm_types}), so the two exits are modelled at their own levels and neither blocks the
4266
+ # other.
4267
+ def block_level_next_arms(body)
4268
+ return nil unless block_level_jump?(body, Prism::NextNode)
4269
+
4270
+ block_level_jump_nodes(body, Prism::NextNode)
3165
4271
  end
3166
4272
 
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)
4276
+
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)
4281
+
3167
4282
  # Re-typing the whole body would be wrong to do unconditionally: this path runs for EVERY block-bearing
3168
4283
  # call, and the statements ahead of the tail are pure cost whenever the tail does not depend on them.
3169
4284
  # Three declines keep that cost where the defect actually is, each falling back to the tail-only path:
@@ -3177,8 +4292,11 @@ module Rigor
3177
4292
  # - the fold is not re-entrant. `StatementEvaluator#eval_call` already evaluates each nested block body
3178
4293
  # once, plus up to three more times under the ADR-56 `BodyFixpoint` when the block rebinds a captured
3179
4294
  # local, so a fold nested inside a fold would multiply that work per block-nesting level. Inside a
3180
- # threaded body a nested block-bearing call reverts to the tail-only path — a wider answer in a rare
3181
- # 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}).
3182
4300
  #
3183
4301
  # ADR-56 interaction: the fold cannot double-apply or fight the captured-local write-back. That
3184
4302
  # write-back is `StatementEvaluator#write_back_block_captures`, computed from the CALLER's scope into
@@ -3242,32 +4360,40 @@ module Rigor
3242
4360
  private_constant :VARIABLE_WRITE_NODES
3243
4361
 
3244
4362
  # Every node that OBSERVES a variable binding: the plain reads plus the compound writes, which read
3245
- # 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.
3246
4365
  VARIABLE_READ_NODES = (
3247
4366
  VARIABLE_WRITE_NODES | [
3248
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
4367
+ Prism::LocalVariableReadNode, Prism::ItLocalVariableReadNode, Prism::InstanceVariableReadNode,
3249
4368
  Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
3250
4369
  ]
3251
4370
  ).freeze
3252
4371
  private_constant :VARIABLE_READ_NODES
3253
4372
 
3254
4373
  # A `next` / `break` that leaves THIS block carries a value the fold cannot see: `evaluate(body).first`
3255
- # is the fall-through value only, and no next-value join into the block return exists (`type_of_jump`
3256
- # types both as `Bot`). So `m.synchronize do next 5 if flag; v = 42; v end` really can answer 5 at
3257
- # runtime, and threading would type it `42`. Both forms escape with a value — `next v` is the block's
3258
- # value for that yield, `break v` is the yielding CALL's value — so both must decline.
4374
+ # is the fall-through value only.
4375
+ #
4376
+ # The `next` entry no longer fires: {#type_block_body} routes such a body past this scan entirely, since
4377
+ # issue #841 joins those arms into the block's value. It is kept because the question this predicate
4378
+ # answers ("can this prefix escape with a value the fall-through misses?") is the caller's premise
4379
+ # rather than the caller's filter restated.
4380
+ #
4381
+ # The `break` entry still fires, and since issue #853 it is conservative rather than load-bearing: the
4382
+ # arms are unioned into the CALL ({#call_break_arm_types}), so the missing value is no longer missing.
4383
+ # What the decline still costs is the threaded tail — `m.synchronize do break 5 if flag; v = 42; v end`
4384
+ # answers `5 | Dynamic[top]` where threading would reach `5 | 42`. Lifting it widens no answer and
4385
+ # invents no fact, but it moves the type of every block that carries a `break`, so it is left to a
4386
+ # change that can measure that.
3259
4387
  JUMP_NODES = Set[Prism::NextNode, Prism::BreakNode].freeze
3260
4388
  private_constant :JUMP_NODES
3261
4389
 
3262
4390
  # Constructs that RETARGET a `next` / `break` nested inside them, so a jump below one of these says
3263
- # nothing about our block's value and must not trigger the decline. A nested `BlockNode` / `LambdaNode`
3264
- # is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads soundly — the inner `next`
3265
- # ends the inner iteration); a loop consumes both forms (`while … next 5 … end` continues the loop);
3266
- # a `DefNode` body is a different method entirely.
3267
- JUMP_BOUNDARY_NODES = Set[
3268
- Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
3269
- Prism::WhileNode, Prism::UntilNode, Prism::ForNode
3270
- ].freeze
4391
+ # nothing about our block's value — it neither triggers the decline nor joins as an arm. A nested
4392
+ # `BlockNode` / `LambdaNode` is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads
4393
+ # soundly — the inner `next` ends the inner iteration); a loop consumes both forms (`while … next 5 …
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
3271
4397
  private_constant :JUMP_BOUNDARY_NODES
3272
4398
 
3273
4399
  # True when the tail statement observes a variable name one of the earlier statements binds OR mutates
@@ -3282,9 +4408,13 @@ module Rigor
3282
4408
  # hands downstream rules a provably-empty array. Threading is the fix, not a cost: `StatementEvaluator`
3283
4409
  # runs `MutationWidening.widen_after_call` on the `push`, so the threaded tail reads the widened
3284
4410
  # `Array[…]`. A call therefore contributes every variable its receiver can evaluate to
3285
- # ({ReceiverAlias.candidates} — the ternary-selected receiver of issue #277 included) whenever its name
3286
- # is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
3287
- # 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"` / `[]`.
3288
4418
  #
3289
4419
  # Cost is two walks of the body, the second only when the first found a write and no jump — the same
3290
4420
  # order of cost `StatementEvaluator`'s own per-call captured-write scan already pays, and far below
@@ -3293,53 +4423,115 @@ module Rigor
3293
4423
  # so `[1].each { v = 5 }` really does bind the outer `v`, and `[1].each { outer << 1 }` really does
3294
4424
  # mutate the outer `outer`), while a jump counts only above the nearest {JUMP_BOUNDARY_NODES} boundary.
3295
4425
  def tail_depends_on_body_binding?(statements)
3296
- written = Set.new
3297
- statements[0...-1].each do |statement|
3298
- return false unless prefix_statement_jump_free?(statement, written, false)
3299
- end
3300
- return false if written.empty?
4426
+ written = prefix_written_names(statements)
4427
+ return false if written.nil?
3301
4428
 
3302
4429
  Source::NodeWalker.each(statements.last) do |node|
3303
- 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))
3304
4431
  end
3305
4432
  false
3306
4433
  end
3307
4434
 
4435
+ # WHICH names the predicate above answers YES on — every name the tail observes that the prefix binds
4436
+ # or mutates in place. Only the tail-only floor ({#unanswered_tail_dependency?}) needs them, which is
4437
+ # why the predicate is not written over this method: the predicate runs for every multi-statement block
4438
+ # body and short-circuits on the first hit, the floor runs only for a walk typed tail-only. Sharing
4439
+ # {#prefix_written_names} is what keeps the two from drifting about what the prefix binds.
4440
+ def tail_dependent_body_names(statements)
4441
+ written = prefix_written_names(statements)
4442
+ return EMPTY_NAME_SET if written.nil?
4443
+
4444
+ Source::NodeWalker.each(statements.last).filter_map do |node|
4445
+ next unless VARIABLE_READ_NODES.include?(node.class)
4446
+
4447
+ name = ReceiverAlias.read_name(node)
4448
+ name if written.include?(name)
4449
+ end.to_set
4450
+ end
4451
+
4452
+ # The names the prefix binds or mutates in place, or `nil` when the scan declines — a prefix that can
4453
+ # jump out of the block with a value the fall-through misses, or one that binds nothing at all.
4454
+ def prefix_written_names(statements)
4455
+ written = Set.new
4456
+ statements[0...-1].each do |statement|
4457
+ return nil unless prefix_statement_jump_free?(statement, written, false, false)
4458
+ end
4459
+ return nil if written.empty?
4460
+
4461
+ written
4462
+ end
4463
+
4464
+ EMPTY_NAME_SET = Set.new.freeze
4465
+ private_constant :EMPTY_NAME_SET
4466
+
3308
4467
  # True when `node` cannot jump out of the block with a value, collecting into `written` the names it
3309
- # binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call, through
3310
- # every variable its receiver can evaluate to) on the way down. `retargeted` is true once the descent
3311
- # 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.
3312
4472
  #
3313
4473
  # A `Prism::DefinedNode`'s operand is never evaluated, so it is not descended into — the same rule
3314
4474
  # {Source::NodeWalker} applies, for the same reason: neither a write nor a jump under `defined?` runs.
3315
- def prefix_statement_jump_free?(node, written, retargeted)
4475
+ def prefix_statement_jump_free?(node, written, retargeted, nested)
3316
4476
  return false if !retargeted && JUMP_NODES.include?(node.class)
3317
4477
 
3318
4478
  written << node.name if VARIABLE_WRITE_NODES.include?(node.class)
3319
- 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)
3320
4480
  return true if node.is_a?(Prism::DefinedNode)
3321
4481
 
3322
4482
  child_retargeted = retargeted || JUMP_BOUNDARY_NODES.include?(node.class)
4483
+ child_nested = nested || CLOSURE_NODES.include?(node.class)
3323
4484
  node.rigor_each_child do |child|
3324
- 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)
3325
4486
  end
3326
4487
  true
3327
4488
  end
3328
4489
 
3329
- def collect_mutated_receivers(call_node, written)
3330
- 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)
3331
4512
 
3332
- ReceiverAlias.candidates(call_node.receiver).each { |read| written << read.name }
4513
+ yield ReceiverAlias.read_name(read)
4514
+ end
3333
4515
  end
3334
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
+
3335
4521
  # v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
3336
4522
  # Tuple position, binds the block parameter to that element's type, and re-types the block body. The
3337
4523
  # per-position results are assembled into `Tuple[U_1..U_n]`, strictly tighter than the RBS-projected
3338
4524
  # `Array[union]`.
3339
4525
  #
3340
4526
  # Declines (returns nil) when the receiver is not a `Tuple` with at least one element, when the call
3341
- # has no `Prism::BlockNode`, when the method is outside the supported set, when block typing raises
3342
- # 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.
3343
4535
  PER_ELEMENT_TUPLE_METHODS = Set[
3344
4536
  :map, :collect, :filter_map, :flat_map,
3345
4537
  :select, :filter, :reject,
@@ -3353,6 +4545,9 @@ module Rigor
3353
4545
  ].freeze
3354
4546
  private_constant :HASH_SHAPE_TRANSFORM_METHODS
3355
4547
 
4548
+ IN_PLACE_HASH_SHAPE_TRANSFORMS = Set[:transform_keys!, :transform_values!].freeze
4549
+ private_constant :IN_PLACE_HASH_SHAPE_TRANSFORMS
4550
+
3356
4551
  # Cardinality cap for per-element block fold over finite-bound `Constant<Range>` receivers. Walking
3357
4552
  # `(1..1_000_000).map { … }` element-wise would balloon block-typing cost and explode the resulting
3358
4553
  # Tuple, so only short ranges expand into per-position folds. Larger ranges decline so the RBS tier
@@ -3362,7 +4557,7 @@ module Rigor
3362
4557
 
3363
4558
  def try_per_element_block_fold(call_node, receiver_type)
3364
4559
  return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
3365
- return nil if find_family_with_args?(call_node)
4560
+ return nil unless call_node.arguments.nil?
3366
4561
 
3367
4562
  element_types = per_element_elements_of(receiver_type)
3368
4563
  return nil if element_types.nil? || element_types.empty?
@@ -3370,7 +4565,59 @@ module Rigor
3370
4565
  per_position = per_element_block_results(call_node.block, element_types)
3371
4566
  return nil if per_position.nil? || per_position.any?(&:nil?)
3372
4567
 
3373
- assemble_per_element_result(call_node.name, per_position, element_types)
4568
+ assemble_per_element_result(call_node.name, per_position, element_types) ||
4569
+ undecided_fold_floor(call_node.name, element_types, receiver_type)
4570
+ end
4571
+
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.
4576
+ #
4577
+ # Falling through to the dispatcher was WRONG for this family, and issue #617 residue (1) is the bill:
4578
+ # `seen = 0; [1, 2].find do |e| seen += 1; seen == 2 end` answered `nil` where the runtime answers `2`.
4579
+ # The dispatcher's `BlockFolding` reads ONE block-return type, typed from the call's ENTRY scope, so a
4580
+ # predicate over a rebound capture pins the first iteration (`Constant[false]`) and
4581
+ # `FALSEY_BLOCK_NIL_METHODS` short-circuits the whole call to `nil`. This walk already knows better: it
4582
+ # typed the predicate per position and saw that it does not fold. Answering here is what keeps that
4583
+ # knowledge from being thrown away in favour of a worse-informed tier.
4584
+ #
4585
+ # The floor is what `find` can return and no tighter: one of the receiver's own elements, or `nil` when
4586
+ # no element matches. Value pinning survives because it is still true of every candidate — `find` hands
4587
+ # back an element, it does not compute one. `find_index` / `index` answer a position in the receiver, so
4588
+ # `Integer?` is their floor.
4589
+ #
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)
4602
+ case method_name
4603
+ when :find, :detect
4604
+ Type::Combinator.union(*element_types, Type::Combinator.constant_of(nil))
4605
+ when :select, :filter, :reject
4606
+ filter_family_floor(element_types, receiver_type)
4607
+ when :find_index, :index
4608
+ Type::Combinator.union(Type::Combinator.nominal_of("Integer"), Type::Combinator.constant_of(nil))
4609
+ end
4610
+ end
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])
3374
4621
  end
3375
4622
 
3376
4623
  # Evaluates the call's block once per receiver element. Two block shapes are supported:
@@ -3405,13 +4652,86 @@ module Rigor
3405
4652
  def per_element_body_results(block, element_types)
3406
4653
  captured = per_element_captured_bindings(block, element_types)
3407
4654
  results = lambda do
3408
- 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
3409
4658
  end
3410
- return results.call if element_types.size <= PER_ELEMENT_THREADING_LIMIT
4659
+ return results.call unless tail_only_walk?(element_types)
4660
+ return tail_only_body_floor(element_types) if unanswered_tail_dependency?(block, captured)
3411
4661
 
3412
4662
  without_block_body_threading(&results)
3413
4663
  end
3414
4664
 
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.
4680
+ #
4681
+ # #584's cliff comment promised `Dynamic[top]` above the cap, and that held for a body-LOCAL: `[1, …,
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)
4699
+ Array.new(element_types.size) { Type::Combinator.untyped }
4700
+ end
4701
+
4702
+ # True when the tail reads something the prefix changed that NOTHING has re-answered for this walk.
4703
+ #
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.
4722
+ def unanswered_tail_dependency?(block, captured)
4723
+ body = block.body
4724
+ return false unless body.is_a?(Prism::StatementsNode)
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?
4730
+
4731
+ entry = BlockParameterBinder.new.bind_onto(block, scope)
4732
+ names.any? { |name| name.start_with?("@", "$") || !entry.local(name).nil? }
4733
+ end
4734
+
3415
4735
  # Issue #587 (b) — first-iteration pinning. Every position of this fold is typed from the SAME entry
3416
4736
  # scope, so a body that rebinds a captured outer local answers the FIRST iteration's value at every
3417
4737
  # position: `total = 0; [1, 2].map do total += 1; total end` folded to `[1, 1]` (runtime `[1, 2]`), and
@@ -3424,12 +4744,12 @@ module Rigor
3424
4744
  # position's entry scope, so a position answers what the local can be in ANY iteration
3425
4745
  # (`[Integer, Integer]`), never what it was in the first.
3426
4746
  #
3427
- # Only the rebound names move. A position whose tail reads an untouched captured local or a block-local
3428
- # keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound counter still
3429
- # decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would have lost all
3430
- # three for nothing. The fixpoint binds the block parameter to the union of the elements, so its cost
3431
- # is independent of the arity — which is why the per-element threading cap is NOT a reason to floor: a
3432
- # 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`.
3433
4753
  #
3434
4754
  # Under threading suppression — this fold nested inside another threaded body — the fixpoint's body
3435
4755
  # evaluations are exactly the re-entrant cost the suppression exists to refuse, so the names take the
@@ -3437,42 +4757,256 @@ module Rigor
3437
4757
  # fixpoint takes the same floor rather than the seed — a seed that reaches a position is the pin this
3438
4758
  # exists to remove.
3439
4759
  #
3440
- # 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.
3441
4800
  def per_element_captured_bindings(block, element_types)
3442
- names = CapturedLocals.writes(block, scope)
3443
- return nil if names.empty?
3444
- 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?
3445
4822
 
3446
- begin
3447
- converged_captured_bindings(block, names, element_types)
3448
- rescue StandardError
3449
- 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
3450
4838
  end
3451
4839
  end
3452
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
+
3453
4851
  def captured_floor(names)
3454
4852
  names.to_h { |name| [name, Type::Combinator.untyped] }
3455
4853
  end
3456
4854
 
3457
- def converged_captured_bindings(block, names, element_types)
3458
- param_types = [Type::Combinator.union(*element_types)]
3459
- BodyFixpoint.converge(
3460
- names: names,
3461
- seed_bindings: 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 = {}
4869
+ converged = BodyFixpoint.converge(
4870
+ names: moving,
4871
+ seed_bindings: seeds,
3462
4872
  widen: Type::Combinator.method(:widen_value_pinned),
3463
- 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
3464
4876
  )
4877
+ unmoved_pins_floored(converged, seeds, moved).merge(captured_floor(floored))
4878
+ end
4879
+
4880
+ # A name the write scan says this block REBINDS, whose fixpoint came back on exactly its value-pinned
4881
+ # seed, is floored rather than believed.
4882
+ #
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.
4891
+ #
4892
+ # An unmoved pin cannot be distinguished from a write that genuinely restores its own entry value
4893
+ # (`x = 5; xs.each { x = 5 }`), so the floor gives that shape up too. It is the far cheaper side: a
4894
+ # value-pinned seed the block rebinds is the exact pre-state this fold exists to stop trusting, and
4895
+ # `Dynamic[top]` is the same escaping-block floor {#captured_floor} already uses. Seeds that carry no
4896
+ # pinning are left alone — there is no first-iteration value or shape in them to remove, and widening a
4897
+ # `Nominal` here would only lose a class for nothing.
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)
4944
+ converged.to_h do |name, type|
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))
4952
+
4953
+ [name, Type::Combinator.untyped]
4954
+ end
4955
+ end
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.
4960
+ def value_pinned?(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
3465
4967
  end
3466
4968
 
3467
4969
  # One fixpoint pass: the body evaluated from `bindings` with the block parameters bound over them (the
3468
- # same layering as {#type_block_body_with_param}), returning the per-name exit binding. Threading is
3469
- # suppressed for the pass, as it is for every full body evaluation the block-return pass runs.
3470
- def captured_exit_bindings(block, param_types, bindings, names)
3471
- params = BlockParameterBinder.new(expected_param_types: param_types).bind(block)
3472
- entry = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
3473
- entry = params.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
3474
- _type, exit_scope = without_block_body_threading { entry.evaluate(block.body) }
3475
- 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
3476
5010
  end
3477
5011
 
3478
5012
  def per_element_symbol_results(block_arg, element_types)
@@ -3501,7 +5035,7 @@ module Rigor
3501
5035
  # `Constant<a..b>` → [Constant[a], …, Constant[b]]
3502
5036
  # everything else → nil
3503
5037
  #
3504
- # Note: `Type::IntegerRange` is the bounded-Integer carrier (`int<a, b>` represents "an Integer between
5038
+ # Note: `Type::IntegerRange` is the bounded-Integer carrier (`Integer[a..b]` represents "an Integer between
3505
5039
  # a and b"), not a Range value. Calls like `.map` / `.find` on an `IntegerRange` receiver would resolve
3506
5040
  # to `Integer#map` / `Integer#find` — neither exists — so IntegerRange does NOT participate in this
3507
5041
  # fold.
@@ -3541,7 +5075,7 @@ module Rigor
3541
5075
  #
3542
5076
  # Part 1 (soundness): the accumulator of a block-form fold must reach a fixpoint over an unknown
3543
5077
  # number of iterations — the RBS tier's generic `(S) { (S, E) -> S } -> S` binds `S` from a SINGLE
3544
- # block pass (acc=seed, elem=element-join), so `(1..5).inject(1) { |a, i| a * i }` types `int<1, 5>`
5078
+ # block pass (acc=seed, elem=element-join), so `(1..5).inject(1) { |a, i| a * i }` types `Integer[1..5]`
3545
5079
  # while the runtime is 120 (out of range — unsound). We iterate the accumulator type to a capped
3546
5080
  # fixpoint (ADR-55/56 `BodyFixpoint`) so the multiply converges to `Integer`, never a value-bounded
3547
5081
  # interval the runtime escapes.
@@ -3557,8 +5091,6 @@ module Rigor
3557
5091
  # tier. Captured-local write-back (ADR-56) runs at the statement level independent of this return-type
3558
5092
  # computation, so a block that both accumulates and mutates captured state keeps its write-back
3559
5093
  # regardless of which arm answers here.
3560
- #
3561
- # @return [Rigor::Type, nil]
3562
5094
  def try_block_inject_fold(call_node, receiver, arg_types)
3563
5095
  return nil unless INJECT_METHODS.include?(call_node.name)
3564
5096
 
@@ -3577,7 +5109,7 @@ module Rigor
3577
5109
  # Splits the positional args into the optional seed. A Symbol final arg (`inject(seed, :*)`) is the
3578
5110
  # no-block Symbol form and never reaches here (the block guard already failed for it).
3579
5111
  #
3580
- # @return [Array(Rigor::Type, nil), Boolean] `[seed, has_seed]`
5112
+ # @return `[seed, has_seed]`
3581
5113
  def inject_seed(arg_types)
3582
5114
  case arg_types.size
3583
5115
  when 0 then [nil, false]
@@ -3637,7 +5169,7 @@ module Rigor
3637
5169
  # member is folded; without a seed the first member seeds the memo and the rest are folded. The
3638
5170
  # accumulator is carried as a `Constant` type (so the block body sees a value-pinned param).
3639
5171
  #
3640
- # @return [Array(Rigor::Type::Constant, nil), Array] `[acc, rest]`
5172
+ # @return `[acc, rest]`
3641
5173
  def inject_constant_start(members, seed, has_seed)
3642
5174
  if has_seed
3643
5175
  return [nil, []] unless seed.is_a?(Type::Constant) && inject_foldable?(seed.value)
@@ -3698,15 +5230,6 @@ module Rigor
3698
5230
  converged[:__inject_acc__] || seed_acc
3699
5231
  end
3700
5232
 
3701
- # `index(value)` and `find_index(value)` carry a positional argument and search by `==` rather than
3702
- # running the block. Decline so the RBS tier owns those forms.
3703
- def find_family_with_args?(call_node)
3704
- return false unless %i[find_index index].include?(call_node.name)
3705
-
3706
- args = call_node.arguments
3707
- !args.nil? && !args.arguments.empty?
3708
- end
3709
-
3710
5233
  def assemble_per_element_result(method_name, per_position, element_types)
3711
5234
  case method_name
3712
5235
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
@@ -3774,28 +5297,40 @@ module Rigor
3774
5297
  # `find` / `detect`: returns the first receiver element whose block result is Ruby-truthy, or `nil`
3775
5298
  # when no position folds to truthy.
3776
5299
  #
3777
- # Folds tightly only when every per-position block result is a `Type::Constant` — otherwise we cannot
3778
- # decide which position (if any) is "the first matching one". When the first decisive truthy position
3779
- # is found, the answer is the corresponding receiver element. When every position folds to falsey,
3780
- # 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}).
3781
5305
  def assemble_find_result(per_position, element_types)
3782
- return nil unless per_position.all?(Type::Constant)
3783
-
3784
- first_truthy_index = per_position.index { |type| truthy_constant?(type) }
3785
- return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
5306
+ candidates = first_match_candidates(per_position)
5307
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
3786
5308
 
3787
- element_types[first_truthy_index]
5309
+ Type::Combinator.union(*candidates.map { |index| element_types[index] })
3788
5310
  end
3789
5311
 
3790
5312
  # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
3791
- # nothing matches.
5313
+ # nothing matches, on {#assemble_find_result}'s terms.
3792
5314
  def assemble_find_index_result(per_position)
3793
- 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)
3794
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
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)
3795
5327
  first_truthy_index = per_position.index { |type| truthy_constant?(type) }
3796
- 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
3797
5331
 
3798
- 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]
3799
5334
  end
3800
5335
 
3801
5336
  def truthy_constant?(type)
@@ -3816,28 +5351,47 @@ module Rigor
3816
5351
  # `Constant[Symbol | String]` — otherwise the tier declines (the new key cannot be used as a static
3817
5352
  # HashShape index). Collisions (two old keys mapping to the same new key) also decline.
3818
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
+ #
3819
5362
  # Returns `nil` on any decline so the dispatcher falls through to `RbsDispatch` and gets the widened
3820
5363
  # `Hash[K, V]` answer.
3821
5364
  def try_hash_shape_block_fold(call_node, receiver_type)
3822
- return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
3823
- return nil unless receiver_type.is_a?(Type::HashShape)
3824
- return nil unless receiver_type.closed?
3825
- return nil unless receiver_type.optional_keys.empty?
5365
+ return nil unless hash_shape_fold_applicable?(call_node, receiver_type)
3826
5366
 
3827
5367
  block_arg = call_node.block
3828
- return nil if block_arg.nil?
5368
+ return nil if in_place_block_sees_receiver?(call_node.name, block_arg)
3829
5369
 
3830
5370
  if %i[transform_values transform_values!].include?(call_node.name)
3831
5371
  fold_hash_shape_transform_values(receiver_type, block_arg)
3832
5372
  else
3833
- 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!)
3834
5374
  end
3835
5375
  end
3836
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
+
3837
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
+
3838
5392
  new_pairs = {}
3839
- shape.pairs.each do |key, value|
3840
- 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)
3841
5395
  return nil if new_value.nil?
3842
5396
 
3843
5397
  new_pairs[key] = new_value
@@ -3845,28 +5399,125 @@ module Rigor
3845
5399
  Type::Combinator.hash_shape_of(new_pairs)
3846
5400
  end
3847
5401
 
3848
- def fold_hash_shape_transform_keys(shape, block_arg)
3849
- new_pairs = {}
3850
- shape.pairs.each do |key, value|
3851
- key_type = Type::Combinator.constant_of(key)
3852
- new_key_type = apply_hash_block(block_arg, key_type)
3853
- 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)
3854
5406
 
3855
- new_key = new_key_type.value
3856
- return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
3857
- 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?
3858
5412
 
3859
5413
  new_pairs[new_key] = value
3860
5414
  end
3861
- 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
+ )
3862
5511
  end
3863
5512
 
3864
5513
  # Applies a single-argument block (either a full BlockNode or a `&:symbol` BlockArgumentNode) to
3865
- # `param_type` and returns the resulting type, or `nil` on failure.
3866
- 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)
3867
5518
  case block_arg
3868
5519
  when Prism::BlockNode
3869
- type_block_body_with_param(block_arg, [param_type])
5520
+ type_block_body_with_param(block_arg, [param_type], captured: captured, position: position)
3870
5521
  when Prism::BlockArgumentNode
3871
5522
  expression = block_arg.expression
3872
5523
  return nil unless expression.is_a?(Prism::SymbolNode)
@@ -3883,13 +5534,18 @@ module Rigor
3883
5534
  end
3884
5535
  end
3885
5536
 
3886
- # `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
3887
5539
  # ({#per_element_captured_bindings}), laid under the parameter bindings so a parameter still shadows.
3888
- def type_block_body_with_param(block_node, expected_param_types, captured: nil)
3889
- bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
3890
- block_scope = (captured || {}).reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
3891
- block_scope = bindings.reduce(block_scope) { |acc, (name, type)| acc.with_local(name, type) }
3892
- 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)
3893
5549
  rescue StandardError
3894
5550
  nil
3895
5551
  end