rigortype 0.2.7 → 0.2.9

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 (433) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/04-diagnostics.md +7 -4
  11. data/docs/manual/06-baseline.md +5 -2
  12. data/docs/manual/07-plugins.md +1 -1
  13. data/docs/manual/14-rails-quickstart.md +4 -2
  14. data/docs/manual/15-type-protection-coverage.md +27 -4
  15. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  16. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  17. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  18. data/lib/rigor/analysis/baseline.rb +56 -96
  19. data/lib/rigor/analysis/buffer_binding.rb +13 -20
  20. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +29 -48
  21. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +35 -54
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +23 -37
  23. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +16 -23
  24. data/lib/rigor/analysis/check_rules/rule_walk.rb +48 -71
  25. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +19 -28
  26. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +48 -67
  27. data/lib/rigor/analysis/check_rules.rb +174 -274
  28. data/lib/rigor/analysis/dependency_recorder.rb +26 -38
  29. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +13 -25
  30. data/lib/rigor/analysis/dependency_source_inference/builder.rb +17 -30
  31. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +15 -26
  32. data/lib/rigor/analysis/dependency_source_inference/index.rb +45 -76
  33. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +23 -40
  34. data/lib/rigor/analysis/dependency_source_inference/walker.rb +50 -83
  35. data/lib/rigor/analysis/dependency_source_inference.rb +6 -10
  36. data/lib/rigor/analysis/diagnostic.rb +48 -75
  37. data/lib/rigor/analysis/erb_template_detector.rb +13 -18
  38. data/lib/rigor/analysis/fact_store.rb +12 -22
  39. data/lib/rigor/analysis/incremental.rb +47 -63
  40. data/lib/rigor/analysis/incremental_session.rb +74 -100
  41. data/lib/rigor/analysis/project_scan.rb +12 -23
  42. data/lib/rigor/analysis/result.rb +3 -5
  43. data/lib/rigor/analysis/rule_catalog.rb +67 -95
  44. data/lib/rigor/analysis/run_stats.rb +38 -61
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +101 -160
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +121 -188
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +62 -96
  48. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -18
  49. data/lib/rigor/analysis/runner.rb +220 -333
  50. data/lib/rigor/analysis/self_call_resolution_recorder.rb +30 -43
  51. data/lib/rigor/analysis/worker_session.rb +64 -102
  52. data/lib/rigor/ast/type_node.rb +6 -8
  53. data/lib/rigor/ast.rb +6 -10
  54. data/lib/rigor/bleeding_edge.rb +27 -35
  55. data/lib/rigor/builtins/hkt_builtins.rb +7 -14
  56. data/lib/rigor/builtins/imported_refinements.rb +106 -149
  57. data/lib/rigor/builtins/predefined_constant_refinements.rb +51 -65
  58. data/lib/rigor/builtins/regex_refinement.rb +32 -43
  59. data/lib/rigor/builtins/static_return_refinements.rb +57 -81
  60. data/lib/rigor/cache/descriptor.rb +58 -94
  61. data/lib/rigor/cache/incremental_snapshot.rb +32 -48
  62. data/lib/rigor/cache/rbs_cache_producer.rb +10 -15
  63. data/lib/rigor/cache/rbs_class_ancestor_table.rb +10 -16
  64. data/lib/rigor/cache/rbs_class_type_param_names.rb +10 -16
  65. data/lib/rigor/cache/rbs_constant_table.rb +8 -13
  66. data/lib/rigor/cache/rbs_descriptor.rb +8 -13
  67. data/lib/rigor/cache/rbs_environment.rb +11 -19
  68. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +11 -19
  69. data/lib/rigor/cache/rbs_known_class_names.rb +8 -12
  70. data/lib/rigor/cache/store.rb +248 -186
  71. data/lib/rigor/cli/annotate_command.rb +51 -88
  72. data/lib/rigor/cli/baseline_command.rb +10 -17
  73. data/lib/rigor/cli/check_command.rb +121 -199
  74. data/lib/rigor/cli/check_runner_factory.rb +8 -14
  75. data/lib/rigor/cli/ci_detector.rb +9 -14
  76. data/lib/rigor/cli/command.rb +8 -14
  77. data/lib/rigor/cli/coverage_command.rb +62 -48
  78. data/lib/rigor/cli/coverage_mutation.rb +14 -19
  79. data/lib/rigor/cli/coverage_report.rb +0 -2
  80. data/lib/rigor/cli/coverage_scan.rb +7 -11
  81. data/lib/rigor/cli/diagnostic_formats.rb +46 -62
  82. data/lib/rigor/cli/diff_command.rb +10 -20
  83. data/lib/rigor/cli/docs_command.rb +26 -40
  84. data/lib/rigor/cli/doctor_command.rb +8 -9
  85. data/lib/rigor/cli/explain_command.rb +4 -7
  86. data/lib/rigor/cli/fused_protection_renderer.rb +4 -5
  87. data/lib/rigor/cli/fused_protection_report.rb +6 -8
  88. data/lib/rigor/cli/lsp_command.rb +11 -19
  89. data/lib/rigor/cli/mcp_command.rb +4 -6
  90. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -4
  91. data/lib/rigor/cli/mutation_protection_report.rb +6 -9
  92. data/lib/rigor/cli/options.rb +11 -19
  93. data/lib/rigor/cli/plugin_command.rb +18 -30
  94. data/lib/rigor/cli/plugins_command.rb +29 -51
  95. data/lib/rigor/cli/plugins_renderer.rb +12 -20
  96. data/lib/rigor/cli/prism_colorizer.rb +13 -19
  97. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  98. data/lib/rigor/cli/protection_renderer.rb +5 -7
  99. data/lib/rigor/cli/protection_report.rb +34 -18
  100. data/lib/rigor/cli/renderable.rb +4 -6
  101. data/lib/rigor/cli/show_bleedingedge_command.rb +5 -8
  102. data/lib/rigor/cli/sig_gen_command.rb +14 -26
  103. data/lib/rigor/cli/skill_command.rb +30 -47
  104. data/lib/rigor/cli/skill_describe.rb +40 -64
  105. data/lib/rigor/cli/trace_command.rb +9 -16
  106. data/lib/rigor/cli/trace_renderer.rb +35 -51
  107. data/lib/rigor/cli/triage_command.rb +6 -10
  108. data/lib/rigor/cli/triage_renderer.rb +1 -2
  109. data/lib/rigor/cli/type_of_command.rb +14 -23
  110. data/lib/rigor/cli/type_of_renderer.rb +3 -5
  111. data/lib/rigor/cli/type_scan_command.rb +6 -10
  112. data/lib/rigor/cli/type_scan_renderer.rb +3 -5
  113. data/lib/rigor/cli/type_scan_report.rb +2 -3
  114. data/lib/rigor/cli/upgrade_command.rb +2 -3
  115. data/lib/rigor/cli.rb +16 -28
  116. data/lib/rigor/config_audit.rb +35 -44
  117. data/lib/rigor/configuration/dependencies.rb +41 -70
  118. data/lib/rigor/configuration/severity_profile.rb +28 -42
  119. data/lib/rigor/configuration.rb +150 -241
  120. data/lib/rigor/environment/bundle_sig_discovery.rb +74 -118
  121. data/lib/rigor/environment/class_registry.rb +19 -26
  122. data/lib/rigor/environment/constant_type_cache_holder.rb +14 -21
  123. data/lib/rigor/environment/hkt_registry_holder.rb +6 -12
  124. data/lib/rigor/environment/lockfile_resolver.rb +31 -47
  125. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  126. data/lib/rigor/environment/rbs_collection_discovery.rb +39 -63
  127. data/lib/rigor/environment/rbs_coverage_report.rb +24 -42
  128. data/lib/rigor/environment/rbs_loader.rb +420 -518
  129. data/lib/rigor/environment/reflection.rb +28 -53
  130. data/lib/rigor/environment/reporters.rb +13 -24
  131. data/lib/rigor/environment.rb +225 -304
  132. data/lib/rigor/flow_contribution/conflict.rb +16 -27
  133. data/lib/rigor/flow_contribution/element.rb +7 -12
  134. data/lib/rigor/flow_contribution/fact.rb +33 -57
  135. data/lib/rigor/flow_contribution/merge_result.rb +6 -9
  136. data/lib/rigor/flow_contribution/merger.rb +32 -47
  137. data/lib/rigor/flow_contribution.rb +37 -55
  138. data/lib/rigor/inference/acceptance.rb +133 -219
  139. data/lib/rigor/inference/block_parameter_binder.rb +52 -82
  140. data/lib/rigor/inference/body_fixpoint.rb +22 -30
  141. data/lib/rigor/inference/budget_trace.rb +43 -63
  142. data/lib/rigor/inference/builtins/array_catalog.rb +4 -6
  143. data/lib/rigor/inference/builtins/comparable_catalog.rb +4 -6
  144. data/lib/rigor/inference/builtins/complex_catalog.rb +13 -19
  145. data/lib/rigor/inference/builtins/date_catalog.rb +38 -67
  146. data/lib/rigor/inference/builtins/encoding_catalog.rb +22 -36
  147. data/lib/rigor/inference/builtins/enumerable_catalog.rb +4 -6
  148. data/lib/rigor/inference/builtins/exception_catalog.rb +38 -57
  149. data/lib/rigor/inference/builtins/hash_catalog.rb +9 -14
  150. data/lib/rigor/inference/builtins/method_catalog.rb +29 -48
  151. data/lib/rigor/inference/builtins/numeric_catalog.rb +13 -18
  152. data/lib/rigor/inference/builtins/pathname_catalog.rb +8 -13
  153. data/lib/rigor/inference/builtins/proc_catalog.rb +38 -62
  154. data/lib/rigor/inference/builtins/random_catalog.rb +21 -32
  155. data/lib/rigor/inference/builtins/range_catalog.rb +12 -21
  156. data/lib/rigor/inference/builtins/rational_catalog.rb +9 -15
  157. data/lib/rigor/inference/builtins/re_catalog.rb +29 -48
  158. data/lib/rigor/inference/builtins/set_catalog.rb +18 -27
  159. data/lib/rigor/inference/builtins/string_catalog.rb +12 -19
  160. data/lib/rigor/inference/builtins/struct_catalog.rb +18 -30
  161. data/lib/rigor/inference/builtins/time_catalog.rb +28 -44
  162. data/lib/rigor/inference/closure_escape_analyzer.rb +44 -67
  163. data/lib/rigor/inference/coverage_scanner.rb +13 -18
  164. data/lib/rigor/inference/def_return_typer.rb +8 -14
  165. data/lib/rigor/inference/dynamic_origin.rb +23 -20
  166. data/lib/rigor/inference/expression_typer.rb +794 -1154
  167. data/lib/rigor/inference/fallback.rb +8 -12
  168. data/lib/rigor/inference/fallback_tracer.rb +4 -10
  169. data/lib/rigor/inference/flow_tracer.rb +25 -36
  170. data/lib/rigor/inference/fork_map.rb +87 -0
  171. data/lib/rigor/inference/hkt_body.rb +45 -68
  172. data/lib/rigor/inference/hkt_body_parser.rb +33 -52
  173. data/lib/rigor/inference/hkt_reducer.rb +38 -59
  174. data/lib/rigor/inference/hkt_registry.rb +50 -76
  175. data/lib/rigor/inference/indexed_narrowing.rb +55 -81
  176. data/lib/rigor/inference/macro_block_self_type.rb +21 -34
  177. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +11 -18
  178. data/lib/rigor/inference/method_dispatcher/block_folding.rb +49 -83
  179. data/lib/rigor/inference/method_dispatcher/call_context.rb +16 -23
  180. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +13 -22
  181. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +307 -454
  182. data/lib/rigor/inference/method_dispatcher/data_folding.rb +24 -34
  183. data/lib/rigor/inference/method_dispatcher/file_folding.rb +22 -37
  184. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +52 -81
  185. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +51 -78
  186. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +60 -97
  187. data/lib/rigor/inference/method_dispatcher/math_folding.rb +23 -34
  188. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +18 -26
  189. data/lib/rigor/inference/method_dispatcher/method_folding.rb +37 -71
  190. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +114 -182
  191. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +122 -193
  192. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +15 -24
  193. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +64 -93
  194. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +31 -45
  195. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -9
  196. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +356 -511
  197. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +22 -32
  198. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +15 -21
  199. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +66 -82
  200. data/lib/rigor/inference/method_dispatcher/time_folding.rb +10 -15
  201. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -13
  202. data/lib/rigor/inference/method_dispatcher.rb +378 -524
  203. data/lib/rigor/inference/method_parameter_binder.rb +81 -114
  204. data/lib/rigor/inference/multi_target_binder.rb +51 -68
  205. data/lib/rigor/inference/mutation_widening.rb +115 -158
  206. data/lib/rigor/inference/narrowing.rb +627 -727
  207. data/lib/rigor/inference/origin_lookup.rb +38 -0
  208. data/lib/rigor/inference/parameter_inference_collector.rb +154 -109
  209. data/lib/rigor/inference/precision_scanner.rb +26 -35
  210. data/lib/rigor/inference/project_patched_methods.rb +20 -32
  211. data/lib/rigor/inference/project_patched_scanner.rb +23 -37
  212. data/lib/rigor/inference/protection_scanner.rb +16 -18
  213. data/lib/rigor/inference/rbs_type_translator.rb +49 -66
  214. data/lib/rigor/inference/scope_indexer.rb +473 -821
  215. data/lib/rigor/inference/statement_evaluator.rb +754 -1114
  216. data/lib/rigor/inference/struct_fold_safety.rb +34 -48
  217. data/lib/rigor/inference/synthetic_method.rb +9 -16
  218. data/lib/rigor/inference/synthetic_method_index.rb +20 -35
  219. data/lib/rigor/inference/synthetic_method_scanner.rb +63 -103
  220. data/lib/rigor/language_server/buffer_resolution.rb +6 -9
  221. data/lib/rigor/language_server/buffer_table.rb +11 -18
  222. data/lib/rigor/language_server/completion_provider.rb +69 -116
  223. data/lib/rigor/language_server/debouncer.rb +16 -25
  224. data/lib/rigor/language_server/diagnostic_publisher.rb +26 -40
  225. data/lib/rigor/language_server/document_symbol_provider.rb +14 -19
  226. data/lib/rigor/language_server/folding_range_provider.rb +11 -19
  227. data/lib/rigor/language_server/hover_provider.rb +8 -11
  228. data/lib/rigor/language_server/hover_renderer.rb +56 -93
  229. data/lib/rigor/language_server/loop.rb +14 -19
  230. data/lib/rigor/language_server/project_context.rb +46 -74
  231. data/lib/rigor/language_server/selection_range_provider.rb +9 -12
  232. data/lib/rigor/language_server/server.rb +55 -83
  233. data/lib/rigor/language_server/signature_help_provider.rb +29 -46
  234. data/lib/rigor/language_server/synchronized_writer.rb +4 -7
  235. data/lib/rigor/language_server/uri.rb +8 -13
  236. data/lib/rigor/language_server.rb +4 -6
  237. data/lib/rigor/mcp/loop.rb +2 -3
  238. data/lib/rigor/mcp/server.rb +4 -7
  239. data/lib/rigor/mcp.rb +3 -6
  240. data/lib/rigor/plugin/access_denied_error.rb +5 -8
  241. data/lib/rigor/plugin/additional_initializer.rb +21 -31
  242. data/lib/rigor/plugin/base.rb +335 -518
  243. data/lib/rigor/plugin/blueprint.rb +14 -23
  244. data/lib/rigor/plugin/box.rb +18 -29
  245. data/lib/rigor/plugin/fact_store.rb +16 -26
  246. data/lib/rigor/plugin/inflector.rb +37 -53
  247. data/lib/rigor/plugin/io_boundary.rb +33 -56
  248. data/lib/rigor/plugin/isolation.rb +42 -55
  249. data/lib/rigor/plugin/load_error.rb +10 -15
  250. data/lib/rigor/plugin/loader.rb +30 -49
  251. data/lib/rigor/plugin/macro/block_as_method.rb +20 -32
  252. data/lib/rigor/plugin/macro/heredoc_template.rb +35 -58
  253. data/lib/rigor/plugin/macro/nested_class_template.rb +22 -36
  254. data/lib/rigor/plugin/macro/trait_registry.rb +34 -51
  255. data/lib/rigor/plugin/macro.rb +10 -15
  256. data/lib/rigor/plugin/manifest.rb +85 -144
  257. data/lib/rigor/plugin/node_context.rb +14 -22
  258. data/lib/rigor/plugin/node_rule_walk.rb +49 -74
  259. data/lib/rigor/plugin/protocol_contract.rb +25 -39
  260. data/lib/rigor/plugin/registry.rb +132 -205
  261. data/lib/rigor/plugin/services.rb +21 -33
  262. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -19
  263. data/lib/rigor/plugin/trust_policy.rb +24 -38
  264. data/lib/rigor/plugin/type_node_resolver.rb +15 -24
  265. data/lib/rigor/protection/diagnostic_oracle.rb +11 -13
  266. data/lib/rigor/protection/mutation_scanner.rb +27 -35
  267. data/lib/rigor/protection/mutator.rb +50 -70
  268. data/lib/rigor/protection/test_suite_oracle.rb +20 -27
  269. data/lib/rigor/rbs_extended/conformance_checker.rb +48 -68
  270. data/lib/rigor/rbs_extended/hkt_directives.rb +28 -54
  271. data/lib/rigor/rbs_extended/reporter.rb +24 -40
  272. data/lib/rigor/rbs_extended.rb +107 -197
  273. data/lib/rigor/reflection.rb +68 -86
  274. data/lib/rigor/scope/discovery_index.rb +14 -19
  275. data/lib/rigor/scope.rb +255 -310
  276. data/lib/rigor/sig_gen/classification.rb +6 -10
  277. data/lib/rigor/sig_gen/generator.rb +197 -323
  278. data/lib/rigor/sig_gen/layout_index.rb +12 -20
  279. data/lib/rigor/sig_gen/method_candidate.rb +12 -17
  280. data/lib/rigor/sig_gen/observation_collector.rb +38 -70
  281. data/lib/rigor/sig_gen/observed_call.rb +13 -23
  282. data/lib/rigor/sig_gen/path_mapper.rb +17 -29
  283. data/lib/rigor/sig_gen/renderer.rb +7 -13
  284. data/lib/rigor/sig_gen/type_elaborator.rb +15 -28
  285. data/lib/rigor/sig_gen/write_result.rb +8 -16
  286. data/lib/rigor/sig_gen/writer.rb +95 -174
  287. data/lib/rigor/sig_gen.rb +3 -6
  288. data/lib/rigor/signature_path_audit.rb +24 -30
  289. data/lib/rigor/source/constant_path.rb +10 -14
  290. data/lib/rigor/source/literals.rb +31 -45
  291. data/lib/rigor/source/node_locator.rb +9 -11
  292. data/lib/rigor/source/node_walker.rb +9 -13
  293. data/lib/rigor/source.rb +3 -4
  294. data/lib/rigor/testing.rb +16 -20
  295. data/lib/rigor/triage/catalogue.rb +38 -62
  296. data/lib/rigor/triage.rb +31 -52
  297. data/lib/rigor/trinary.rb +9 -13
  298. data/lib/rigor/type/acceptance_router.rb +4 -6
  299. data/lib/rigor/type/accepts_result.rb +10 -14
  300. data/lib/rigor/type/app.rb +19 -27
  301. data/lib/rigor/type/bot.rb +4 -6
  302. data/lib/rigor/type/bound_method.rb +10 -15
  303. data/lib/rigor/type/combinator.rb +165 -257
  304. data/lib/rigor/type/constant.rb +23 -34
  305. data/lib/rigor/type/data_class.rb +10 -15
  306. data/lib/rigor/type/data_instance.rb +14 -20
  307. data/lib/rigor/type/difference.rb +21 -32
  308. data/lib/rigor/type/dynamic.rb +3 -5
  309. data/lib/rigor/type/hash_shape.rb +32 -18
  310. data/lib/rigor/type/integer_range.rb +11 -16
  311. data/lib/rigor/type/intersection.rb +27 -42
  312. data/lib/rigor/type/nominal.rb +10 -15
  313. data/lib/rigor/type/plain_lattice.rb +9 -13
  314. data/lib/rigor/type/refined.rb +67 -114
  315. data/lib/rigor/type/singleton.rb +4 -6
  316. data/lib/rigor/type/struct_class.rb +11 -16
  317. data/lib/rigor/type/struct_instance.rb +15 -21
  318. data/lib/rigor/type/tuple.rb +14 -19
  319. data/lib/rigor/type/union.rb +30 -42
  320. data/lib/rigor/type_node/generic.rb +14 -26
  321. data/lib/rigor/type_node/identifier.rb +12 -19
  322. data/lib/rigor/type_node.rb +3 -12
  323. data/lib/rigor/value_semantics.rb +16 -21
  324. data/lib/rigor/version.rb +1 -1
  325. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +18 -30
  326. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +16 -29
  327. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +12 -23
  328. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +22 -39
  329. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +26 -46
  330. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +51 -87
  331. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +10 -17
  332. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +26 -39
  333. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +141 -184
  334. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +30 -51
  335. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +57 -92
  336. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +92 -108
  337. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +13 -22
  338. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +15 -25
  339. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +8 -13
  340. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +16 -24
  341. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +40 -48
  342. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +140 -129
  343. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +56 -59
  344. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +27 -41
  345. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +16 -24
  346. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  347. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +168 -241
  348. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +3 -5
  349. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +8 -14
  350. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +9 -15
  351. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +4 -7
  352. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +23 -41
  353. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +3 -5
  354. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +6 -10
  355. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +8 -11
  356. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  357. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +30 -45
  358. data/plugins/rigor-devise/lib/rigor-devise.rb +3 -5
  359. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +43 -69
  360. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +26 -35
  361. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +3 -5
  362. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +30 -47
  363. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +3 -5
  364. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +67 -113
  365. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +29 -46
  366. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +3 -5
  367. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -20
  368. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +14 -23
  369. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +2 -3
  370. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +22 -37
  371. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +28 -46
  372. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +13 -17
  373. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -47
  374. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +52 -80
  375. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +21 -31
  376. data/plugins/rigor-graphql/lib/rigor-graphql.rb +3 -5
  377. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +11 -17
  378. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +20 -34
  379. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +45 -75
  380. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +22 -36
  381. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +22 -41
  382. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +17 -32
  383. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +6 -12
  384. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +4 -8
  385. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +17 -32
  386. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -18
  387. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +58 -92
  388. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +20 -34
  389. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +20 -31
  390. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +49 -82
  391. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +55 -92
  392. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +51 -90
  393. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +17 -30
  394. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  395. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +38 -70
  396. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +61 -75
  397. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +319 -505
  398. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +91 -96
  399. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +32 -54
  400. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +8 -13
  401. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +9 -16
  402. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +24 -39
  403. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +13 -22
  404. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +34 -56
  405. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +22 -42
  406. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +43 -75
  407. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +27 -39
  408. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +14 -19
  409. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +20 -34
  410. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +28 -47
  411. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +21 -37
  412. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +13 -22
  413. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +18 -31
  414. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +6 -10
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +14 -29
  416. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +23 -41
  417. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +3 -5
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +23 -38
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +57 -96
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +22 -35
  421. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +41 -69
  422. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +6 -10
  423. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +24 -41
  424. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +29 -50
  425. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +55 -88
  426. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +159 -262
  427. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +21 -29
  428. data/plugins/rigor-statesman/lib/rigor-statesman.rb +3 -5
  429. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +23 -37
  430. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +4 -6
  431. data/sig/rigor/environment.rbs +1 -0
  432. data/sig/rigor/scope.rbs +6 -0
  433. metadata +9 -1
@@ -9,6 +9,7 @@ require_relative "../source/node_walker"
9
9
  require_relative "../source/constant_path"
10
10
  require_relative "block_parameter_binder"
11
11
  require_relative "body_fixpoint"
12
+ require_relative "dynamic_origin"
12
13
  require_relative "struct_fold_safety"
13
14
  require_relative "closure_escape_analyzer"
14
15
  require_relative "indexed_narrowing"
@@ -20,14 +21,11 @@ require_relative "narrowing"
20
21
 
21
22
  module Rigor
22
23
  module Inference
23
- # Statement-level evaluator that complements `Rigor::Inference::ExpressionTyper`
24
- # by threading an immutable {Rigor::Scope} through control-flow constructs.
25
- # The output is the pair `[Rigor::Type, Rigor::Scope]`: the type that the
26
- # evaluated node produces, and the scope that callers should observe
27
- # AFTER the node has run.
24
+ # Statement-level evaluator that complements `Rigor::Inference::ExpressionTyper` by threading an immutable
25
+ # {Rigor::Scope} through control-flow constructs. The output is the pair `[Rigor::Type, Rigor::Scope]`: the type
26
+ # that the evaluated node produces, and the scope that callers should observe AFTER the node has run.
28
27
  #
29
- # Slice 3 phase 2 ships the evaluator surface and the scope-threading
30
- # rules for the canonical statement-y nodes:
28
+ # Slice 3 phase 2 ships the evaluator surface and the scope-threading rules for the canonical statement-y nodes:
31
29
  #
32
30
  # - sequential evaluation across `Prism::StatementsNode`/`ProgramNode`,
33
31
  # - local-variable assignment (`Prism::LocalVariableWriteNode`) binding
@@ -40,24 +38,20 @@ module Rigor
40
38
  # - pass-through helpers for `ParenthesesNode`, `ElseNode`,
41
39
  # `WhenNode`/`InNode`, and `RescueNode`.
42
40
  #
43
- # Anything outside the catalogue defers to `Rigor::Scope#type_of` and
44
- # returns the receiver scope unchanged. This matches the Slice 1
45
- # fail-soft policy: an unrecognised statement-level node MUST NOT
46
- # raise and MUST keep the scope intact.
41
+ # Anything outside the catalogue defers to `Rigor::Scope#type_of` and returns the receiver scope unchanged. This
42
+ # matches the Slice 1 fail-soft policy: an unrecognised statement-level node MUST NOT raise and MUST keep the scope
43
+ # intact.
47
44
  #
48
- # The class is stateful (`@scope`, `@tracer`) but every public call
49
- # returns fresh values; the receiver scope MUST never be mutated.
50
- # Recursive evaluation always allocates a new instance with the
51
- # forked scope so different branches stay isolated.
45
+ # The class is stateful (`@scope`, `@tracer`) but every public call returns fresh values; the receiver scope MUST
46
+ # never be mutated. Recursive evaluation always allocates a new instance with the forked scope so different branches
47
+ # stay isolated.
52
48
  #
53
- # See docs/internal-spec/inference-engine.md for the public contract
54
- # and docs/adr/4-type-inference-engine.md for the slice rationale.
49
+ # See docs/internal-spec/inference-engine.md for the public contract and docs/adr/4-type-inference-engine.md for the
50
+ # slice rationale.
55
51
  # rubocop:disable Metrics/ClassLength
56
52
  class StatementEvaluator
57
- # Hash-based dispatch keeps `evaluate` linear and lets future slices
58
- # add control-flow node kinds without growing a single case
59
- # statement past RuboCop's cyclomatic budget. Anonymous Prism
60
- # subclasses are not expected.
53
+ # Hash-based dispatch keeps `evaluate` linear and lets future slices add control-flow node kinds without growing a
54
+ # single case statement past RuboCop's cyclomatic budget. Anonymous Prism subclasses are not expected.
61
55
  HANDLERS = {
62
56
  Prism::StatementsNode => :eval_statements,
63
57
  Prism::ProgramNode => :eval_program,
@@ -107,34 +101,27 @@ module Rigor
107
101
  }.freeze
108
102
  private_constant :HANDLERS
109
103
 
110
- # Thread-local sink (an Array) collecting the value types of explicit
111
- # `return value` nodes reached while evaluating a method body, so
112
- # `ExpressionTyper#infer_user_method_return` can join them into the
113
- # method's inferred return type. The flow value of a `return` is still
114
- # `Bot` (it transfers control rather than producing a value); the sink
115
- # only records what the method *returns* through that edge. nil means
116
- # "not collecting" — a top-level / DSL-block walk, or inside a nested
117
- # `def` barrier (whose returns belong to the inner method).
104
+ # Thread-local sink (an Array) collecting the value types of explicit `return value` nodes reached while
105
+ # evaluating a method body, so `ExpressionTyper#infer_user_method_return` can join them into the method's inferred
106
+ # return type. The flow value of a `return` is still `Bot` (it transfers control rather than producing a value);
107
+ # the sink only records what the method *returns* through that edge. nil means "not collecting" a top-level /
108
+ # DSL-block walk, or inside a nested `def` barrier (whose returns belong to the inner method).
118
109
  RETURN_SINK_KEY = :rigor_return_sink
119
110
  private_constant :RETURN_SINK_KEY
120
111
 
121
- # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the
122
- # scope at each `break` reached while evaluating a loop body, so
123
- # `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true;
124
- # break`) into the loop continuation that the fall-through would
125
- # otherwise drop. Stacks like the return sink: a nested loop installs its
126
- # own sink, restored on exit, so an inner loop's break does not leak to
127
- # the outer one. A `break` inside a block / nested loop targets that
128
- # inner construct, not the lexical loop — filtered out by the
129
- # directly-targeting break set, see {#directly_targeting_breaks}.
130
- # See docs/notes/20260615-loop-break-binding-propagation-design.md.
112
+ # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the scope at each `break` reached while
113
+ # evaluating a loop body, so `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true; break`) into
114
+ # the loop continuation that the fall-through would otherwise drop. Stacks like the return sink: a nested loop
115
+ # installs its own sink, restored on exit, so an inner loop's break does not leak to the outer one. A `break`
116
+ # inside a block / nested loop targets that inner construct, not the lexical loop filtered out by the
117
+ # directly-targeting break set, see {#directly_targeting_breaks}. See
118
+ # docs/notes/20260615-loop-break-binding-propagation-design.md.
131
119
  BREAK_SINK_KEY = :rigor_break_sink
132
120
  private_constant :BREAK_SINK_KEY
133
121
 
134
- # Lexical class frame: the `name:` field is the qualified class
135
- # name as it would render in Ruby (e.g., `"Foo::Bar"`); the
136
- # `singleton:` field is `true` for `class << self` frames so
137
- # nested defs resolve to singleton-method RBS lookups.
122
+ # Lexical class frame: the `name:` field is the qualified class name as it would render in Ruby (e.g.,
123
+ # `"Foo::Bar"`); the `singleton:` field is `true` for `class << self` frames so nested defs resolve to
124
+ # singleton-method RBS lookups.
138
125
  ClassFrame = Data.define(:name, :singleton)
139
126
 
140
127
  # @param scope [Rigor::Scope]
@@ -167,13 +154,11 @@ module Rigor
167
154
  @converged_loop_recording = converged_loop_recording
168
155
  end
169
156
 
170
- # Runs `block` with a fresh return sink installed, then yields the
171
- # collected explicit-`return` value types to the caller. The sink is
172
- # an array of `Rigor::Type`. Nested invocations stack: the previous
173
- # sink is restored on exit so a `def` evaluated inside another method's
174
- # body (which itself installed a sink) does not corrupt the outer one.
175
- # Used by `ExpressionTyper#infer_user_method_return` to join the
176
- # explicit returns into the inferred method-return type.
157
+ # Runs `block` with a fresh return sink installed, then yields the collected explicit-`return` value types to the
158
+ # caller. The sink is an array of `Rigor::Type`. Nested invocations stack: the previous sink is restored on exit
159
+ # so a `def` evaluated inside another method's body (which itself installed a sink) does not corrupt the outer
160
+ # one. Used by `ExpressionTyper#infer_user_method_return` to join the explicit returns into the inferred
161
+ # method-return type.
177
162
  def self.with_return_sink
178
163
  previous = Thread.current[RETURN_SINK_KEY]
179
164
  sink = []
@@ -186,10 +171,8 @@ module Rigor
186
171
  [result, sink]
187
172
  end
188
173
 
189
- # Evaluate `node` under the receiver scope. Returns `[type, scope']`
190
- # where `type` is the value the node produces and `scope'` is the
191
- # scope observable after the node has run. The receiver scope is
192
- # never mutated.
174
+ # Evaluate `node` under the receiver scope. Returns `[type, scope']` where `type` is the value the node produces
175
+ # and `scope'` is the scope observable after the node has run. The receiver scope is never mutated.
193
176
  #
194
177
  # @param node [Prism::Node]
195
178
  # @return [Array(Rigor::Type, Rigor::Scope)]
@@ -199,9 +182,8 @@ module Rigor
199
182
  handler = HANDLERS[node.class]
200
183
  return send(handler, node) if handler
201
184
 
202
- # Default: the node is treated as a pure expression. Type it
203
- # through the existing expression typer (which observes the
204
- # current scope's locals) and leave the scope unchanged.
185
+ # Default: the node is treated as a pure expression. Type it through the existing expression typer (which
186
+ # observes the current scope's locals) and leave the scope unchanged.
205
187
  [@scope.type_of(node, tracer: @tracer), @scope]
206
188
  end
207
189
 
@@ -209,10 +191,9 @@ module Rigor
209
191
 
210
192
  attr_reader :scope, :tracer
211
193
 
212
- # Thread the scope through every child statement in declaration
213
- # order. The body's value is the type of the last statement (or
214
- # `Constant[nil]` for an empty body); intermediate statements'
215
- # types are discarded, but their scope effects are preserved.
194
+ # Thread the scope through every child statement in declaration order. The body's value is the type of the last
195
+ # statement (or `Constant[nil]` for an empty body); intermediate statements' types are discarded, but their scope
196
+ # effects are preserved.
216
197
  def eval_statements(node)
217
198
  result_type = Type::Combinator.constant_of(nil)
218
199
  current = scope
@@ -228,48 +209,53 @@ module Rigor
228
209
  sub_eval(node.statements, scope)
229
210
  end
230
211
 
231
- # `name = rvalue` evaluates the rvalue under the entry scope (so
232
- # earlier assignments in a chained `a = b = expr` propagate
233
- # left-to-right) and binds `name` to the result type. Compound
234
- # assignment forms (`+=` etc.) are deferred to a follow-up; for
235
- # now they degrade to "type the rhs, do not rebind" via the
236
- # default branch in {#evaluate}.
212
+ # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
213
+ # propagate left-to-right) and binds `name` to the result type. Compound assignment forms (`+=` etc.) are deferred
214
+ # to a follow-up; for now they degrade to "type the rhs, do not rebind" via the default branch in {#evaluate}.
237
215
  def eval_local_write(node)
238
216
  rhs_type, post_rhs = sub_eval(node.value, scope)
239
- # ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely
240
- # declaration-sourced makes `r` declaration-sourced too (the survey's
241
- # exact rotation/traversal shape `r = @right; r.key`). The mark is
242
- # computed on the RHS *value*'s provenance a pure ivar read of a
243
- # currently declaration-sourced ivar so it survives the local copy.
244
- # Any other RHS (a call result, a method-local-nil-bearing value)
245
- # leaves the local flow-live and the diagnostic fires as before.
217
+ # ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely declaration-sourced makes `r`
218
+ # declaration-sourced too (the survey's exact rotation/traversal shape `r = @right; r.key`). The mark is
219
+ # computed on the RHS *value*'s provenance a pure ivar read of a currently declaration-sourced ivar — so it
220
+ # survives the local copy. Any other RHS (a call result, a method-local-nil-bearing value) leaves the local
221
+ # flow-live and the diagnostic fires as before.
246
222
  if declaration_sourced_ivar_read?(node.value, post_rhs)
247
223
  return [rhs_type, post_rhs.with_declaration_sourced_local(node.name, rhs_type)]
248
224
  end
249
225
 
250
- [rhs_type, post_rhs.with_local(node.name, rhs_type)]
226
+ bound = post_rhs.with_local(node.name, rhs_type)
227
+ bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
228
+ [rhs_type, bound]
251
229
  end
252
230
 
253
- # True when `value_node` is a bare instance-variable read whose binding
254
- # in `scope_at_read` is currently marked declaration-sourced.
231
+ # ADR-82 WD1 the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
232
+ # Returns the cause recorded on the assignment's rhs node when the value is `Dynamic` (so a later
233
+ # `x` / `@x` receiver-read resolves to why it is dynamic), else `nil` — `with_local_origin` /
234
+ # `with_ivar_origin` treat `nil` as a no-op, so this is safe to call unconditionally.
235
+ def rhs_origin(value_node, scope_after_rhs, rhs_type)
236
+ return nil unless rhs_type.is_a?(Type::Dynamic)
237
+
238
+ scope_after_rhs.dynamic_origins[value_node]
239
+ end
240
+
241
+ # True when `value_node` is a bare instance-variable read whose binding in `scope_at_read` is currently marked
242
+ # declaration-sourced.
255
243
  def declaration_sourced_ivar_read?(value_node, scope_at_read)
256
244
  return false unless value_node.is_a?(Prism::InstanceVariableReadNode)
257
245
 
258
246
  scope_at_read.declaration_sourced?(:ivar, value_node.name)
259
247
  end
260
248
 
261
- # Slice 7 phase 1 — instance/class/global variable
262
- # writes. Each handler evaluates the rvalue under the
263
- # entry scope and binds the named variable into the
264
- # post-scope's per-kind binding map. The expression value
265
- # is the rvalue type, matching Ruby's semantics. Bindings
266
- # are method-local: a fresh scope is built at every `def`
267
- # entry through `build_method_entry_scope`, so writes do
268
- # not leak across method boundaries until cross-method
269
- # ivar/cvar tracking lands.
249
+ # Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
250
+ # and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
251
+ # type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
252
+ # `build_method_entry_scope`, so writes do not leak across method boundaries until cross-method ivar/cvar tracking
253
+ # lands.
270
254
  def eval_ivar_write(node)
271
255
  rhs_type, post_rhs = sub_eval(node.value, scope)
272
- [rhs_type, post_rhs.with_ivar(node.name, rhs_type)]
256
+ bound = post_rhs.with_ivar(node.name, rhs_type)
257
+ bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
258
+ [rhs_type, bound]
273
259
  end
274
260
 
275
261
  def eval_cvar_write(node)
@@ -282,8 +268,7 @@ module Rigor
282
268
  [rhs_type, post_rhs.with_global(node.name, rhs_type)]
283
269
  end
284
270
 
285
- # Slice 7 phase 3 — compound writes (||=, &&=, +=/-=/...)
286
- # for every variable kind. Each handler:
271
+ # Slice 7 phase 3 — compound writes (||=, &&=, +=/-=/...) for every variable kind. Each handler:
287
272
  # 1. Reads the current type from the appropriate scope
288
273
  # binding map (or `Dynamic[Top]` when unbound).
289
274
  # 2. Evaluates the rvalue under the entry scope and
@@ -380,18 +365,14 @@ module Rigor
380
365
  end
381
366
  end
382
367
 
383
- # `receiver[key] ||= default` — the Redmine `Query#as_params`
384
- # idiom. After the `||=`, the next read at `receiver[key]` is known
385
- # non-nil; the next `<<` / `[]=` / other mutator runs against
386
- # a Tuple / Hash carrier instead of the `Constant[nil]` an
387
- # empty `HashShape{}` lookup would otherwise fold to.
368
+ # `receiver[key] ||= default` — the Redmine `Query#as_params` idiom. After the `||=`, the next read at
369
+ # `receiver[key]` is known non-nil; the next `<<` / `[]=` / other mutator runs against a Tuple / Hash carrier
370
+ # instead of the `Constant[nil]` an empty `HashShape{}` lookup would otherwise fold to.
388
371
  #
389
- # The handler:
390
- # 1. Types the equivalent `receiver[key]` read under the
372
+ # The handler: 1. Types the equivalent `receiver[key]` read under the
391
373
  # entry scope (so any previously-recorded narrowing for
392
374
  # the same address is already applied).
393
- # 2. Types the rvalue under the entry scope.
394
- # 3. Computes `union(narrow_truthy(current), rhs)` — the
375
+ # 2. Types the rvalue under the entry scope. 3. Computes `union(narrow_truthy(current), rhs)` — the
395
376
  # standard `||=` result shape used by locals / ivars /
396
377
  # cvars / globals.
397
378
  # 4. Records the result type in the post-scope as a
@@ -401,8 +382,7 @@ module Rigor
401
382
  # fall through to "no scope effect", matching the old
402
383
  # `Prism::IndexOrWriteNode` default-branch behaviour.
403
384
  #
404
- # The expression value is the result type, matching Ruby's
405
- # semantics: `(x = params[:f] ||= []); x` observes the
385
+ # The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
406
386
  # post-`||=` value, not the rvalue alone.
407
387
  def eval_index_or_write(node)
408
388
  rhs_type, post_rhs = sub_eval(node.value, scope)
@@ -435,15 +415,11 @@ module Rigor
435
415
  result || Type::Combinator.untyped
436
416
  end
437
417
 
438
- # `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring.
439
- # Evaluates the right-hand side under the entry scope, then
440
- # decomposes its type against the multi-write target tree
441
- # (Prism::MultiWriteNode#lefts/rest/rights, including nested
442
- # Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped
443
- # right-hand sides produce per-slot types element-wise; other
444
- # carriers fall back to `Dynamic[Top]` per slot. The expression
445
- # value is the right-hand side type (matching Ruby's semantics:
446
- # `(a, b = [1, 2])` evaluates to `[1, 2]`).
418
+ # `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring. Evaluates the right-hand side under the entry scope,
419
+ # then decomposes its type against the multi-write target tree (Prism::MultiWriteNode#lefts/rest/rights, including
420
+ # nested Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped right-hand sides produce per-slot types
421
+ # element-wise; other carriers fall back to `Dynamic[Top]` per slot. The expression value is the right-hand side
422
+ # type (matching Ruby's semantics: `(a, b = [1, 2])` evaluates to `[1, 2]`).
447
423
  def eval_multi_write(node)
448
424
  rhs_type, post_rhs = sub_eval(node.value, scope)
449
425
  bindings = MultiTargetBinder.bind(node, rhs_type)
@@ -451,34 +427,24 @@ module Rigor
451
427
  [rhs_type, post]
452
428
  end
453
429
 
454
- # `if pred; t; (elsif/else)?` runs the predicate first (its
455
- # post-scope is shared by both branches), then asks
456
- # `Rigor::Inference::Narrowing` for the truthy and falsey edge
457
- # scopes derived from the predicate. Slice 6 phase 1 narrows
458
- # local-variable bindings on truthiness, `nil?`, `!`, and `&&`/
459
- # `||` predicate composition; predicates the analyser does not
460
- # specialise return the post-predicate scope unchanged on both
461
- # edges, preserving the Slice 3 phase 2 behaviour. The branches'
462
- # result types are unioned; their post-scopes are joined with
463
- # nil-injection on half-bound names so a name set in one branch
464
- # but not the other is observable as `T | nil` after the if.
430
+ # `if pred; t; (elsif/else)?` runs the predicate first (its post-scope is shared by both branches), then asks
431
+ # `Rigor::Inference::Narrowing` for the truthy and falsey edge scopes derived from the predicate. Slice 6 phase 1
432
+ # narrows local-variable bindings on truthiness, `nil?`, `!`, and `&&`/ `||` predicate composition; predicates the
433
+ # analyser does not specialise return the post-predicate scope unchanged on both edges, preserving the Slice 3
434
+ # phase 2 behaviour. The branches' result types are unioned; their post-scopes are joined with nil-injection on
435
+ # half-bound names so a name set in one branch but not the other is observable as `T | nil` after the if.
465
436
  def eval_if(node)
466
437
  pred_type, post_pred = sub_eval(node.predicate, scope)
467
438
 
468
- # When the predicate is a known-truthy / known-falsey type
469
- # (notably `Constant[true]` / `Constant[false]` after the
470
- # constant-fold tier), only the live branch contributes a
471
- # type and a post-scope. The dead branch is skipped so the
472
- # result type is precise (`Constant[:even]` instead of the
473
- # joined `Constant[:even] | Constant[:odd]`).
439
+ # When the predicate is a known-truthy / known-falsey type (notably `Constant[true]` / `Constant[false]` after
440
+ # the constant-fold tier), only the live branch contributes a type and a post-scope. The dead branch is skipped
441
+ # so the result type is precise (`Constant[:even]` instead of the joined `Constant[:even] | Constant[:odd]`).
474
442
  live = live_branch_for_if(node, pred_type, post_pred)
475
443
  if live
476
444
  live_type, _live_scope = live
477
- # When the provably-live then-branch terminates and there is no
478
- # else, apply the same falsey-scope narrowing as the standard
479
- # early-return path below. Without this, `return if @ivar.nil?`
480
- # with an ivar seeded as Constant[nil] (making nil? = Constant[true]
481
- # and the then-branch "provably live") propagates the un-narrowed
445
+ # When the provably-live then-branch terminates and there is no else, apply the same falsey-scope narrowing as
446
+ # the standard early-return path below. Without this, `return if @ivar.nil?` with an ivar seeded as
447
+ # Constant[nil] (making nil? = Constant[true] and the then-branch "provably live") propagates the un-narrowed
482
448
  # nil scope past the guard instead of Bot.
483
449
  if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
484
450
  _, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
@@ -490,27 +456,19 @@ module Rigor
490
456
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
491
457
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
492
458
  else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
493
- # Slice 7 phase 14 — early-return narrowing. When the
494
- # then-branch unconditionally exits (return / next /
495
- # break / raise) and there is no else, the post-scope
496
- # is the falsey edge of the predicate (subsequent
497
- # statements observe the predicate-was-false world). The
498
- # then-body is the *skipped* path, so the bare narrowing
499
- # (no body assignments) is the correct continuation.
459
+ # Slice 7 phase 14 — early-return narrowing. When the then-branch unconditionally exits (return / next / break /
460
+ # raise) and there is no else, the post-scope is the falsey edge of the predicate (subsequent statements observe
461
+ # the predicate-was-false world). The then-body is the *skipped* path, so the bare narrowing (no body
462
+ # assignments) is the correct continuation.
500
463
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
501
464
  if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
502
- # Symmetric case: the else / elsif-chain (`node.subsequent`)
503
- # unconditionally exits, so the only surviving path is the
504
- # then-branch that RAN. The continuation must therefore carry
505
- # `then_scope` the predicate-truthy narrowing PLUS the
506
- # then-body's assignments not the bare `truthy_scope`.
507
- # Returning `truthy_scope` drops every local the then-body
508
- # bound, leaving it unbound for any enclosing merge to
509
- # spuriously nil-inject: e.g. the inner `elsif … else raise`
510
- # of `if a then x=… elsif b then x=… else raise end` would
511
- # return with `x` unbound, and the outer if's join would then
512
- # read `x` as `… | nil` and fire a false `possible-nil-receiver`
513
- # (liquid v5.x sweep, Event 3).
465
+ # Symmetric case: the else / elsif-chain (`node.subsequent`) unconditionally exits, so the only surviving path
466
+ # is the then-branch that RAN. The continuation must therefore carry `then_scope` — the predicate-truthy
467
+ # narrowing PLUS the then-body's assignments not the bare `truthy_scope`. Returning `truthy_scope` drops every
468
+ # local the then-body bound, leaving it unbound for any enclosing merge to spuriously nil-inject: e.g. the inner
469
+ # `elsif … else raise` of `if a then x=… elsif b then x=… else raise end` would return with `x` unbound, and the
470
+ # outer if's join would then read `x` as `… | nil` and fire a false `possible-nil-receiver` (liquid v5.x sweep,
471
+ # Event 3).
514
472
  return [Type::Combinator.union(then_type, else_type), then_scope] \
515
473
  if branch_terminates?(node.subsequent, else_type) && node.statements
516
474
 
@@ -520,20 +478,18 @@ module Rigor
520
478
  ]
521
479
  end
522
480
 
523
- # `unless pred; t; else; e; end`. Same shape as `if`, but Prism
524
- # exposes the else-branch as `else_clause` (no elsif chain). The
525
- # narrower's truthy/falsey edges are routed in swapped form
526
- # because `unless` runs its body when the predicate is falsey.
481
+ # `unless pred; t; else; e; end`. Same shape as `if`, but Prism exposes the else-branch as `else_clause` (no elsif
482
+ # chain). The narrower's truthy/falsey edges are routed in swapped form because `unless` runs its body when the
483
+ # predicate is falsey.
527
484
  def eval_unless(node)
528
485
  pred_type, post_pred = sub_eval(node.predicate, scope)
529
486
 
530
487
  live = live_branch_for_unless(node, pred_type, post_pred)
531
488
  if live
532
489
  live_type, _live_scope = live
533
- # Mirror of the eval_if fix: when the provably-live unless-body
534
- # terminates and there is no else, apply the truthy-scope narrowing
535
- # so `return unless @ivar` with a nil-seeded ivar doesn't propagate
536
- # the nil scope past the guard.
490
+ # Mirror of the eval_if fix: when the provably-live unless-body terminates and there is no else, apply the
491
+ # truthy-scope narrowing so `return unless @ivar` with a nil-seeded ivar doesn't propagate the nil scope past
492
+ # the guard.
537
493
  if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
538
494
  truthy_scope, = Narrowing.predicate_scopes(node.predicate, post_pred)
539
495
  return [live_type, truthy_scope]
@@ -544,18 +500,13 @@ module Rigor
544
500
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
545
501
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
546
502
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
547
- # Slice 7 phase 14 — same early-return narrowing as
548
- # `if`: when the body unconditionally exits and there
549
- # is no else, the post-scope is the truthy edge (the body
550
- # is the skipped path, so the bare narrowing is correct).
503
+ # Slice 7 phase 14 — same early-return narrowing as `if`: when the body unconditionally exits and there is no
504
+ # else, the post-scope is the truthy edge (the body is the skipped path, so the bare narrowing is correct).
551
505
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
552
506
  if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
553
- # Symmetric to the `if` else-exits fix: when the else-clause
554
- # exits, the surviving path is the unless-body that RAN, so the
555
- # continuation carries `then_scope` (the predicate-falsey
556
- # narrowing PLUS the body's assignments), not the bare
557
- # `falsey_scope` — otherwise body-bound locals are dropped and
558
- # an enclosing merge nil-injects them.
507
+ # Symmetric to the `if` else-exits fix: when the else-clause exits, the surviving path is the unless-body that
508
+ # RAN, so the continuation carries `then_scope` (the predicate-falsey narrowing PLUS the body's assignments),
509
+ # not the bare `falsey_scope` otherwise body-bound locals are dropped and an enclosing merge nil-injects them.
559
510
  return [Type::Combinator.union(then_type, else_type), then_scope] \
560
511
  if branch_terminates?(node.else_clause, else_type) && node.statements
561
512
 
@@ -565,12 +516,10 @@ module Rigor
565
516
  ]
566
517
  end
567
518
 
568
- # Returns the `[type, post_scope]` of the live branch when the
569
- # predicate is provably truthy / falsey, else nil so the
570
- # caller falls through to the standard both-branch evaluation.
571
- # Constant `true`/`false` is the obvious trigger; non-falsey
572
- # carriers like `Nominal[Integer]` (Integer is always truthy
573
- # in Ruby — including 0) also collapse the dead else.
519
+ # Returns the `[type, post_scope]` of the live branch when the predicate is provably truthy / falsey, else nil so
520
+ # the caller falls through to the standard both-branch evaluation. Constant `true`/`false` is the obvious trigger;
521
+ # non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
522
+ # dead else.
574
523
  def live_branch_for_if(node, pred_type, post_pred)
575
524
  case Narrowing.predicate_certainty(pred_type)
576
525
  when :truthy then eval_branch_or_nil(node.statements, post_pred)
@@ -591,11 +540,9 @@ module Rigor
591
540
  sub_eval(node.statements, scope)
592
541
  end
593
542
 
594
- # `case pred; when ...; when ...; else; end` and the pattern-
595
- # matching variant. The predicate's post-scope is shared with
596
- # every branch (including the else); branches are evaluated
597
- # independently and merged with nil-injection so half-bound
598
- # names degrade to `T | nil`.
543
+ # `case pred; when ...; when ...; else; end` and the pattern- matching variant. The predicate's post-scope is
544
+ # shared with every branch (including the else); branches are evaluated independently and merged with
545
+ # nil-injection so half-bound names degrade to `T | nil`.
599
546
  def eval_case(node)
600
547
  post_pred = node.predicate ? sub_eval(node.predicate, scope).last : scope
601
548
  branch_results, falsey_scope = eval_case_when_branches(node.predicate, node.conditions, post_pred)
@@ -609,16 +556,13 @@ module Rigor
609
556
  ]
610
557
  end
611
558
 
612
- # Joins the post-scopes of every `when`/`in`/`else` branch, dropping
613
- # the scope of any branch that terminates (raises / returns / throws /
614
- # types to `Bot`) before the merge control never falls through such
615
- # a branch, so its half-bound locals must not nil-inject the names a
616
- # live sibling branch assigned. Mirrors the `branch_terminates?` rule
617
- # `eval_if`/`eval_unless` already apply to the if/else merge: e.g.
618
- # `case x; when 1 then v="a"; when 2 then v="b"; else raise; end`
619
- # keeps `v: "a" | "b"` instead of `... | nil`. When every branch
620
- # terminates the merge is itself unreachable; fall back to the full
621
- # join so the continuation scope stays well-formed.
559
+ # Joins the post-scopes of every `when`/`in`/`else` branch, dropping the scope of any branch that terminates
560
+ # (raises / returns / throws / types to `Bot`) before the merge — control never falls through such a branch, so
561
+ # its half-bound locals must not nil-inject the names a live sibling branch assigned. Mirrors the
562
+ # `branch_terminates?` rule `eval_if`/`eval_unless` already apply to the if/else merge: e.g. `case x; when 1 then
563
+ # v="a"; when 2 then v="b"; else raise; end` keeps `v: "a" | "b"` instead of `... | nil`. When every branch
564
+ # terminates the merge is itself unreachable; fall back to the full join so the continuation scope stays
565
+ # well-formed.
622
566
  def join_case_branch_scopes(results, nodes)
623
567
  live = []
624
568
  results.each_with_index do |(type, branch_scope), i|
@@ -633,14 +577,11 @@ module Rigor
633
577
  results = []
634
578
  falsey_scope = entry_scope
635
579
  conditions.each do |branch|
636
- # ADR-47 WD2 — record the scope ENTERING this clause (the
637
- # subject narrowed by every earlier clause's negation) on the
638
- # clause's first condition node, so `flow.unreachable-clause`
639
- # can tell a prior-exhausted subject (entry already `bot`)
640
- # from a per-clause-disjoint one (entry concrete, this clause
641
- # disjoint). `on_enter`-only (no recursion) so no condition
642
- # sub-expression is newly typed; `propagate` preserves the
643
- # entry because it already keys the node.
580
+ # ADR-47 WD2 — record the scope ENTERING this clause (the subject narrowed by every earlier clause's negation)
581
+ # on the clause's first condition node, so `flow.unreachable-clause` can tell a prior-exhausted subject (entry
582
+ # already `bot`) from a per-clause-disjoint one (entry concrete, this clause disjoint). `on_enter`-only (no
583
+ # recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
584
+ # keys the node.
644
585
  record_clause_entry_scope(branch, falsey_scope)
645
586
  body_scope, falsey_scope = branch_body_and_falsey_scopes(subject, branch, falsey_scope)
646
587
  results << sub_eval(branch, body_scope)
@@ -648,10 +589,9 @@ module Rigor
648
589
  [results, falsey_scope]
649
590
  end
650
591
 
651
- # ADR-47 WD2/WD3 — record the scope ENTERING a `when`/`in` clause on
652
- # the node `flow.unreachable-clause` reads to classify a dead clause
653
- # (`when`: first condition; `in`: the pattern). `on_enter`-only so no
654
- # sub-expression is newly typed; `propagate` preserves it.
592
+ # ADR-47 WD2/WD3 — record the scope ENTERING a `when`/`in` clause on the node `flow.unreachable-clause` reads to
593
+ # classify a dead clause (`when`: first condition; `in`: the pattern). `on_enter`-only so no sub-expression is
594
+ # newly typed; `propagate` preserves it.
655
595
  def record_clause_entry_scope(branch, entry_scope)
656
596
  node =
657
597
  case branch
@@ -661,11 +601,9 @@ module Rigor
661
601
  @on_enter&.call(node, entry_scope) if node
662
602
  end
663
603
 
664
- # Returns `[body_scope, updated_falsey_scope]` for a single branch.
665
- # `WhenNode` branches narrow through `Narrowing.case_when_scopes`.
666
- # `InNode` branches narrow soundly only for a bare class pattern
667
- # (`in C` / `in C => x`, pure `is_a?`); every other pattern keeps
668
- # the conservative "body = entry + bindings, falsey unchanged" shape.
604
+ # Returns `[body_scope, updated_falsey_scope]` for a single branch. `WhenNode` branches narrow through
605
+ # `Narrowing.case_when_scopes`. `InNode` branches narrow soundly only for a bare class pattern (`in C` / `in C =>
606
+ # x`, pure `is_a?`); every other pattern keeps the conservative "body = entry + bindings, falsey unchanged" shape.
669
607
  def branch_body_and_falsey_scopes(subject, branch, falsey_scope)
670
608
  if branch.is_a?(Prism::InNode)
671
609
  in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
@@ -675,13 +613,11 @@ module Rigor
675
613
  end
676
614
  end
677
615
 
678
- # ADR-47 WD3a — a bare class pattern matches on `C === subject`, i.e.
679
- # exactly `subject.is_a?(C)` with no deconstruction, so it narrows
680
- # like `when C`: the body sees the subject narrowed to `C` and the
681
- # next clause's falsey scope has `C` removed. Other patterns can fail
682
- # to match even when a class test would pass (deconstruction arity,
683
- # hash keys, ...), so removing anything from the falsey scope would
684
- # be unsound — they keep the conservative shape.
616
+ # ADR-47 WD3a — a bare class pattern matches on `C === subject`, i.e. exactly `subject.is_a?(C)` with no
617
+ # deconstruction, so it narrows like `when C`: the body sees the subject narrowed to `C` and the next clause's
618
+ # falsey scope has `C` removed. Other patterns can fail to match even when a class test would pass (deconstruction
619
+ # arity, hash keys, ...), so removing anything from the falsey scope would be unsound they keep the conservative
620
+ # shape.
685
621
  def in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
686
622
  class_node = bare_class_pattern_node(branch.pattern)
687
623
  return [apply_in_pattern_bindings(subject, branch.pattern, falsey_scope), falsey_scope] unless class_node
@@ -690,9 +626,8 @@ module Rigor
690
626
  [apply_in_pattern_bindings(subject, branch.pattern, truthy_scope), narrowed_falsey]
691
627
  end
692
628
 
693
- # The class-constant node of a `in C` / `in C => x` pattern (the only
694
- # `in` shapes whose match is pure `is_a?`), or nil for any pattern
695
- # that deconstructs, binds, or matches a value.
629
+ # The class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is pure `is_a?`), or
630
+ # nil for any pattern that deconstructs, binds, or matches a value.
696
631
  def bare_class_pattern_node(pattern)
697
632
  case pattern
698
633
  when Prism::ConstantReadNode, Prism::ConstantPathNode
@@ -715,49 +650,35 @@ module Rigor
715
650
  sub_eval(node.statements, scope)
716
651
  end
717
652
 
718
- # `begin; body; rescue ...; else; ensure; end`. The body and the
719
- # rescue chain are alternative exit paths whose scopes are joined
720
- # with nil-injection. The else-clause replaces the body's value
721
- # when present (matching Ruby semantics: else runs only if the
722
- # body raises no exception). The ensure-clause runs but does not
723
- # contribute to the value; its scope effects are layered on the
724
- # joined exit scope so locals bound exclusively in `ensure` stay
653
+ # `begin; body; rescue ...; else; ensure; end`. The body and the rescue chain are alternative exit paths whose
654
+ # scopes are joined with nil-injection. The else-clause replaces the body's value when present (matching Ruby
655
+ # semantics: else runs only if the body raises no exception). The ensure-clause runs but does not contribute to
656
+ # the value; its scope effects are layered on the joined exit scope so locals bound exclusively in `ensure` stay
725
657
  # observable.
726
658
  def eval_begin(node)
727
659
  entry = scope
728
660
  primary_type, primary_scope = eval_begin_primary_under(node, entry)
729
661
  rescue_chain = collect_rescue_chain_results(node.rescue_clause, entry)
730
662
 
731
- # B2.1 — retry-edge widening. When any rescue body
732
- # contains `Prism::RetryNode`, control re-enters the
733
- # primary body with the rescue arm's rebinds visible.
734
- # Today's flow loses that effect, so a counter like
735
- # `tries = 0; ...; rescue; tries += 1; retry; end`
736
- # observes `tries: Constant[0]` inside the body and any
737
- # `tries > 100` predicate folds to always-falsey.
738
- # The fix: widen rebound locals / ivars in any
739
- # retry-emitting arm to their Nominal envelope (Constant
740
- # → Nominal[<class>], Tuple → Array, HashShape → Hash),
741
- # then re-evaluate primary body AND rescue chain once
742
- # under the widened entry. Nominal envelope is the
743
- # maximally widened form so the re-evaluation converges
744
- # in one step.
663
+ # B2.1 — retry-edge widening. When any rescue body contains `Prism::RetryNode`, control re-enters the primary
664
+ # body with the rescue arm's rebinds visible. Today's flow loses that effect, so a counter like `tries = 0; ...;
665
+ # rescue; tries += 1; retry; end` observes `tries: Constant[0]` inside the body and any `tries > 100` predicate
666
+ # folds to always-falsey. The fix: widen rebound locals / ivars in any retry-emitting arm to their Nominal
667
+ # envelope (Constant Nominal[<class>], Tuple Array, HashShape Hash), then re-evaluate primary body AND
668
+ # rescue chain once under the widened entry. Nominal envelope is the maximally widened form so the re-evaluation
669
+ # converges in one step.
745
670
  widened_entry = widen_entry_for_retry(entry, rescue_chain)
746
671
  if widened_entry
747
672
  primary_type, primary_scope = eval_begin_primary_under(node, widened_entry)
748
673
  rescue_chain = collect_rescue_chain_results(node.rescue_clause, widened_entry)
749
674
  end
750
675
 
751
- # Rescue arms whose body unconditionally exits (`return`,
752
- # `next`, `break`, `raise`, `throw`, `exit`, `abort`,
753
- # `fail`) contribute neither a type fragment NOR a scope
754
- # to the post-begin flow control left the `begin` via
755
- # that arm. Mirrors the `eval_if` / `eval_unless` /
756
- # `eval_and_or` early-return narrowing. Without this
757
- # filter, a `rescue ... return` on a local bound only in
758
- # the primary body nil-injects that local across the
759
- # join, defeating the rescue arm's whole point of guaranteeing
760
- # the primary local is in scope for downstream statements.
676
+ # Rescue arms whose body unconditionally exits (`return`, `next`, `break`, `raise`, `throw`, `exit`, `abort`,
677
+ # `fail`) contribute neither a type fragment NOR a scope to the post-begin flow — control left the `begin` via
678
+ # that arm. Mirrors the `eval_if` / `eval_unless` / `eval_and_or` early-return narrowing. Without this filter, a
679
+ # `rescue ... return` on a local bound only in the primary body nil-injects that local across the join,
680
+ # defeating the rescue arm's whole point of guaranteeing the primary local is in scope for downstream
681
+ # statements.
761
682
  live_rescues = rescue_chain.reject { |_pair, arm_node| branch_unconditionally_exits?(arm_node.statements) }
762
683
  .map(&:first)
763
684
 
@@ -777,11 +698,9 @@ module Rigor
777
698
  [exit_type, exit_scope]
778
699
  end
779
700
 
780
- # `BeginNode#statements` is the primary body; when an else-clause
781
- # is present, its value replaces the body's per Ruby semantics
782
- # (the else runs only when no exception was raised), but the
783
- # body's scope effects still apply because the body did run
784
- # before the else.
701
+ # `BeginNode#statements` is the primary body; when an else-clause is present, its value replaces the body's per
702
+ # Ruby semantics (the else runs only when no exception was raised), but the body's scope effects still apply
703
+ # because the body did run before the else.
785
704
  def eval_begin_primary_under(node, entry_scope)
786
705
  body_type, body_scope =
787
706
  if node.statements
@@ -798,17 +717,12 @@ module Rigor
798
717
  end
799
718
  end
800
719
 
801
- # B2.1 — return a widened entry scope when at least one
802
- # rescue arm in `rescue_chain` contains a `Prism::RetryNode`
803
- # AND that arm rebinds at least one local or ivar relative
804
- # to the original entry. Returns nil when no widening is
805
- # needed (no retry, or no rebinds reachable across the
806
- # retry edge).
720
+ # B2.1 — return a widened entry scope when at least one rescue arm in `rescue_chain` contains a `Prism::RetryNode`
721
+ # AND that arm rebinds at least one local or ivar relative to the original entry. Returns nil when no widening is
722
+ # needed (no retry, or no rebinds reachable across the retry edge).
807
723
  #
808
- # Always-safe: the widening can only LOSE precision; it
809
- # never invents a fact (Nominal envelope is a superset of
810
- # the Constant / shape carrier it widens from). Convergent
811
- # in one step because Nominal envelope is the maximally
724
+ # Always-safe: the widening can only LOSE precision; it never invents a fact (Nominal envelope is a superset of
725
+ # the Constant / shape carrier it widens from). Convergent in one step because Nominal envelope is the maximally
812
726
  # widened form against the engine's current carrier set.
813
727
  def widen_entry_for_retry(entry_scope, rescue_chain)
814
728
  widened = nil
@@ -827,9 +741,8 @@ module Rigor
827
741
  def arm_contains_retry?(node)
828
742
  return false unless node.is_a?(Prism::Node)
829
743
  return true if node.is_a?(Prism::RetryNode)
830
- # Don't descend into nested blocks / defs / classes /
831
- # modules a `retry` inside a nested method body or
832
- # block targets its own enclosing `begin`, not this one.
744
+ # Don't descend into nested blocks / defs / classes / modules — a `retry` inside a nested method body or block
745
+ # targets its own enclosing `begin`, not this one.
833
746
  return false if node.is_a?(Prism::DefNode) ||
834
747
  node.is_a?(Prism::ClassNode) ||
835
748
  node.is_a?(Prism::ModuleNode) ||
@@ -840,8 +753,7 @@ module Rigor
840
753
 
841
754
  def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
842
755
  scope_acc = accumulator
843
- # Walk every local visible in either side, compare types,
844
- # widen to Nominal envelope on a difference.
756
+ # Walk every local visible in either side, compare types, widen to Nominal envelope on a difference.
845
757
  local_keys = arm_post_scope.locals.keys | entry_scope.locals.keys
846
758
  local_keys.each do |name|
847
759
  pre = entry_scope.local(name)
@@ -864,8 +776,7 @@ module Rigor
864
776
  end
865
777
 
866
778
  def retry_widened_type(pre, post)
867
- # `pre` is nil when the local was introduced inside the
868
- # rescue body. The retry edge brings it back into the
779
+ # `pre` is nil when the local was introduced inside the rescue body. The retry edge brings it back into the
869
780
  # primary body's entry — widen the post type itself.
870
781
  envelope = nominal_envelope_for(post)
871
782
  return envelope if pre.nil?
@@ -873,11 +784,9 @@ module Rigor
873
784
  nominal_envelope_for(Type::Combinator.union(pre, envelope))
874
785
  end
875
786
 
876
- # Nominal envelope of a value type: widens Constant /
877
- # Tuple / HashShape carriers to the underlying class's
878
- # `Nominal`, preserving everything else (`Nominal`,
879
- # `Union` of non-shape members, `Top`, `Dynamic`, `Bot`).
880
- # Union members are walked individually.
787
+ # Nominal envelope of a value type: widens Constant / Tuple / HashShape carriers to the underlying class's
788
+ # `Nominal`, preserving everything else (`Nominal`, `Union` of non-shape members, `Top`, `Dynamic`, `Bot`). Union
789
+ # members are walked individually.
881
790
  def nominal_envelope_for(type)
882
791
  members = type.is_a?(Type::Union) ? type.members : [type]
883
792
  widened = members.map { |m| nominal_envelope_member(m) }
@@ -916,92 +825,75 @@ module Rigor
916
825
  eval_branch_or_nil(node.statements, scope)
917
826
  end
918
827
 
919
- # `while pred; body; end` / `until pred; body; end`. The body
920
- # might run zero or more times, so half-bound names degrade to
921
- # `T | nil` in the post-loop scope. The loop expression itself
922
- # types as `Constant[nil]` (Slice 3 phase 1), reflecting the
923
- # common case where no `break VALUE` is observed.
828
+ # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
829
+ # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
830
+ # 1), reflecting the common case where no `break VALUE` is observed.
924
831
  def eval_loop(node)
925
832
  _pred_type, post_pred = sub_eval(node.predicate, scope)
926
833
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
927
834
 
928
- # The historical single body pass joined with the pre-loop scope.
929
- # This continues to carry everything the fixpoint does NOT track:
930
- # receiver-mutation widening of non-rebound locals (`buf.push(i)`
931
- # widens `buf`'s Tuple), body-introduced locals' nil-injection, and
932
- # the loop value itself. The fixpoint then OVERLAYS only the
835
+ # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
836
+ # fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
837
+ # Tuple), body-introduced locals' nil-injection, and the loop value itself. The fixpoint then OVERLAYS only the
933
838
  # rebound-local bindings it corrects.
934
839
  #
935
- # The pass runs under a break sink so a `break`-path binding
936
- # (`flag = true; break`) the fall-through `body_scope` drops is
937
- # collected for the continuation join below.
840
+ # The pass runs under a break sink so a `break`-path binding (`flag = true; break`) the fall-through
841
+ # `body_scope` drops is collected for the continuation join below.
938
842
  break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
939
843
  base_scope = join_with_nil_injection(post_pred, body_scope)
940
844
 
941
845
  rebound, body_first = loop_body_local_writes(node.statements, post_pred)
942
846
  names = rebound + body_first
943
847
 
944
- # Fast path: a loop whose body rebinds no local skips the rebind
945
- # fixpoint, but still needs the slice-C content writeback (a loop may
946
- # content-mutate a collection without rebinding any local — `acc <<
947
- # x`), so apply it to the single-pass join before returning.
848
+ # Fast path: a loop whose body rebinds no local skips the rebind fixpoint, but still needs the slice-C content
849
+ # writeback (a loop may content-mutate a collection without rebinding any local — `acc << x`), so apply it to
850
+ # the single-pass join before returning.
948
851
  if names.empty?
949
852
  fast = loop_content_writeback(node.statements, base_scope)
950
853
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
951
854
  end
952
855
 
953
856
  post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
954
- # Recover `break`-path bindings the fall-through dropped (`flag = true;
955
- # break` -> `flag` is `false | true`, not the stale `false`).
857
+ # Recover `break`-path bindings the fall-through dropped (`flag = true; break` -> `flag` is `false | true`, not
858
+ # the stale `false`).
956
859
  post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
957
860
  post_loop = narrow_loop_exit_edge(node, post_loop)
958
861
  [Type::Combinator.constant_of(nil), post_loop]
959
862
  end
960
863
 
961
- # The continuation scope for a loop whose body rebinds locals: the
962
- # ADR-56 slice-B rebind fixpoint overlaid on `base_scope`, then the
963
- # slice-C receiver-content writeback.
864
+ # The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
865
+ # `base_scope`, then the slice-C receiver-content writeback.
964
866
  def converged_loop_scope(node, post_pred, base_scope, names, body_first)
965
- # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
966
- # may compound (`d *= 2`), so the historical single body pass joined
967
- # with the pre-loop scope kept stale folded constants
968
- # (`d = 1; while …; d *= 2; end` `1 | 2`, never reaching `4, 8`).
969
- # Fold each body-written local's continuation binding through the same
970
- # capped fixpoint slice A uses for non-escaping block captures. Seed:
971
- # a pre-existing local seeds with its post-predicate binding; a local
972
- # FIRST assigned inside the body seeds with `nil` so the 0-iteration
973
- # path degrades it to `T | nil`, matching the nil-injection treatment.
867
+ # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
868
+ # single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end`
869
+ # `1 | 2`, never reaching `4, 8`). Fold each body-written local's continuation binding through the same capped
870
+ # fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
871
+ # post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
872
+ # degrades it to `T | nil`, matching the nil-injection treatment.
974
873
  result = loop_rebind_fixpoint(node, post_pred, names, body_first)
975
- # Display-path re-record: the fixpoint's body re-evaluations fire
976
- # `on_enter` with the cap-N INTERMEDIATE assumptions, so the
977
- # last-visit-wins scope index would annotate loop-body lines with
978
- # stale pre-convergence constants. One extra pass from the converged
979
- # bindings (result discarded) re-records the body's entry scopes.
874
+ # Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
875
+ # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
876
+ # constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
980
877
  record_converged_loop_body(node, post_pred, result, names, body_first)
981
878
  post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
982
- # ADR-56 slice C — loop-body receiver-content element-type join. A loop
983
- # that content-mutates a collection (`acc << n`) keeps only the seed's
984
- # element types after the single-pass widen; join the appended/stored
985
- # types into the continuation collection (pre-state read from
986
- # `post_loop` so a local both rebound and content-mutated composes).
879
+ # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
880
+ # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
881
+ # the continuation collection (pre-state read from `post_loop` so a local both rebound and content-mutated
882
+ # composes).
987
883
  loop_content_writeback(node.statements, post_loop)
988
884
  end
989
885
 
990
- # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
991
- # loop exits PRECISELY on the predicate's exit edge: `while` exits when
992
- # `pred` is falsey, `until` when `pred` is truthy. So after the loop the
993
- # predicate-assignment target carries the exit polarity `until line =
994
- # io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
995
- # until `gets` returned a truthy (non-nil) line. Apply the exit edge of
886
+ # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
887
+ # exit edge: `while` exits when `pred` is falsey, `until` when `pred` is truthy. So after the loop the
888
+ # predicate-assignment target carries the exit polarity — `until line = io.gets; …; end; line.foo` reads `line`
889
+ # non-nil because the loop ran until `gets` returned a truthy (non-nil) line. Apply the exit edge of
996
890
  # `Narrowing.predicate_scopes` to the continuation scope.
997
891
  #
998
- # Guarded against `break`: a `break` exits the loop WITHOUT the predicate
999
- # ever going false (`while line = gets; break if done; end` can leave
1000
- # `line` truthy on a `while`, or exit before the `until` predicate fires),
1001
- # so the exit-edge proof does not hold and the loop is left un-narrowed.
1002
- # `break` inside a NESTED loop/block does not target this loop, but a
1003
- # nested-loop `break` is rare in predicate-assignment loops and the
1004
- # conservative bail only costs precision, never soundness.
892
+ # Guarded against `break`: a `break` exits the loop WITHOUT the predicate ever going false (`while line = gets;
893
+ # break if done; end` can leave `line` truthy on a `while`, or exit before the `until` predicate fires), so the
894
+ # exit-edge proof does not hold and the loop is left un-narrowed. `break` inside a NESTED loop/block does not
895
+ # target this loop, but a nested-loop `break` is rare in predicate-assignment loops and the conservative bail only
896
+ # costs precision, never soundness.
1005
897
  def narrow_loop_exit_edge(node, post_loop)
1006
898
  return post_loop if loop_body_breaks?(node.statements)
1007
899
 
@@ -1009,10 +901,9 @@ module Rigor
1009
901
  node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
1010
902
  end
1011
903
 
1012
- # True when the loop body can `break` out of THIS loop. Conservatively
1013
- # treats any `BreakNode` under the body as a break for this loop (a
1014
- # break inside a nested loop/block actually targets the inner construct,
1015
- # but bailing is precision-only).
904
+ # True when the loop body can `break` out of THIS loop. Conservatively treats any `BreakNode` under the body as a
905
+ # break for this loop (a break inside a nested loop/block actually targets the inner construct, but bailing is
906
+ # precision-only).
1016
907
  def loop_body_breaks?(statements)
1017
908
  return false if statements.nil?
1018
909
 
@@ -1023,10 +914,8 @@ module Rigor
1023
914
  found
1024
915
  end
1025
916
 
1026
- # A `break` inside one of these nested constructs targets the inner
1027
- # construct (an inner loop, a block's method, a nested def), NOT the
1028
- # lexical loop — so the directly-targeting break scan does not descend
1029
- # into them.
917
+ # A `break` inside one of these nested constructs targets the inner construct (an inner loop, a block's method, a
918
+ # nested def), NOT the lexical loop so the directly-targeting break scan does not descend into them.
1030
919
  BREAK_BOUNDARY_NODES = [
1031
920
  Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
1032
921
  Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
@@ -1034,11 +923,9 @@ module Rigor
1034
923
  ].freeze
1035
924
  private_constant :BREAK_BOUNDARY_NODES
1036
925
 
1037
- # The `BreakNode`s that lexically target THIS loop — reachable from the
1038
- # body without crossing a nested loop / block / def boundary. An
1039
- # identity-keyed Hash used as a membership set to filter the collected
1040
- # break scopes (the thread-local sink also collects breaks from nested
1041
- # blocks that did not install their own sink).
926
+ # The `BreakNode`s that lexically target THIS loop — reachable from the body without crossing a nested loop /
927
+ # block / def boundary. An identity-keyed Hash used as a membership set to filter the collected break scopes (the
928
+ # thread-local sink also collects breaks from nested blocks that did not install their own sink).
1042
929
  def directly_targeting_breaks(statements)
1043
930
  found = {}.compare_by_identity
1044
931
  collect_direct_breaks(statements, found)
@@ -1056,10 +943,9 @@ module Rigor
1056
943
  end
1057
944
  end
1058
945
 
1059
- # Installs a fresh thread-local break sink around `yield` (a loop-body
1060
- # evaluation), returning `[collected, yield_result]`. Stacks: the
1061
- # previous sink is restored on exit so a nested loop's breaks do not
1062
- # leak to the enclosing loop.
946
+ # Installs a fresh thread-local break sink around `yield` (a loop-body evaluation), returning `[collected,
947
+ # yield_result]`. Stacks: the previous sink is restored on exit so a nested loop's breaks do not leak to the
948
+ # enclosing loop.
1063
949
  def collect_break_scopes
1064
950
  previous = Thread.current[BREAK_SINK_KEY]
1065
951
  sink = []
@@ -1072,22 +958,19 @@ module Rigor
1072
958
  [sink, result]
1073
959
  end
1074
960
 
1075
- # Runs a loop body's single pass under a break sink. Returns the
1076
- # directly-targeting break set, the collected break scopes, and the
1077
- # fall-through body scope — the three inputs the continuation's
1078
- # {#join_break_scopes} needs. Shared by `eval_loop` and `eval_for`.
961
+ # Runs a loop body's single pass under a break sink. Returns the directly-targeting break set, the collected break
962
+ # scopes, and the fall-through body scope the three inputs the continuation's {#join_break_scopes} needs. Shared
963
+ # by `eval_loop` and `eval_for`.
1079
964
  def capture_loop_body_breaks(statements, entry)
1080
965
  targets = directly_targeting_breaks(statements)
1081
966
  sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
1082
967
  [targets, sink, body_scope]
1083
968
  end
1084
969
 
1085
- # Joins each directly-targeting break's body-written local bindings into
1086
- # the loop continuation, so a `break`-path binding the fall-through
1087
- # dropped is recovered (`flag = true; break` -> `flag` becomes `false |
1088
- # true`). Only loop-body-written names are joined an unchanged local
1089
- # unions to itself; a break-only-written local is already present via the
1090
- # fixpoint / nil-injection seed, so the union reflects its break value.
970
+ # Joins each directly-targeting break's body-written local bindings into the loop continuation, so a `break`-path
971
+ # binding the fall-through dropped is recovered (`flag = true; break` -> `flag` becomes `false | true`). Only
972
+ # loop-body-written names are joined an unchanged local unions to itself; a break-only-written local is already
973
+ # present via the fixpoint / nil-injection seed, so the union reflects its break value.
1091
974
  def join_break_scopes(continuation, sink, targeting, names)
1092
975
  return continuation if sink.empty? || names.empty?
1093
976
 
@@ -1104,14 +987,11 @@ module Rigor
1104
987
  end
1105
988
  end
1106
989
 
1107
- # Joins loop-body content mutations into the continuation collection
1108
- # bindings. The mutator arguments are typed against `post_loop`, whose
1109
- # locals already carry the loop-body fixpoint widening (so an
1110
- # appended `n` that the loop decrements types `Integer`, not its
1111
- # entry `Constant[3]` otherwise only the first iteration's value
1112
- # would be captured, an unsound under-approximation). Pre-state is
1113
- # read from `post_loop` too. A loop body shares the surrounding scope,
1114
- # so the receiver is any `LocalVariableReadNode` (no depth filter).
990
+ # Joins loop-body content mutations into the continuation collection bindings. The mutator arguments are typed
991
+ # against `post_loop`, whose locals already carry the loop-body fixpoint widening (so an appended `n` that the
992
+ # loop decrements types `Integer`, not its entry `Constant[3]` — otherwise only the first iteration's value would
993
+ # be captured, an unsound under-approximation). Pre-state is read from `post_loop` too. A loop body shares the
994
+ # surrounding scope, so the receiver is any `LocalVariableReadNode` (no depth filter).
1115
995
  def loop_content_writeback(statements, post_loop)
1116
996
  return post_loop if statements.nil?
1117
997
 
@@ -1128,12 +1008,9 @@ module Rigor
1128
1008
  end
1129
1009
  end
1130
1010
 
1131
- # Re-evaluates the loop body once from the converged fixpoint
1132
- # bindings, solely for the `on_enter` side effect of re-recording
1133
- # the body's per-node entry scopes. Gated behind the
1134
- # display-path-only `converged_loop_recording` flag so the check
1135
- # path neither pays the extra body evaluation nor risks any
1136
- # diagnostic drift.
1011
+ # Re-evaluates the loop body once from the converged fixpoint bindings, solely for the `on_enter` side effect of
1012
+ # re-recording the body's per-node entry scopes. Gated behind the display-path-only `converged_loop_recording`
1013
+ # flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift.
1137
1014
  def record_converged_loop_body(node, post_pred, bindings, names, body_first)
1138
1015
  return unless @converged_loop_recording && @on_enter
1139
1016
 
@@ -1141,11 +1018,10 @@ module Rigor
1141
1018
  nil
1142
1019
  end
1143
1020
 
1144
- # Runs the slice-B loop-body rebind fixpoint, returning the per-name
1145
- # continuation binding. Seed: a pre-existing local seeds with its
1146
- # post-predicate binding; a local FIRST assigned inside the body seeds
1147
- # with `nil` so the 0-iteration path (the body may never run) degrades
1148
- # it to `T | nil`, matching the historical nil-injection treatment.
1021
+ # Runs the slice-B loop-body rebind fixpoint, returning the per-name continuation binding. Seed: a pre-existing
1022
+ # local seeds with its post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the
1023
+ # 0-iteration path (the body may never run) degrades it to `T | nil`, matching the historical nil-injection
1024
+ # treatment.
1149
1025
  def loop_rebind_fixpoint(node, post_pred, names, body_first)
1150
1026
  nil_const = Type::Combinator.constant_of(nil)
1151
1027
  seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
@@ -1157,13 +1033,10 @@ module Rigor
1157
1033
  )
1158
1034
  end
1159
1035
 
1160
- # Names of locals the loop body can rebind, partitioned into those
1161
- # already bound in `base_scope` (their pre-loop binding seeds the
1162
- # fixpoint) and those FIRST assigned inside the body (no pre-state, so
1163
- # they seed with `nil` for 0-iteration soundness). A loop body
1164
- # introduces no new binding scope — every write leaks to the
1165
- # surrounding scope — so unlike a block there is no introduced-name
1166
- # filter; every local-write form under the body node counts.
1036
+ # Names of locals the loop body can rebind, partitioned into those already bound in `base_scope` (their pre-loop
1037
+ # binding seeds the fixpoint) and those FIRST assigned inside the body (no pre-state, so they seed with `nil` for
1038
+ # 0-iteration soundness). A loop body introduces no new binding scope every write leaks to the surrounding scope
1039
+ # so unlike a block there is no introduced-name filter; every local-write form under the body node counts.
1167
1040
  def loop_body_local_writes(statements, base_scope)
1168
1041
  pre_existing = []
1169
1042
  body_first = []
@@ -1180,30 +1053,22 @@ module Rigor
1180
1053
  [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
1181
1054
  end
1182
1055
 
1183
- # Evaluates the loop body once with each fixpoint-tracked local bound
1184
- # to the supplied running assumption and returns the per-name exit
1185
- # binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
1056
+ # Evaluates the loop body once with each fixpoint-tracked local bound to the supplied running assumption and
1057
+ # returns the per-name exit binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
1186
1058
  #
1187
- # The body runs from `post_pred` overlaid with the assumptions, then
1188
- # narrowed by the predicate's loop-entry edge: a `while` body only
1189
- # runs when the predicate is TRUTHY, an `until` body only when it is
1190
- # FALSEY. Re-applying that narrowing per iteration keeps loop-carried
1191
- # narrowing sound without it, an accumulator whose rebind can
1192
- # introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
1193
- # `while prefix && …`) would re-enter the body with `nil` un-narrowed
1194
- # and false-fire `possible nil receiver` on the guarded re-read. The
1195
- # historical single body pass (which seeds these locals from their
1196
- # never-nil pre-loop binding) did not need this; the fixpoint, which
1197
- # feeds the widened assumption back in, does.
1059
+ # The body runs from `post_pred` overlaid with the assumptions, then narrowed by the predicate's loop-entry edge:
1060
+ # a `while` body only runs when the predicate is TRUTHY, an `until` body only when it is FALSEY. Re-applying that
1061
+ # narrowing per iteration keeps loop-carried narrowing sound — without it, an accumulator whose rebind can
1062
+ # introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under `while prefix && …`) would re-enter the body with
1063
+ # `nil` un-narrowed and false-fire `possible nil receiver` on the guarded re-read. The historical single body pass
1064
+ # (which seeds these locals from their never-nil pre-loop binding) did not need this; the fixpoint, which feeds
1065
+ # the widened assumption back in, does.
1198
1066
  #
1199
- # A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
1200
- # into the body-entry scope: when the body runs it assigns the local
1201
- # before any use, exactly as the historical single body pass saw it.
1202
- # Its `nil` seed exists only to model the 0-iteration path and is kept
1203
- # as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
1204
- # back into the body re-evaluation would leak it past a condition-form
1205
- # assignment the engine does not thread into the branch (`if exps.size
1206
- # > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
1067
+ # A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid into the body-entry scope: when the body
1068
+ # runs it assigns the local before any use, exactly as the historical single body pass saw it. Its `nil` seed
1069
+ # exists only to model the 0-iteration path and is kept as a join constituent by {BodyFixpoint#converge}; feeding
1070
+ # that `nil` back into the body re-evaluation would leak it past a condition-form assignment the engine does not
1071
+ # thread into the branch (`if exps.size > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
1207
1072
  def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1208
1073
  overlaid = bindings.except(*body_first)
1209
1074
  entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
@@ -1213,16 +1078,12 @@ module Rigor
1213
1078
  names.to_h { |name| [name, exit_scope.local(name)] }
1214
1079
  end
1215
1080
 
1216
- # `for index in collection; body; end`. Unlike `each {}` blocks,
1217
- # `for` does NOT create a new variable scope: the index variable
1218
- # AND every local written in the body leak to the surrounding
1219
- # scope. The collection is evaluated once; the body runs zero or
1220
- # more times, so the post-loop scope is the join of the
1221
- # no-iteration scope (just `post_collection`) and the body scope,
1222
- # with half-bound names degraded to `T | nil` via nil-injection.
1223
- # The loop expression itself types as `Constant[nil]` (the common
1224
- # case where no `break VALUE` is observed), matching the policy
1225
- # `eval_loop` uses for `while` / `until`.
1081
+ # `for index in collection; body; end`. Unlike `each {}` blocks, `for` does NOT create a new variable scope: the
1082
+ # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
1083
+ # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
1084
+ # `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
1085
+ # expression itself types as `Constant[nil]` (the common case where no `break VALUE` is observed), matching the
1086
+ # policy `eval_loop` uses for `while` / `until`.
1226
1087
  def eval_for(node)
1227
1088
  coll_type, post_coll = sub_eval(node.collection, scope)
1228
1089
  element_type = for_iteration_element_type(coll_type)
@@ -1232,10 +1093,9 @@ module Rigor
1232
1093
  return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
1233
1094
  end
1234
1095
 
1235
- # Run the body pass under a break sink so a `break`-path binding the
1236
- # fall-through drops is recovered into the continuation (the `for`
1237
- # sibling of `eval_loop`'s break join; `for` has no fixpoint, so the
1238
- # single-pass join is the only continuation).
1096
+ # Run the body pass under a break sink so a `break`-path binding the fall-through drops is recovered into the
1097
+ # continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single-pass join is
1098
+ # the only continuation).
1239
1099
  break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
1240
1100
  continuation = join_with_nil_injection(post_coll, body_scope)
1241
1101
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
@@ -1243,15 +1103,11 @@ module Rigor
1243
1103
  [Type::Combinator.constant_of(nil), continuation]
1244
1104
  end
1245
1105
 
1246
- # `for x in coll` is semantically `coll.each { |x| ... }`. We
1247
- # ask the method dispatcher for `coll.each`'s expected block
1248
- # parameter types that path consults RBS and the iterator
1249
- # dispatch table, which is more precise than the structural
1250
- # `collection_element_type` fallback (it knows, e.g., that
1251
- # `Hash[K, V]#each` yields `[K, V]` even when the receiver is
1252
- # not a literal Hash carrier in our local lattice). When the
1253
- # dispatcher returns nothing (no signature, unknown receiver)
1254
- # we fall back to the structural extractor.
1106
+ # `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
1107
+ # block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
1108
+ # structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
1109
+ # the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
1110
+ # signature, unknown receiver) we fall back to the structural extractor.
1255
1111
  def for_iteration_element_type(coll_type)
1256
1112
  structural = collection_element_type(coll_type)
1257
1113
  return structural unless structural.equal?(Type::Combinator.untyped)
@@ -1269,11 +1125,9 @@ module Rigor
1269
1125
  Type::Combinator.untyped
1270
1126
  end
1271
1127
 
1272
- # Binds the `for` index variable(s) into `scope`. A single
1273
- # `LocalVariableTargetNode` is bound to `element_type` (the
1274
- # per-iteration value the collection yields). A `MultiTargetNode`
1275
- # (`for a, b in pairs`) delegates to {MultiTargetBinder}, which
1276
- # decomposes a tuple-shaped element into the inner slots.
1128
+ # Binds the `for` index variable(s) into `scope`. A single `LocalVariableTargetNode` is bound to `element_type`
1129
+ # (the per-iteration value the collection yields). A `MultiTargetNode` (`for a, b in pairs`) delegates to
1130
+ # {MultiTargetBinder}, which decomposes a tuple-shaped element into the inner slots.
1277
1131
  def bind_for_index(index_node, element_type, scope)
1278
1132
  case index_node
1279
1133
  when Prism::LocalVariableTargetNode
@@ -1286,13 +1140,10 @@ module Rigor
1286
1140
  end
1287
1141
  end
1288
1142
 
1289
- # Extracts the per-iteration element type from a collection
1290
- # carrier. `Tuple[T1..Tn]` yields the union of its elements;
1291
- # `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`;
1292
- # `Nominal[Hash, [K, V]]` yields `Tuple[K, V]` (Hash#each yields
1293
- # `[key, value]` pairs); `IntegerRange` yields `Integer`;
1294
- # `Constant<Range>` reads the literal range's element class.
1295
- # Anything else falls back to `untyped`.
1143
+ # Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
1144
+ # elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
1145
+ # V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
1146
+ # literal range's element class. Anything else falls back to `untyped`.
1296
1147
  def collection_element_type(type)
1297
1148
  case type
1298
1149
  when Type::Tuple
@@ -1337,27 +1188,18 @@ module Rigor
1337
1188
  end
1338
1189
  end
1339
1190
 
1340
- # `a && b` / `a || b`. The LHS always runs, the RHS only
1341
- # sometimes runs. Slice 6 phase 1 narrows the RHS evaluation:
1342
- # `a && b` evaluates `b` under the truthy edge of `a`, and
1343
- # `a || b` evaluates `b` under the falsey edge of `a`. The
1344
- # narrowed RHS post-scope is joined with the LHS post-scope
1345
- # (RHS skipped) using nil-injection so half-bound names from
1346
- # the RHS still degrade to `T | nil`. The result type is
1347
- # edge-aware: `a && b` can only produce the falsey fragment of
1348
- # `a` when the RHS is skipped, while `a || b` can only produce
1349
- # the truthy fragment of `a` when the RHS is skipped.
1191
+ # `a && b` / `a || b`. The LHS always runs, the RHS only sometimes runs. Slice 6 phase 1 narrows the RHS
1192
+ # evaluation: `a && b` evaluates `b` under the truthy edge of `a`, and `a || b` evaluates `b` under the falsey
1193
+ # edge of `a`. The narrowed RHS post-scope is joined with the LHS post-scope (RHS skipped) using nil-injection so
1194
+ # half-bound names from the RHS still degrade to `T | nil`. The result type is edge-aware: `a && b` can only
1195
+ # produce the falsey fragment of `a` when the RHS is skipped, while `a || b` can only produce the truthy fragment
1196
+ # of `a` when the RHS is skipped.
1350
1197
  #
1351
- # When the RHS is a terminating branch — it `raise`s /
1352
- # `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
1353
- # inferred type is `Bot` (ADR-24 WD6: a divergent helper such
1354
- # as `a or fail_with_message(...)`, recognised via
1355
- # `branch_terminates?`) the post-OR / post-AND scope is the
1356
- # LHS-skipped edge alone: `a or raise` only survives when `a`
1357
- # was truthy, so subsequent statements observe `a` narrowed to
1358
- # its truthy fragment; the symmetric `a and raise` survives
1359
- # only when `a` was falsey. Same shape as the `eval_if` /
1360
- # `eval_unless` early-return narrowing.
1198
+ # When the RHS is a terminating branch — it `raise`s / `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
1199
+ # inferred type is `Bot` (ADR-24 WD6: a divergent helper such as `a or fail_with_message(...)`, recognised via
1200
+ # `branch_terminates?`) the post-OR / post-AND scope is the LHS-skipped edge alone: `a or raise` only survives
1201
+ # when `a` was truthy, so subsequent statements observe `a` narrowed to its truthy fragment; the symmetric `a and
1202
+ # raise` survives only when `a` was falsey. Same shape as the `eval_if` / `eval_unless` early-return narrowing.
1361
1203
  def eval_and_or(node)
1362
1204
  left_type, left_scope = sub_eval(node.left, scope)
1363
1205
  truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
@@ -1365,8 +1207,7 @@ module Rigor
1365
1207
  right_type, right_scope = sub_eval(node.right, rhs_entry)
1366
1208
 
1367
1209
  if branch_terminates?(node.right, right_type)
1368
- # Control never reaches any statement after `a or raise`
1369
- # via the RHS edge — the RHS scope is discarded.
1210
+ # Control never reaches any statement after `a or raise` via the RHS edge — the RHS scope is discarded.
1370
1211
  surviving_type =
1371
1212
  if node.is_a?(Prism::AndNode)
1372
1213
  Narrowing.narrow_falsey(left_type)
@@ -1389,23 +1230,18 @@ module Rigor
1389
1230
  ]
1390
1231
  end
1391
1232
 
1392
- # `(body)`. Threads scope through the inner expression so
1393
- # `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
1233
+ # `(body)`. Threads scope through the inner expression so `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
1394
1234
  def eval_parentheses(node)
1395
1235
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
1396
1236
 
1397
1237
  sub_eval(node.body, scope)
1398
1238
  end
1399
1239
 
1400
- # `class Foo; body; end` and `module Foo; body; end`. The class
1401
- # body runs in a fresh scope (Ruby's class scope does not see
1402
- # the outer locals), and the StatementEvaluator pushes a new
1403
- # `ClassFrame` so nested `def`s know their lexical owner. The
1404
- # outer scope is unchanged on exit because Ruby's class
1405
- # definition does not bind any local in the enclosing scope.
1406
- # The class body's value is the value of its last statement
1407
- # (`Constant[nil]` for an empty body); we discard the body's
1408
- # post-scope.
1240
+ # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
1241
+ # does not see the outer locals), and the StatementEvaluator pushes a new `ClassFrame` so nested `def`s know their
1242
+ # lexical owner. The outer scope is unchanged on exit because Ruby's class definition does not bind any local in
1243
+ # the enclosing scope. The class body's value is the value of its last statement (`Constant[nil]` for an empty
1244
+ # body); we discard the body's post-scope.
1409
1245
  def eval_class_or_module(node)
1410
1246
  name = Source::ConstantPath.qualified_name(node.constant_path)
1411
1247
  new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
@@ -1413,45 +1249,30 @@ module Rigor
1413
1249
  [body_type, scope]
1414
1250
  end
1415
1251
 
1416
- # `class << expr; body; end`. When `expr` is `self`, the body
1417
- # defines class methods on the immediate enclosing class the
1418
- # innermost frame flips to `singleton: true` so a nested
1419
- # `def foo` resolves through `singleton_method` rather than
1420
- # `instance_method`. For non-`self` expressions we cannot
1421
- # statically resolve the receiver, so we keep the existing
1422
- # context and accept that nested defs degrade to the
1423
- # `Dynamic[Top]` default.
1252
+ # `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
1253
+ # class the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
1254
+ # rather than `instance_method`. For non-`self` expressions we cannot statically resolve the receiver, so we keep
1255
+ # the existing context and accept that nested defs degrade to the `Dynamic[Top]` default.
1424
1256
  def eval_singleton_class(node)
1425
1257
  new_context = singleton_context_for(node)
1426
1258
  body_type, _body_scope = eval_class_body(node, new_context)
1427
1259
  [body_type, scope]
1428
1260
  end
1429
1261
 
1430
- # `def name(params); body; end`. Builds the method-entry scope
1431
- # by binding the parameter list (RBS-driven where available, or
1432
- # `Dynamic[Top]` for the slice 3 phase 2 fallback) into a fresh
1433
- # scope, then evaluates the body under that scope. The outer
1434
- # scope is left unchanged: a `def` does not introduce a binding
1435
- # in its enclosing scope. Ruby evaluates `def` to the method's
1436
- # name as a Symbol, so the produced type is `Constant[:name]`.
1262
+ # `def name(params); body; end`. Builds the method-entry scope by binding the parameter list (RBS-driven where
1263
+ # available, or `Dynamic[Top]` for the slice 3 phase 2 fallback) into a fresh scope, then evaluates the body under
1264
+ # that scope. The outer scope is left unchanged: a `def` does not introduce a binding in its enclosing scope. Ruby
1265
+ # evaluates `def` to the method's name as a Symbol, so the produced type is `Constant[:name]`.
1437
1266
  def eval_def(node)
1438
1267
  body_scope = build_method_entry_scope(node)
1439
- # Parameter default value expressions (e.g. `self.x` in
1440
- # `def copy(x: self.x)`) execute when the method is
1441
- # *invoked*, not when the `def` is read; their `self` is
1442
- # the instance receiver, not the surrounding class body.
1443
- # Walk the parameters subtree under `body_scope` so the
1444
- # scope-index records the instance `self_type` for every
1445
- # node inside parameter defaults. `propagate` would
1446
- # otherwise drop them to the outer class-body scope (where
1447
- # `self_type` is `singleton(C)`), making `self.foo` look
1448
- # like a singleton-side call. Observed surfacing 915 false
1449
- # positives in `prism-1.9.0`'s auto-generated `copy`
1450
- # methods alone.
1451
- # A nested `def` is a return barrier: its body's `return`s belong to
1452
- # the inner method, not the one currently being inferred. Suspend the
1453
- # return sink across the nested body so `eval_return` does not record
1454
- # them into the outer method's return type.
1268
+ # Parameter default value expressions (e.g. `self.x` in `def copy(x: self.x)`) execute when the method is
1269
+ # *invoked*, not when the `def` is read; their `self` is the instance receiver, not the surrounding class body.
1270
+ # Walk the parameters subtree under `body_scope` so the scope-index records the instance `self_type` for every
1271
+ # node inside parameter defaults. `propagate` would otherwise drop them to the outer class-body scope (where
1272
+ # `self_type` is `singleton(C)`), making `self.foo` look like a singleton-side call. Observed surfacing 915
1273
+ # false positives in `prism-1.9.0`'s auto-generated `copy` methods alone. A nested `def` is a return barrier:
1274
+ # its body's `return`s belong to the inner method, not the one currently being inferred. Suspend the return sink
1275
+ # across the nested body so `eval_return` does not record them into the outer method's return type.
1455
1276
  outer_sink = Thread.current[RETURN_SINK_KEY]
1456
1277
  Thread.current[RETURN_SINK_KEY] = nil
1457
1278
  begin
@@ -1463,145 +1284,98 @@ module Rigor
1463
1284
  [Type::Combinator.constant_of(node.name), scope]
1464
1285
  end
1465
1286
 
1466
- # `recv.foo(args) { |params| body }` and friends. The call
1467
- # type comes from `Scope#type_of` (which routes through
1468
- # `ExpressionTyper#call_type_for` and is itself block-aware
1469
- # since Slice 6 phase C sub-phase 2: it builds the block-entry
1470
- # scope from the receiving method's RBS signature, types the
1471
- # block body, and threads the body's type into
1472
- # `MethodDispatcher.dispatch`'s `block_type:` so generic
1473
- # methods like `Array#map { |n| n.to_s }` resolve to
1287
+ # `recv.foo(args) { |params| body }` and friends. The call type comes from `Scope#type_of` (which routes through
1288
+ # `ExpressionTyper#call_type_for` and is itself block-aware since Slice 6 phase C sub-phase 2: it builds the
1289
+ # block-entry scope from the receiving method's RBS signature, types the block body, and threads the body's type
1290
+ # into `MethodDispatcher.dispatch`'s `block_type:` so generic methods like `Array#map { |n| n.to_s }` resolve to
1474
1291
  # `Array[String]`).
1475
1292
  #
1476
- # The handler still re-evaluates the block under its entry
1477
- # scope so the per-node scope index sees the bindings on the
1478
- # `on_enter` callback path. Block effects do NOT leak into the
1479
- # post-call scope: a block-local write is observed only
1480
- # inside the block body. The receiver and arguments still
1481
- # observe the outer scope, matching Ruby evaluation order.
1293
+ # The handler still re-evaluates the block under its entry scope so the per-node scope index sees the bindings on
1294
+ # the `on_enter` callback path. Block effects do NOT leak into the post-call scope: a block-local write is
1295
+ # observed only inside the block body. The receiver and arguments still observe the outer scope, matching Ruby
1296
+ # evaluation order.
1482
1297
  def eval_call(node)
1483
1298
  call_type = scope.type_of(node, tracer: tracer)
1484
- # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
1485
- # returns the memo; the engine otherwise types the call `Dynamic[top]`.
1486
- # Compute the joined memo type from the block's content mutations of
1487
- # the memo block-param and adopt it as the call's return type.
1299
+ # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
1300
+ # types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
1301
+ # block-param and adopt it as the call's return type.
1488
1302
  call_type = each_with_object_return(node, call_type)
1489
1303
  evaluate_block_if_present(node)
1490
- # `ruby2_keywords def foo(...)` (and similar wrappers like
1491
- # `private def`, `public def`, `module_function def`) parse
1492
- # the def as the call's positional argument; the
1493
- # ExpressionTyper#type_of_def handler types it as
1494
- # `Constant[:foo]` without walking the body. Without
1495
- # explicitly evaluating the argument-position def, the body's
1496
- # scope-index entries inherit the outer class-body
1497
- # `self_type = singleton(C)` from `ScopeIndexer.propagate`,
1498
- # so `self.helper` inside reports `undefined method 'helper'
1499
- # for singleton(C)`. Walking each argument-position def under
1500
- # the current evaluator (not a sub_eval — the def's effects
1501
- # do not bind into the surrounding scope) populates the
1502
- # scope index with the correct instance / singleton
1503
- # `self_type` for the def's body.
1304
+ # `ruby2_keywords def foo(...)` (and similar wrappers like `private def`, `public def`, `module_function def`)
1305
+ # parse the def as the call's positional argument; the ExpressionTyper#type_of_def handler types it as
1306
+ # `Constant[:foo]` without walking the body. Without explicitly evaluating the argument-position def, the body's
1307
+ # scope-index entries inherit the outer class-body `self_type = singleton(C)` from `ScopeIndexer.propagate`, so
1308
+ # `self.helper` inside reports `undefined method 'helper' for singleton(C)`. Walking each argument-position def
1309
+ # under the current evaluator (not a sub_eval — the def's effects do not bind into the surrounding scope)
1310
+ # populates the scope index with the correct instance / singleton `self_type` for the def's body.
1504
1311
  evaluate_def_arguments(node)
1505
1312
  post_scope = record_closure_escape_if_any(node)
1506
- # ADR-56 slice A — non-escaping block captured-local write-back.
1507
- # A `:non_escaping` block (each / times / upto / map …) that
1508
- # rebinds an outer local must not leave that local's pre-call
1509
- # binding unmodified in the continuation scope; the spec MUST in
1510
- # § "Fact stability and mutation" names captured locals a
1511
- # first-class invalidation category. (The escaping / unknown path
1512
- # already widened to Dynamic[top] via `record_closure_escape_if_any`.)
1313
+ # ADR-56 slice A — non-escaping block captured-local write-back. A `:non_escaping` block (each / times / upto /
1314
+ # map …) that rebinds an outer local must not leave that local's pre-call binding unmodified in the continuation
1315
+ # scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
1316
+ # category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
1513
1317
  post_scope = write_back_block_captures(node, post_scope)
1514
1318
  post_scope = apply_rbs_extended_assertions(node, post_scope)
1515
1319
  post_scope = apply_plugin_assertions(node, post_scope)
1516
1320
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
1517
- # Flow-folding G1 / G2 — widen a local- or instance-variable
1518
- # binding when the call is an in-place mutator on it (e.g.
1519
- # `arms << x`, `@tags << hashtag`). Stops a literal-shape
1520
- # carrier (`Tuple` / `HashShape`) from outliving its
1521
- # justification when the value is mutated. Always-safe
1522
- # (loses precision, never invents facts).
1321
+ # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
1322
+ # (e.g. `arms << x`, `@tags << hashtag`). Stops a literal-shape carrier (`Tuple` / `HashShape`) from outliving
1323
+ # its justification when the value is mutated. Always-safe (loses precision, never invents facts).
1523
1324
  post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1524
- # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
1525
- # self-call resolves to a user method that CONTENT-mutates one of its
1526
- # parameters inside an escaping block (the `build_option_parser(opts)`
1527
- # idiom the callee returns an `OptionParser` whose
1528
- # `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
1529
- # the matching caller-argument local. The callee's escape is invisible
1530
- # across the boundary, so without this the caller's `options` keeps its
1531
- # seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
1532
- # fires only when the resolved callee actually escape-mutates that
1533
- # parameter (not for every self-call), and sound — only loses
1534
- # precision on the floored argument.
1325
+ # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a self-call resolves to a user method that
1326
+ # CONTENT-mutates one of its parameters inside an escaping block (the `build_option_parser(opts)` idiom — the
1327
+ # callee returns an `OptionParser` whose `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
1328
+ # the matching caller-argument local. The callee's escape is invisible across the boundary, so without this the
1329
+ # caller's `options` keeps its seed and `options.fetch(:mode)` folds to a wrong constant. Precise: fires only
1330
+ # when the resolved callee actually escape-mutates that parameter (not for every self-call), and sound — only
1331
+ # loses precision on the floored argument.
1535
1332
  post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1536
- # Same always-safe rationale as `widen_after_call` above —
1537
- # propagates outer-scope local / ivar widening from block body
1538
- # mutations (`items.each { |x| arr << x }`).
1333
+ # Same always-safe rationale as `widen_after_call` above — propagates outer-scope local / ivar widening from
1334
+ # block body mutations (`items.each { |x| arr << x }`).
1539
1335
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1540
- # ADR-56 slice C — receiver-content element-type join. Joins
1541
- # appended / stored element / key / value types into the
1542
- # continuation collection so `out = [0]; arr.each { |x| out << x }`
1543
- # types `Array[0 | Integer]`, not `Array[0]`. Same always-safe
1544
- # rationale (only widens).
1336
+ # ADR-56 slice C — receiver-content element-type join. Joins appended / stored element / key / value types into
1337
+ # the continuation collection so `out = [0]; arr.each { |x| out << x }` types `Array[0 | Integer]`, not
1338
+ # `Array[0]`. Same always-safe rationale (only widens).
1545
1339
  post_scope = content_writeback_block_captures(node, post_scope)
1546
- # Indexed-collection narrowing — drop any
1547
- # `receiver[key] ||= default` narrowing the analyzer
1548
- # recorded earlier when an intervening `[]=` writes the
1549
- # same slot or any other mutator runs against the
1550
- # receiver. Always-safe (only forgets; never invents).
1340
+ # Indexed-collection narrowing — drop any `receiver[key] ||= default` narrowing the analyzer recorded earlier
1341
+ # when an intervening `[]=` writes the same slot or any other mutator runs against the receiver. Always-safe
1342
+ # (only forgets; never invents).
1551
1343
  post_scope = IndexedNarrowing.invalidate_after_call(call_node: node, current_scope: post_scope)
1552
- # Single-hop method-chain narrowing — drop every
1553
- # `(receiver, *)` chain narrowing rooted at the call's
1554
- # outer stable receiver (any-call-against-the-root
1555
- # invalidation rule, B2 from the slice's design
1556
- # notes). Calls whose outer receiver is itself a chain
1557
- # node (e.g. `x.last << y`) do NOT drop narrowings
1558
- # keyed on `x` — only direct calls against the root
1559
- # variable invalidate the chain.
1344
+ # Single-hop method-chain narrowing — drop every `(receiver, *)` chain narrowing rooted at the call's outer
1345
+ # stable receiver (any-call-against-the-root invalidation rule, B2 from the slice's design notes). Calls whose
1346
+ # outer receiver is itself a chain node (e.g. `x.last << y`) do NOT drop narrowings keyed on `x` — only direct
1347
+ # calls against the root variable invalidate the chain.
1560
1348
  post_scope = IndexedNarrowing.invalidate_chain_after_call(call_node: node, current_scope: post_scope)
1561
- # B2.2 — intervening method call ivar invalidation.
1562
- # An implicit-self / self-receiver call could mutate any
1563
- # ivar of the enclosing class (we cannot prove purity
1564
- # without an effect system). Reset each ivar whose
1565
- # current local binding has narrowed below the class-ivar
1566
- # seed back to the seed itself, so a subsequent
1567
- # `if @flag` predicate observes the seed's union (not the
1568
- # pre-call narrowed value). Always-safe (only widens; no
1569
- # new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
1570
- # § "Flow-folding" — G2 intervening-call case.
1349
+ # B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
1350
+ # of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
1351
+ # binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
1352
+ # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
1353
+ # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" G2 intervening-call case.
1571
1354
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
1572
- # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
1573
- # narrowed to non-nil on a successful-match edge; a later call
1574
- # that itself runs a regex match rebinds them, so the narrowed
1575
- # facts must be dropped. We forget them only when the call is
1576
- # match-CAPABLE (a regex-matching method, or an implicit-self /
1577
- # unknown-receiver call whose body we cannot prove match-free).
1578
- # A call provably match-free on a known receiver — `$3.to_i`,
1579
- # `year < 50` — does NOT clobber, so the multi-statement
1580
- # `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1355
+ # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
1356
+ # later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
1357
+ # only when the call is match-CAPABLE (a regex-matching method, or an implicit-self / unknown-receiver call
1358
+ # whose body we cannot prove match-free). A call provably match-free on a known receiver — `$3.to_i`, `year <
1359
+ # 50` does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1581
1360
  # while a genuinely interposed match still invalidates.
1582
1361
  post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1583
1362
  [call_type, post_scope]
1584
1363
  end
1585
1364
 
1586
- # Method names that (may) run a regex match and therefore rebind
1587
- # the `$~` family. Conservative over-approximation a few set
1588
- # globals only with a Regexp argument, but we do not inspect args.
1365
+ # Method names that (may) run a regex match and therefore rebind the `$~` family. Conservative over-approximation
1366
+ # a few set globals only with a Regexp argument, but we do not inspect args.
1589
1367
  MATCH_CAPABLE_METHODS = %i[
1590
1368
  =~ match match? gsub gsub! sub sub! scan split slice slice!
1591
1369
  [] partition rpartition index rindex === grep grep_v
1592
1370
  ].freeze
1593
1371
  private_constant :MATCH_CAPABLE_METHODS
1594
1372
 
1595
- # True when `node` could rebind the regex match-data globals:
1596
- # a known regex-matching method by name, or an implicit-self /
1597
- # self-receiver call whose body we cannot inspect for an internal
1598
- # match. An explicit-receiver call to a non-matching method
1599
- # (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
1600
- # the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
1601
- # narrowed globals. The over-approximation is one-directional: a
1602
- # user method that secretly matches on an explicit receiver is the
1603
- # only escape, and re-narrowing on the next real guard recovers —
1604
- # weighed against the false-positive cost, precision wins here.
1373
+ # True when `node` could rebind the regex match-data globals: a known regex-matching method by name, or an
1374
+ # implicit-self / self-receiver call whose body we cannot inspect for an internal match. An explicit-receiver call
1375
+ # to a non-matching method (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so the multi-statement `m
1376
+ # = /…/ =~ s; …; use($2)` idiom keeps the narrowed globals. The over-approximation is one-directional: a user
1377
+ # method that secretly matches on an explicit receiver is the only escape, and re-narrowing on the next real guard
1378
+ # recovers weighed against the false-positive cost, precision wins here.
1605
1379
  def match_capable_call?(node)
1606
1380
  return true unless node.is_a?(Prism::CallNode)
1607
1381
  return true if MATCH_CAPABLE_METHODS.include?(node.name)
@@ -1610,12 +1384,9 @@ module Rigor
1610
1384
  receiver.nil? || receiver.is_a?(Prism::SelfNode)
1611
1385
  end
1612
1386
 
1613
- # Returns a scope with each ivar's narrowed local binding
1614
- # widened back to its class-ivar seed value when the call
1615
- # is one that could plausibly mutate ivars on the enclosing
1616
- # class (implicit-self or explicit `self.foo`). External-
1617
- # receiver calls (`obj.method`) cannot reach the caller's
1618
- # ivars; they pass through unchanged.
1387
+ # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
1388
+ # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
1389
+ # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
1619
1390
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
1620
1391
  return current_scope unless intervening_call_candidate?(call_node)
1621
1392
 
@@ -1655,11 +1426,9 @@ module Rigor
1655
1426
  end
1656
1427
  end
1657
1428
 
1658
- # v0.0.3 — recognises a small catalogue of RSpec
1659
- # matcher patterns as assert-shaped narrows on the
1660
- # local passed to `expect(...)`. The pattern is
1661
- # matched purely on AST shape; no RBS for RSpec is
1662
- # required (and none is shipped today).
1429
+ # v0.0.3 — recognises a small catalogue of RSpec matcher patterns as assert-shaped narrows on the local passed to
1430
+ # `expect(...)`. The pattern is matched purely on AST shape; no RBS for RSpec is required (and none is shipped
1431
+ # today).
1663
1432
  #
1664
1433
  # Recognised today:
1665
1434
  #
@@ -1674,11 +1443,9 @@ module Rigor
1674
1443
  # `be_an_instance_of`, subtype-permitting
1675
1444
  # otherwise).
1676
1445
  #
1677
- # Anything else is silently passed through. Symmetric
1678
- # negative class assertions (`not_to be_a(C)`) and
1679
- # narrowing TO `NilClass` are intentionally NOT
1680
- # modelled: they are rarely useful in practice and
1681
- # risk masking bugs if the assertion later fails.
1446
+ # Anything else is silently passed through. Symmetric negative class assertions (`not_to be_a(C)`) and narrowing
1447
+ # TO `NilClass` are intentionally NOT modelled: they are rarely useful in practice and risk masking bugs if the
1448
+ # assertion later fails.
1682
1449
  def apply_rspec_matcher_narrowing(call_node, current_scope)
1683
1450
  narrow = rspec_matcher_narrowing_request(call_node)
1684
1451
  return current_scope if narrow.nil?
@@ -1691,10 +1458,8 @@ module Rigor
1691
1458
  current_scope.with_local(local_name, narrowed)
1692
1459
  end
1693
1460
 
1694
- # Decodes an `expect(x).<chain>` outer call into a
1695
- # narrowing request hash, or `nil` when the shape is
1696
- # not recognised. The hash carries `:local` (the local
1697
- # name being narrowed) plus the narrowing parameters.
1461
+ # Decodes an `expect(x).<chain>` outer call into a narrowing request hash, or `nil` when the shape is not
1462
+ # recognised. The hash carries `:local` (the local name being narrowed) plus the narrowing parameters.
1698
1463
  def rspec_matcher_narrowing_request(call_node)
1699
1464
  local_name = rspec_expectation_target(call_node)
1700
1465
  return nil if local_name.nil?
@@ -1725,8 +1490,7 @@ module Rigor
1725
1490
  end
1726
1491
  end
1727
1492
 
1728
- # `be_a` / `be_kind_of` / `be_an_instance_of` accept a
1729
- # single class argument — either a `ConstantReadNode`
1493
+ # `be_a` / `be_kind_of` / `be_an_instance_of` accept a single class argument — either a `ConstantReadNode`
1730
1494
  # (`Integer`) or a `ConstantPathNode` (`Rigor::Type::Nominal`).
1731
1495
  def rspec_be_a_narrow(matcher, local_name, exact:)
1732
1496
  args = matcher.arguments&.arguments || []
@@ -1749,11 +1513,8 @@ module Rigor
1749
1513
  end
1750
1514
  end
1751
1515
 
1752
- # Returns the local name passed to `expect(...)` when
1753
- # the receiver chain matches `expect(<local>)` exactly,
1754
- # or nil otherwise. Centralised so each per-matcher
1755
- # decoder can short-circuit on a non-matching outer
1756
- # call.
1516
+ # Returns the local name passed to `expect(...)` when the receiver chain matches `expect(<local>)` exactly, or nil
1517
+ # otherwise. Centralised so each per-matcher decoder can short-circuit on a non-matching outer call.
1757
1518
  def rspec_expectation_target(call_node)
1758
1519
  receiver = call_node.receiver
1759
1520
  return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
@@ -1776,10 +1537,8 @@ module Rigor
1776
1537
  matcher
1777
1538
  end
1778
1539
 
1779
- # True when `call_node`'s sole argument is an
1780
- # implicit-self matcher call with the given name and
1781
- # no positional arguments — used by the no-arg
1782
- # matchers (`be_nil`).
1540
+ # True when `call_node`'s sole argument is an implicit-self matcher call with the given name and no positional
1541
+ # arguments used by the no-arg matchers (`be_nil`).
1783
1542
  def rspec_matcher_argument?(call_node, matcher_name)
1784
1543
  matcher = rspec_matcher_node(call_node)
1785
1544
  return false if matcher.nil?
@@ -1788,16 +1547,11 @@ module Rigor
1788
1547
  matcher.arguments.nil? || matcher.arguments.arguments.empty?
1789
1548
  end
1790
1549
 
1791
- # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the
1792
- # post-return facts the merger produces for an
1793
- # `RBS::Extended`-annotated call. Reads through
1794
- # `RbsExtended.read_flow_contribution` so the bundle
1795
- # carries the canonical `Rigor::FlowContribution::Fact`
1796
- # rows for `:always` assert directives (the slice-4a
1797
- # routing places conditional asserts on `truthy_facts` /
1798
- # `falsey_facts`, which `Narrowing.predicate_scopes`
1799
- # consumes). Plugin `:always` assertions are handled by
1800
- # the sibling `apply_plugin_assertions`, not this path.
1550
+ # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the post-return facts the merger produces for an
1551
+ # `RBS::Extended`-annotated call. Reads through `RbsExtended.read_flow_contribution` so the bundle carries the
1552
+ # canonical `Rigor::FlowContribution::Fact` rows for `:always` assert directives (the slice-4a routing places
1553
+ # conditional asserts on `truthy_facts` / `falsey_facts`, which `Narrowing.predicate_scopes` consumes). Plugin
1554
+ # `:always` assertions are handled by the sibling `apply_plugin_assertions`, not this path.
1801
1555
  def apply_rbs_extended_assertions(call_node, current_scope)
1802
1556
  method_def = resolve_call_method(call_node, current_scope)
1803
1557
  return current_scope if method_def.nil?
@@ -1814,31 +1568,20 @@ module Rigor
1814
1568
  end
1815
1569
  end
1816
1570
 
1817
- # ADR-7 § "Slice 4-A" / T.bind priority slice 2 — applies
1818
- # the post-return facts plugin contributions produce. This
1819
- # is the sibling of {apply_rbs_extended_assertions}: the
1820
- # carrier (`Rigor::FlowContribution::Fact`) and the
1821
- # downstream narrowing path (`apply_post_return_fact` →
1822
- # `apply_self_post_return_fact`) are the same; only the
1823
- # *source* of the bundle changes (RBS::Extended vs the
1824
- # registered plugins' `flow_contribution_for`).
1571
+ # ADR-7 § "Slice 4-A" / T.bind priority slice 2 — applies the post-return facts plugin contributions produce. This
1572
+ # is the sibling of {apply_rbs_extended_assertions}: the carrier (`Rigor::FlowContribution::Fact`) and the
1573
+ # downstream narrowing path (`apply_post_return_fact` `apply_self_post_return_fact`) are the same; only the
1574
+ # *source* of the bundle changes (RBS::Extended vs the registered plugins' `flow_contribution_for`).
1825
1575
  #
1826
- # `:self`-targeted facts narrow `scope.self_type` for the
1827
- # surrounding scope. In a block body, the surrounding
1828
- # scope is the block's own scope, so the narrowing applies
1829
- # to the rest of the block — exactly the contract Sorbet's
1830
- # `T.bind(self, T)` commits to.
1576
+ # `:self`-targeted facts narrow `scope.self_type` for the surrounding scope. In a block body, the surrounding
1577
+ # scope is the block's own scope, so the narrowing applies to the rest of the block — exactly the contract
1578
+ # Sorbet's `T.bind(self, T)` commits to.
1831
1579
  #
1832
- # `:parameter`-targeted facts only land when the called
1833
- # method has an authoritative RBS sig (via
1834
- # `resolve_call_method`); plugins recognising their own
1835
- # synthetic call shapes (e.g. `T.assert_type!`) have no
1836
- # method_def and the parameter facts silently skip the
1837
- # plugin's own diagnostics_for_file path covers those
1838
- # cases. The full plugin-side parameter-targeting story
1839
- # (PHPStan-style Type-Specifying Extensions on
1840
- # plugin-recognised calls) lives behind a follow-up slice
1841
- # that introduces `:local` / `:argument_at` target kinds.
1580
+ # `:parameter`-targeted facts only land when the called method has an authoritative RBS sig (via
1581
+ # `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
1582
+ # method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
1583
+ # cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
1584
+ # plugin-recognised calls) lives behind a follow-up slice that introduces `:local` / `:argument_at` target kinds.
1842
1585
  def apply_plugin_assertions(call_node, current_scope)
1843
1586
  registry = current_scope.environment&.plugin_registry
1844
1587
  return current_scope if registry.nil? || registry.empty?
@@ -1856,16 +1599,14 @@ module Rigor
1856
1599
  end
1857
1600
  end
1858
1601
 
1859
- # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
1860
- # narrowing from the method-gated `narrowing_facts` DSL, wrapped as
1861
- # a facts-only `FlowContribution`, swallowing per-plugin
1862
- # exceptions so a buggy plugin can't abort the assertion path.
1602
+ # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return narrowing from the method-gated
1603
+ # `narrowing_facts` DSL, wrapped as a facts-only `FlowContribution`, swallowing per-plugin exceptions so a buggy
1604
+ # plugin can't abort the assertion path.
1863
1605
  EMPTY_CONTRIBUTIONS = [].freeze
1864
1606
  private_constant :EMPTY_CONTRIBUTIONS
1865
1607
 
1866
- # Fast-exit guard: skip if no plugin declares a `narrowing_facts` rule, or if
1867
- # no registered method-name gate matches the call. See
1868
- # `collect_gated_statement_contributions` for the full consultation.
1608
+ # Fast-exit guard: skip if no plugin declares a `narrowing_facts` rule, or if no registered method-name gate
1609
+ # matches the call. See `collect_gated_statement_contributions` for the full consultation.
1869
1610
  def collect_plugin_contributions(registry, call_node, current_scope)
1870
1611
  index = registry.contribution_index
1871
1612
  relevant = index.for_statement
@@ -1877,10 +1618,9 @@ module Rigor
1877
1618
  collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1878
1619
  end
1879
1620
 
1880
- # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
1881
- # Visits only plugins in `for_statement` (declare a `narrowing_facts` rule),
1882
- # further gated by the method-name Set probe so the common no-candidate
1883
- # case is a single lookup. Accumulates lazily; caller is read-only.
1621
+ # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order. Visits only plugins in `for_statement` (declare
1622
+ # a `narrowing_facts` rule), further gated by the method-name Set probe so the common no-candidate case is a
1623
+ # single lookup. Accumulates lazily; caller is read-only.
1884
1624
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1885
1625
  result = nil
1886
1626
  relevant.each do |plugin|
@@ -1923,19 +1663,13 @@ module Rigor
1923
1663
  end
1924
1664
  end
1925
1665
 
1926
- # Slice 4b-2 — applies a single post-return Fact to the
1927
- # scope. Mirrors `Narrowing#apply_fact_to_scope` (Fact
1928
- # variant of the v0.0.2 `apply_assert_effect`); shares the
1929
- # narrowing logic via `Narrowing.narrow_for_fact` so the
1930
- # predicate / assert / plugin paths all converge on the
1931
- # same hierarchy-aware narrowing rules.
1666
+ # Slice 4b-2 — applies a single post-return Fact to the scope. Mirrors `Narrowing#apply_fact_to_scope` (Fact
1667
+ # variant of the v0.0.2 `apply_assert_effect`); shares the narrowing logic via `Narrowing.narrow_for_fact` so the
1668
+ # predicate / assert / plugin paths all converge on the same hierarchy-aware narrowing rules.
1932
1669
  #
1933
- # v0.1.8 Pillar 2 Slice 1 added the `:local` target_kind
1934
- # branch so plugins recognising bespoke call shapes
1935
- # (`expect(x).to be_a(T)`) can directly narrow a named
1936
- # local in the surrounding scope, bypassing the
1937
- # parameter-name lookup that requires an authoritative RBS
1938
- # sig on the called method (which RSpec matchers lack).
1670
+ # v0.1.8 Pillar 2 Slice 1 added the `:local` target_kind branch so plugins recognising bespoke call shapes
1671
+ # (`expect(x).to be_a(T)`) can directly narrow a named local in the surrounding scope, bypassing the
1672
+ # parameter-name lookup that requires an authoritative RBS sig on the called method (which RSpec matchers lack).
1939
1673
  def apply_post_return_fact(fact, call_node, current_scope, method_def)
1940
1674
  return apply_local_post_return_fact(fact, current_scope) if fact.target_kind == :local
1941
1675
 
@@ -1951,12 +1685,9 @@ module Rigor
1951
1685
  current_scope.with_local(local_name, narrowed)
1952
1686
  end
1953
1687
 
1954
- # v0.1.8 Pillar 2 Slice 1 — narrows the named local directly
1955
- # without consulting the call node's argument list. The fact's
1956
- # `target_name` is the local-variable name as written in
1957
- # source. Silently no-ops when the local is unbound in the
1958
- # current scope (the plugin's named local may have already
1959
- # gone out of scope when the contribution fires).
1688
+ # v0.1.8 Pillar 2 Slice 1 — narrows the named local directly without consulting the call node's argument list. The
1689
+ # fact's `target_name` is the local-variable name as written in source. Silently no-ops when the local is unbound
1690
+ # in the current scope (the plugin's named local may have already gone out of scope when the contribution fires).
1960
1691
  def apply_local_post_return_fact(fact, current_scope)
1961
1692
  local_name = fact.target_name
1962
1693
  current_type = current_scope.local(local_name)
@@ -1966,9 +1697,8 @@ module Rigor
1966
1697
  current_scope.with_local(local_name, narrowed)
1967
1698
  end
1968
1699
 
1969
- # v0.1.1 Track 1 slice 3 — `assert self is T` post-return
1970
- # narrowing for the four supported receiver shapes (mirrors
1971
- # `Narrowing#apply_self_fact`).
1700
+ # v0.1.1 Track 1 slice 3 — `assert self is T` post-return narrowing for the four supported receiver shapes
1701
+ # (mirrors `Narrowing#apply_self_fact`).
1972
1702
  def apply_self_post_return_fact(fact, receiver_node, current_scope)
1973
1703
  case receiver_node
1974
1704
  when nil, Prism::SelfNode
@@ -1994,8 +1724,7 @@ module Rigor
1994
1724
  end
1995
1725
  end
1996
1726
 
1997
- # `:self` routes to the call receiver; otherwise we look
1998
- # up the matching positional argument by parameter name.
1727
+ # `:self` routes to the call receiver; otherwise we look up the matching positional argument by parameter name.
1999
1728
  def fact_target_node(fact, call_node, method_def)
2000
1729
  if fact.target_kind == :self
2001
1730
  call_node.receiver
@@ -2005,12 +1734,9 @@ module Rigor
2005
1734
  end
2006
1735
 
2007
1736
  def lookup_post_return_arg(call_node, method_def, target_name)
2008
- # Plugin-source contributions arrive without an
2009
- # authoritative method_def (the plugin recognised the
2010
- # call shape directly). Parameter-targeting falls back
2011
- # to "no narrow" in that case — the wider plugin-side
2012
- # parameter mapping (`:local` / `:argument_at`) is a
2013
- # follow-up slice.
1737
+ # Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
1738
+ # directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
1739
+ # mapping (`:local` / `:argument_at`) is a follow-up slice.
2014
1740
  return nil if method_def.nil?
2015
1741
 
2016
1742
  arguments = call_node.arguments&.arguments || []
@@ -2030,8 +1756,8 @@ module Rigor
2030
1756
  sub_eval(block, block_entry)
2031
1757
  end
2032
1758
 
2033
- # Slice 6 phase C sub-phase 3b/3c. When the call carries a
2034
- # block whose receiving method is NOT proven non-escaping:
1759
+ # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
1760
+ # non-escaping:
2035
1761
  #
2036
1762
  # - 3b: attach a `dynamic_origin` `closure_escape` fact to the
2037
1763
  # post-call scope so consumers can see that the closure may
@@ -2045,19 +1771,15 @@ module Rigor
2045
1771
  # only reads (without writing) are also untouched: read-only
2046
1772
  # captures cannot rebind the outer variable.
2047
1773
  #
2048
- # A `:non_escaping` classification (or any block-less call)
2049
- # leaves the post-call scope unchanged.
1774
+ # A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
2050
1775
  def record_closure_escape_if_any(node)
2051
- # ADR-57 slice 3 work-item 1: an escaping block can also be attached
2052
- # to a RECEIVER call in a chain rather than to `node` itself the
2053
- # canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
2054
- # .parse!(argv)` idiom, where the content-mutating block hangs off
2055
- # `OptionParser.new` but the statement-level call node is the chained
2056
- # `.parse!`. A receiver call is evaluated as an expression, never as a
2057
- # statement, so its block never reaches this escape handler on its own.
2058
- # Fold each escaping receiver-chain block's content widening into the
2059
- # continuation here so the captured collection is floored regardless of
2060
- # how deep in the receiver chain its mutating block lives.
1776
+ # ADR-57 slice 3 work-item 1: an escaping block can also be attached to a RECEIVER call in a chain rather than
1777
+ # to `node` itself the canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end .parse!(argv)` idiom,
1778
+ # where the content-mutating block hangs off `OptionParser.new` but the statement-level call node is the chained
1779
+ # `.parse!`. A receiver call is evaluated as an expression, never as a statement, so its block never reaches
1780
+ # this escape handler on its own. Fold each escaping receiver-chain block's content widening into the
1781
+ # continuation here so the captured collection is floored regardless of how deep in the receiver chain its
1782
+ # mutating block lives.
2061
1783
  post_scope = widen_escaping_receiver_chain_captures(node, scope)
2062
1784
 
2063
1785
  return post_scope unless node.block.is_a?(Prism::BlockNode)
@@ -2078,15 +1800,13 @@ module Rigor
2078
1800
  )
2079
1801
  end
2080
1802
 
2081
- # Floor each caller-argument local whose matching parameter the resolved
2082
- # callee escape-mutates (see the call-site comment). Only self-dispatch
2083
- # calls resolving to a discovered user def are considered; the per-def
2084
- # "which parameters escape-mutate" set is memoised on the def node.
1803
+ # Floor each caller-argument local whose matching parameter the resolved callee escape-mutates (see the call-site
1804
+ # comment). Only self-dispatch calls resolving to a discovered user def are considered; the per-def "which
1805
+ # parameters escape-mutate" set is memoised on the def node.
2085
1806
  def widen_callee_escaped_argument_captures(node, base_scope)
2086
1807
  # Apply to the statement call AND every call in its receiver chain: the
2087
- # `build_option_parser(options).parse!(argv)` idiom puts the escape-
2088
- # mutating helper call in the RECEIVER position, where its argument is
2089
- # never the statement node's own argument.
1808
+ # `build_option_parser(options).parse!(argv)` idiom puts the escape- mutating helper call in the RECEIVER
1809
+ # position, where its argument is never the statement node's own argument.
2090
1810
  acc = floor_callee_escaped_args_for_call(node, base_scope)
2091
1811
  receiver = node.receiver
2092
1812
  while receiver.is_a?(Prism::CallNode)
@@ -2098,25 +1818,31 @@ module Rigor
2098
1818
 
2099
1819
  def floor_callee_escaped_args_for_call(node, base_scope)
2100
1820
  return base_scope unless self_dispatch_call?(node)
2101
- # Fast path — the floor only ever touches a local passed as an
2102
- # argument, so a call with no arguments cannot floor anything. Skip the
2103
- # def resolution + body scan entirely (the overwhelming common case).
1821
+ # Fast path — the floor only ever touches a local passed as an argument, so a call with no arguments cannot
1822
+ # floor anything. Skip the def resolution + body scan entirely (the overwhelming common case).
2104
1823
  return base_scope unless call_passes_local_argument?(node)
2105
1824
 
2106
1825
  def_node = resolve_self_callee_def(node)
2107
1826
  return base_scope if def_node.nil?
2108
1827
 
2109
- escaped = escaped_content_parameters(def_node)
2110
- return base_scope if escaped.empty?
1828
+ mutated = callee_content_mutated_parameters(def_node)
1829
+ return base_scope if mutated.empty?
1830
+
1831
+ floor_arguments_at_positions(node, mutated, base_scope)
1832
+ end
2111
1833
 
2112
- floor_arguments_at_positions(node, escaped, base_scope)
1834
+ # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
1835
+ # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
1836
+ # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
1837
+ # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
1838
+ def callee_content_mutated_parameters(def_node)
1839
+ cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
1840
+ cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
2113
1841
  end
2114
1842
 
2115
- # The user def a self-dispatch `node` resolves to in the enclosing class,
2116
- # or nil. Reuses the discovery index `Scope#user_def_for` reads; no
2117
- # ancestor walk (the boundary-escape idiom is same-class), keeping this
2118
- # off the hot path for the overwhelming majority of self-calls that
2119
- # resolve to nothing escaping.
1843
+ # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
1844
+ # `Scope#user_def_for` reads; no ancestor walk (the boundary-escape idiom is same-class), keeping this off the hot
1845
+ # path for the overwhelming majority of self-calls that resolve to nothing escaping.
2120
1846
  def resolve_self_callee_def(node)
2121
1847
  class_name = enclosing_class_name_for(scope.self_type)
2122
1848
  return scope.top_level_def_for(node.name) if class_name.nil?
@@ -2130,11 +1856,10 @@ module Rigor
2130
1856
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
2131
1857
  end
2132
1858
 
2133
- # The set of `[name, position]` parameters of `def_node` whose content a
2134
- # block in the body escape-mutates. Memoised per def node (the body walk
2135
- # is otherwise repeated at every call site). A parameter is "escape-
2136
- # mutated" when a `param[k] = v` / `param << x` mutation on it appears
2137
- # inside a block whose receiving call is not proven non-escaping.
1859
+ # The set of `[name, position]` parameters of `def_node` whose content a block in the body escape-mutates.
1860
+ # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
1861
+ # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
1862
+ # non-escaping.
2138
1863
  def escaped_content_parameters(def_node)
2139
1864
  cache = (@escaped_param_cache ||= {}.compare_by_identity)
2140
1865
  cache[def_node] ||= compute_escaped_content_parameters(def_node)
@@ -2156,15 +1881,48 @@ module Rigor
2156
1881
  positions.slice(*mutated)
2157
1882
  end
2158
1883
 
2159
- # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
2160
- # non-escaping verdict, used when scanning a callee body where the block-
2161
- # owning call's receiver TYPE is not available. A call whose method name
2162
- # is a known structural iterator (`each` / `map` / `tap` / …) runs its
2163
- # block synchronously and does not retain it, so its captured mutations
2164
- # are not a cross-boundary escape. Any other name (`on`, `subscribe`,
2165
- # `define_method`, an unknown DSL hook) is treated as escaping — sound,
2166
- # since mis-classifying a truly-non-escaping call only floors an argument
2167
- # that was about to be precise.
1884
+ # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
1885
+ # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
1886
+ # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
1887
+ # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
1888
+ # merely shadows the parameter so we never floor a caller argument the callee did not actually mutate.
1889
+ def direct_content_parameters(def_node)
1890
+ cache = (@direct_param_cache ||= {}.compare_by_identity)
1891
+ cache[def_node] ||= compute_direct_content_parameters(def_node)
1892
+ end
1893
+
1894
+ def compute_direct_content_parameters(def_node)
1895
+ positions = positional_parameter_positions(def_node)
1896
+ return {} if positions.empty?
1897
+
1898
+ mutated = Set.new
1899
+ each_node_outside_nested_scopes(def_node.body) do |descendant|
1900
+ name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
1901
+ mutated << name if !name.nil? && positions.key?(name)
1902
+ end
1903
+ positions.slice(*mutated)
1904
+ end
1905
+
1906
+ # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
1907
+ # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
1908
+ # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
1909
+ def each_node_outside_nested_scopes(node, &)
1910
+ return if node.nil?
1911
+
1912
+ yield node
1913
+ node.compact_child_nodes.each do |child|
1914
+ next if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
1915
+
1916
+ each_node_outside_nested_scopes(child, &)
1917
+ end
1918
+ end
1919
+
1920
+ # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
1921
+ # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
1922
+ # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
1923
+ # captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
1924
+ # unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
1925
+ # argument that was about to be precise.
2168
1926
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
2169
1927
  ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
2170
1928
  ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
@@ -2179,9 +1937,8 @@ module Rigor
2179
1937
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
2180
1938
  end
2181
1939
 
2182
- # `{ name => position }` for the required / optional positional
2183
- # parameters of a def. Keyword / rest / block parameters are skipped —
2184
- # the boundary-escape idiom passes a plain positional collection.
1940
+ # `{ name => position }` for the required / optional positional parameters of a def. Keyword / rest / block
1941
+ # parameters are skipped the boundary-escape idiom passes a plain positional collection.
2185
1942
  def positional_parameter_positions(def_node)
2186
1943
  params = def_node.parameters
2187
1944
  return {} if params.nil?
@@ -2194,10 +1951,9 @@ module Rigor
2194
1951
  positions
2195
1952
  end
2196
1953
 
2197
- # True when at least one argument of `node` is a bare local-variable read
2198
- # (positional or keyword value) bound in the current scope a cheap
2199
- # pre-filter so the def resolution / body scan only runs for calls that
2200
- # could actually floor something.
1954
+ # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
1955
+ # the current scope a cheap pre-filter so the def resolution / body scan only runs for calls that could actually
1956
+ # floor something.
2201
1957
  def call_passes_local_argument?(node)
2202
1958
  args = node.arguments
2203
1959
  return false unless args.respond_to?(:arguments)
@@ -2232,12 +1988,10 @@ module Rigor
2232
1988
  end
2233
1989
  end
2234
1990
 
2235
- # Walk the receiver chain of `node` and fold the escaping-content
2236
- # widening of every block-bearing, escaping receiver call into
2237
- # `base_scope`. Only receiver calls are walked `node` itself is handled
2238
- # by the caller. A `:non_escaping` receiver block is left to slice C's
2239
- # non-escaping write-back (which the receiver expression evaluation
2240
- # already drives), so we only floor the escaping / unknown ones here.
1991
+ # Walk the receiver chain of `node` and fold the escaping-content widening of every block-bearing, escaping
1992
+ # receiver call into `base_scope`. Only receiver calls are walked — `node` itself is handled by the caller. A
1993
+ # `:non_escaping` receiver block is left to slice C's non-escaping write-back (which the receiver expression
1994
+ # evaluation already drives), so we only floor the escaping / unknown ones here.
2241
1995
  def widen_escaping_receiver_chain_captures(node, base_scope)
2242
1996
  receiver = node.receiver
2243
1997
  acc = base_scope
@@ -2251,28 +2005,23 @@ module Rigor
2251
2005
  acc
2252
2006
  end
2253
2007
 
2254
- # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
2255
- # An escaping / unknown block that CONTENT-mutates a captured outer
2256
- # local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
2257
- # stored proc) previously left that local's content untouched only its
2258
- # narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
2259
- # survived and its element fold (`options[:format]` -> `"text"`,
2260
- # `s.empty?` -> `true`) was unsoundly precise.
2008
+ # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks). An escaping / unknown block that
2009
+ # CONTENT-mutates a captured outer local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a stored
2010
+ # proc) previously left that local's content untouched only its narrowing was dropped, so a constant seed
2011
+ # (`options = {}`, `s = ""`) survived and its element fold (`options[:format]` -> `"text"`, `s.empty?` -> `true`)
2012
+ # was unsoundly precise.
2261
2013
  #
2262
- # An escaping block may run later and any number of times, so joining a
2263
- # bounded evidence set is not sound (unlike slice C's non-escaping
2264
- # join): the sound continuation is the bare-collection floor Array ->
2265
- # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
2266
- # `String`. The seed's element/key/value precision is forgotten; only
2267
- # the carrier survives. Read-only captures and locals the block merely
2268
- # rebinds (already floored by `drop_captured_narrowing`) are untouched.
2014
+ # An escaping block may run later and any number of times, so joining a bounded evidence set is not sound (unlike
2015
+ # slice C's non-escaping join): the sound continuation is the bare-collection floor — Array ->
2016
+ # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String -> `String`. The seed's element/key/value
2017
+ # precision is forgotten; only the carrier survives. Read-only captures and locals the block merely rebinds
2018
+ # (already floored by `drop_captured_narrowing`) are untouched.
2269
2019
  def widen_escaping_content_captures(block_node, post_scope)
2270
2020
  body = block_node.body
2271
2021
  return post_scope if body.nil?
2272
2022
 
2273
- # Transitive case first: the body may content-mutate a captured local
2274
- # through a self-call rather than a direct `local[k] = v` write, which
2275
- # `collect_content_mutations` cannot see (see below).
2023
+ # Transitive case first: the body may content-mutate a captured local through a self-call rather than a direct
2024
+ # `local[k] = v` write, which `collect_content_mutations` cannot see (see below).
2276
2025
  post_scope = floor_block_body_callee_escaped_args(body, post_scope)
2277
2026
 
2278
2027
  mutations = collect_content_mutations(body)
@@ -2284,20 +2033,15 @@ module Rigor
2284
2033
  end
2285
2034
  end
2286
2035
 
2287
- # Inside an ESCAPING block body, a captured outer local can be content-
2288
- # mutated transitively: the body is (or contains) a self-call that
2289
- # escape-mutates one of its arguments. The canonical shape is the CLI's
2290
- # own `OptionParser.new { |opts| define_options(opts, options) }` — the
2291
- # block body is a bare `define_options(opts, options)` whose `options`
2292
- # parameter is escape-mutated inside ITS nested `opts.on { options[:k] =
2293
- # v }` blocks. `collect_content_mutations` only sees direct `local[k] =
2294
- # v` writes in THIS body, so it misses the transitive write and the
2295
- # captured Hash keeps its literal-false seed (folding the caller's
2296
- # `options[:mutation]` guard to an always-falsey constant). Reuse the
2297
- # cross-method-boundary callee-escaped-argument floor (the same gate the
2298
- # receiver-chain path uses at the call site) on every self-call in the
2299
- # body. Sound — only ever floors a captured local passed as an argument
2300
- # to a callee that demonstrably escape-mutates the matching parameter.
2036
+ # Inside an ESCAPING block body, a captured outer local can be content- mutated transitively: the body is (or
2037
+ # contains) a self-call that escape-mutates one of its arguments. The canonical shape is the CLI's own
2038
+ # `OptionParser.new { |opts| define_options(opts, options) }` the block body is a bare `define_options(opts,
2039
+ # options)` whose `options` parameter is escape-mutated inside ITS nested `opts.on { options[:k] = v }` blocks.
2040
+ # `collect_content_mutations` only sees direct `local[k] = v` writes in THIS body, so it misses the transitive
2041
+ # write and the captured Hash keeps its literal-false seed (folding the caller's `options[:mutation]` guard to an
2042
+ # always-falsey constant). Reuse the cross-method-boundary callee-escaped-argument floor (the same gate the
2043
+ # receiver-chain path uses at the call site) on every self-call in the body. Sound only ever floors a captured
2044
+ # local passed as an argument to a callee that demonstrably escape-mutates the matching parameter.
2301
2045
  def floor_block_body_callee_escaped_args(body, post_scope)
2302
2046
  acc = post_scope
2303
2047
  Source::NodeWalker.each(body) do |descendant|
@@ -2306,9 +2050,8 @@ module Rigor
2306
2050
  acc
2307
2051
  end
2308
2052
 
2309
- # The Dynamic-floor carrier for a content-mutated escaping capture, or
2310
- # nil when the pre-state is not a recognised mutable collection (leave
2311
- # it alone — e.g. an already-`Dynamic` binding or an unknown shape).
2053
+ # The Dynamic-floor carrier for a content-mutated escaping capture, or nil when the pre-state is not a recognised
2054
+ # mutable collection (leave it alone e.g. an already-`Dynamic` binding or an unknown shape).
2312
2055
  def content_floor_for(type)
2313
2056
  return nil if type.nil?
2314
2057
 
@@ -2334,14 +2077,10 @@ module Rigor
2334
2077
  :unknown
2335
2078
  end
2336
2079
 
2337
- # Sub-phase 3c. Replace the outer-local types that the block
2338
- # body can rebind with `Dynamic[Top]`. The conservative drop
2339
- # matches the spec line "facts about locals it can write
2340
- # become unstable after the escape point": rather than
2341
- # synthesise the union of the block's write types (which the
2342
- # current pass does not yet expose), we discard the narrowed
2343
- # binding altogether. A future sub-phase MAY refine this to
2344
- # the union of the block's actual writes.
2080
+ # Sub-phase 3c. Replace the outer-local types that the block body can rebind with `Dynamic[Top]`. The conservative
2081
+ # drop matches the spec line "facts about locals it can write become unstable after the escape point": rather than
2082
+ # synthesise the union of the block's write types (which the current pass does not yet expose), we discard the
2083
+ # narrowed binding altogether. A future sub-phase MAY refine this to the union of the block's actual writes.
2345
2084
  def drop_captured_narrowing(block_node, base_scope)
2346
2085
  names = captured_local_writes(block_node, base_scope)
2347
2086
  return base_scope if names.empty?
@@ -2349,13 +2088,11 @@ module Rigor
2349
2088
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
2350
2089
  end
2351
2090
 
2352
- # Names of outer locals the block body can REBIND, across every
2353
- # local-write form: plain `=` (`LocalVariableWriteNode`), the
2354
- # operator / `||=` / `&&=` compound forms, and a multi-assign target
2355
- # (`x, y = ...` `LocalVariableTargetNode` under `MultiWriteNode`).
2356
- # Block-introduced names (parameters, numbered params, `;`-locals) and
2357
- # names not bound in the outer scope are excluded — a write to either
2358
- # is not a captured rebind of an outer variable.
2091
+ # Names of outer locals the block body can REBIND, across every local-write form: plain `=`
2092
+ # (`LocalVariableWriteNode`), the operator / `||=` / `&&=` compound forms, and a multi-assign target (`x, y = ...`
2093
+ # `LocalVariableTargetNode` under `MultiWriteNode`). Block-introduced names (parameters, numbered params,
2094
+ # `;`-locals) and names not bound in the outer scope are excluded — a write to either is not a captured rebind of
2095
+ # an outer variable.
2359
2096
  LOCAL_WRITE_NODES = [
2360
2097
  Prism::LocalVariableWriteNode,
2361
2098
  Prism::LocalVariableOperatorWriteNode,
@@ -2381,18 +2118,14 @@ module Rigor
2381
2118
  outer_writes.uniq
2382
2119
  end
2383
2120
 
2384
- # ADR-56 slice A. For a `:non_escaping` block, fold the continuation
2385
- # binding of every outer local the body can rebind back into
2386
- # `post_scope`. The binding is a capped fixpoint (cap 3) over the
2387
- # block body re-evaluated under the running per-name assumption,
2388
- # joined with the pre-call binding (kept as a constituent so the
2389
- # 0-iteration path — `[].each { … }` — stays sound), value-pinned-
2390
- # widened on the final permitted iteration, and floored to
2121
+ # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
2122
+ # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
2123
+ # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
2124
+ # `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
2391
2125
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2392
2126
  #
2393
- # Fast path: a block writing no outer local leaves `post_scope`
2394
- # byte-identical (the overwhelming majority of blocks), so this costs
2395
- # one extra `captured_local_writes` walk and nothing else.
2127
+ # Fast path: a block writing no outer local leaves `post_scope` byte-identical (the overwhelming majority of
2128
+ # blocks), so this costs one extra `captured_local_writes` walk and nothing else.
2396
2129
  def write_back_block_captures(call_node, post_scope)
2397
2130
  block = call_node.block
2398
2131
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2412,20 +2145,16 @@ module Rigor
2412
2145
  result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
2413
2146
  end
2414
2147
 
2415
- # ADR-56 slice C — receiver-content element-type join. After the
2416
- # rebind write-back and `MutationWidening.widen_after_block` (which
2417
- # forgets a content-mutated collection's literal arity but keeps only
2418
- # the SEED's element types), join the appended/stored element / key /
2419
- # value types INTO the continuation collection's parameter, so
2420
- # `out = [0]; arr.each { |x| out << x }` types `out` as
2421
- # `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
2422
- # under-approximation: the runtime array is `[0, 1, 2, 3]`).
2148
+ # ADR-56 slice C — receiver-content element-type join. After the rebind write-back and
2149
+ # `MutationWidening.widen_after_block` (which forgets a content-mutated collection's literal arity but keeps only
2150
+ # the SEED's element types), join the appended/stored element / key / value types INTO the continuation
2151
+ # collection's parameter, so `out = [0]; arr.each { |x| out << x }` types `out` as `Array[0 | Integer]` (sound)
2152
+ # rather than `Array[0]` (the B1 under-approximation: the runtime array is `[0, 1, 2, 3]`).
2423
2153
  #
2424
- # Pre-state is read from `post_scope` so a local that is BOTH rebound
2425
- # and content-mutated composes: the rebind fixpoint result feeds the
2426
- # content join. The block body is typed once for argument evidence;
2427
- # the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
2428
- # sound empty-seed behaviour). Always sound — only ever widens.
2154
+ # Pre-state is read from `post_scope` so a local that is BOTH rebound and content-mutated composes: the rebind
2155
+ # fixpoint result feeds the content join. The block body is typed once for argument evidence; the floor is
2156
+ # `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the sound empty-seed behaviour). Always sound — only ever
2157
+ # widens.
2429
2158
  def content_writeback_block_captures(call_node, post_scope)
2430
2159
  block = call_node.block
2431
2160
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2444,13 +2173,11 @@ module Rigor
2444
2173
  end
2445
2174
  end
2446
2175
 
2447
- # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
2448
- # the return is the memo object after the block has mutated it through
2449
- # the `acc` alias. Compute the joined memo type the same way captured-
2450
- # local content mutations are joined: pre-state = the memo argument's
2451
- # type, added evidence = the content-mutator args on the memo block
2452
- # param. Returns `call_type` unchanged for any other call, a missing
2453
- # block, or a memo whose pre-state is not a collection.
2176
+ # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
2177
+ # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured- local content
2178
+ # mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
2179
+ # memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
2180
+ # is not a collection.
2454
2181
  def each_with_object_return(call_node, call_type)
2455
2182
  return call_type unless call_node.name == :each_with_object
2456
2183
 
@@ -2466,8 +2193,8 @@ module Rigor
2466
2193
  body = block.body
2467
2194
  return call_type if body.nil?
2468
2195
 
2469
- # The memo alias is a block-local (depth 0) — collect content
2470
- # mutations on it directly rather than via the captured-local walk.
2196
+ # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
2197
+ # captured-local walk.
2471
2198
  calls = body_content_mutations_on(body, memo_param)
2472
2199
  return call_type if calls.empty?
2473
2200
 
@@ -2477,9 +2204,8 @@ module Rigor
2477
2204
  joined || call_type
2478
2205
  end
2479
2206
 
2480
- # The name of the memo block parameter (the SECOND positional param of
2481
- # an `each_with_object` block), or nil when the block does not bind a
2482
- # second positional param.
2207
+ # The name of the memo block parameter (the SECOND positional param of an `each_with_object` block), or nil when
2208
+ # the block does not bind a second positional param.
2483
2209
  def each_with_object_memo_param(block)
2484
2210
  params_root = block.parameters
2485
2211
  return nil unless params_root.is_a?(Prism::BlockParametersNode)
@@ -2494,8 +2220,7 @@ module Rigor
2494
2220
  second.respond_to?(:name) ? second.name : nil
2495
2221
  end
2496
2222
 
2497
- # Content-mutator calls on a block-local receiver `var_name`
2498
- # (depth 0) within `body`.
2223
+ # Content-mutator calls on a block-local receiver `var_name` (depth 0) within `body`.
2499
2224
  def body_content_mutations_on(body, var_name)
2500
2225
  calls = []
2501
2226
  Source::NodeWalker.each(body) do |descendant|
@@ -2511,15 +2236,14 @@ module Rigor
2511
2236
  calls
2512
2237
  end
2513
2238
 
2514
- # Joins content evidence for a memo / param given its pre-state and a
2515
- # list of mutator calls, dispatching Array vs Hash by the mutator set.
2239
+ # Joins content evidence for a memo / param given its pre-state and a list of mutator calls, dispatching Array vs
2240
+ # Hash by the mutator set.
2516
2241
  def join_content_for_param(calls, pre_state, block_entry)
2517
2242
  return nil if pre_state.nil?
2518
2243
 
2519
2244
  if stringish?(pre_state)
2520
- # String carries no element parameter; mutating `<<`/`concat`
2521
- # makes the constant value unsound (`s = "a"; s << x` runtime
2522
- # `"a…"`), so widen to the nominal base. Sound — only widens.
2245
+ # String carries no element parameter; mutating `<<`/`concat` makes the constant value unsound (`s = "a"; s <<
2246
+ # x` runtime `"a"`), so widen to the nominal base. Sound — only widens.
2523
2247
  Type::Combinator.nominal_of("String")
2524
2248
  elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2525
2249
  join_hash_param(calls, pre_state, block_entry)
@@ -2539,9 +2263,8 @@ module Rigor
2539
2263
  return nil unless arrayish?(pre_state)
2540
2264
 
2541
2265
  added = calls.flat_map do |c|
2542
- # Index-write on an array (`a[i] += v`) introduces no new element
2543
- # evidence we can cheaply attribute the array-arity forget
2544
- # already widened the binding; contribute nothing.
2266
+ # Index-write on an array (`a[i] += v`) introduces no new element evidence we can cheaply attribute — the
2267
+ # array-arity forget already widened the binding; contribute nothing.
2545
2268
  next [] if index_write?(c)
2546
2269
 
2547
2270
  MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
@@ -2549,11 +2272,9 @@ module Rigor
2549
2272
  MutationWidening.join_array_content(pre_state, added)
2550
2273
  end
2551
2274
 
2552
- # Walks the block body for content-mutator calls (`<<`, `push`,
2553
- # `[]=`, ) whose receiver is a captured outer local (depth >= 1),
2554
- # returning `{ name => [call_node, ...] }`. Mirrors the
2555
- # `MutationWidening.widen_after_block` walk (descends into nested
2556
- # blocks; the depth check keeps nested block-locals out).
2275
+ # Walks the block body for content-mutator calls (`<<`, `push`, `[]=`, …) whose receiver is a captured outer local
2276
+ # (depth >= 1), returning `{ name => [call_node, ...] }`. Mirrors the `MutationWidening.widen_after_block` walk
2277
+ # (descends into nested blocks; the depth check keeps nested block-locals out).
2557
2278
  def collect_content_mutations(body)
2558
2279
  mutations = Hash.new { |h, k| h[k] = [] }
2559
2280
  Source::NodeWalker.each(body) do |descendant|
@@ -2563,11 +2284,9 @@ module Rigor
2563
2284
  mutations
2564
2285
  end
2565
2286
 
2566
- # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
2567
- # multi-assign target) that mutate a collection's CONTENT without a
2568
- # `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
2569
- # OrWrite even though the appended values land on the nested array —
2570
- # leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2287
+ # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a multi-assign target) that mutate a collection's
2288
+ # CONTENT without a `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the OrWrite even though the
2289
+ # appended values land on the nested array — leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2571
2290
  INDEX_WRITE_NODES = [
2572
2291
  Prism::IndexOrWriteNode,
2573
2292
  Prism::IndexAndWriteNode,
@@ -2576,10 +2295,8 @@ module Rigor
2576
2295
  ].freeze
2577
2296
  private_constant :INDEX_WRITE_NODES
2578
2297
 
2579
- # `[receiver_name, node]` when `node` is a content mutation whose
2580
- # receiver is a local variable satisfying `accept` (depth predicate),
2581
- # else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
2582
- # index-write node forms.
2298
+ # `[receiver_name, node]` when `node` is a content mutation whose receiver is a local variable satisfying `accept`
2299
+ # (depth predicate), else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the index-write node forms.
2583
2300
  def content_mutation_target(node)
2584
2301
  is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2585
2302
  return [nil, nil] unless is_call_mutator || index_write?(node)
@@ -2591,11 +2308,9 @@ module Rigor
2591
2308
  [receiver.name, node]
2592
2309
  end
2593
2310
 
2594
- # Computes the joined continuation collection type for one captured
2595
- # local from its content-mutator calls. Returns `nil` (no overlay)
2596
- # when the pre-state is neither an Array-ish nor a Hash-ish binding
2597
- # e.g. a String accumulator, whose `<<` carries no element parameter
2598
- # and whose binding already types as `String`.
2311
+ # Computes the joined continuation collection type for one captured local from its content-mutator calls. Returns
2312
+ # `nil` (no overlay) when the pre-state is neither an Array-ish nor a Hash-ish binding — e.g. a String
2313
+ # accumulator, whose `<<` carries no element parameter and whose binding already types as `String`.
2599
2314
  def join_content_for_local(name, calls, post_scope, block_entry)
2600
2315
  join_content_for_param(calls, post_scope.local(name), block_entry)
2601
2316
  end
@@ -2633,11 +2348,9 @@ module Rigor
2633
2348
  (type.is_a?(Type::Nominal) && type.class_name == "String")
2634
2349
  end
2635
2350
 
2636
- # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
2637
- # an index-write node (`h[k] ||= v`), typed in the block-entry scope.
2638
- # For an index-write the stored value is opaque (the appended values
2639
- # often land on a NESTED collection via `h[k] << v`), so the value is
2640
- # floored to `untyped` — sound: it only ever widens the value param.
2351
+ # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or an index-write node (`h[k] ||= v`), typed in
2352
+ # the block-entry scope. For an index-write the stored value is opaque (the appended values often land on a NESTED
2353
+ # collection via `h[k] << v`), so the value is floored to `untyped` — sound: it only ever widens the value param.
2641
2354
  # Returns `[]` for other forms.
2642
2355
  def hash_pair_types(node, block_entry)
2643
2356
  if index_write?(node)
@@ -2664,10 +2377,9 @@ module Rigor
2664
2377
  nil
2665
2378
  end
2666
2379
 
2667
- # Argument types for a content-mutator call, typed against the
2668
- # block-entry scope (block params bound). A sub-evaluator over
2669
- # `block_entry` keeps the argument typing flow-correct for params /
2670
- # `;`-locals without leaking into the outer scope.
2380
+ # Argument types for a content-mutator call, typed against the block-entry scope (block params bound). A
2381
+ # sub-evaluator over `block_entry` keeps the argument typing flow-correct for params / `;`-locals without leaking
2382
+ # into the outer scope.
2671
2383
  def content_arg_types(call_node, block_entry)
2672
2384
  arguments = call_node.arguments
2673
2385
  return [] if arguments.nil?
@@ -2677,10 +2389,9 @@ module Rigor
2677
2389
  []
2678
2390
  end
2679
2391
 
2680
- # Evaluates `block`'s body once with each written outer local bound to
2681
- # the supplied `bindings` (block params / `;`-locals re-bound as
2682
- # usual) and returns the per-name exit binding for `names`. Used as
2683
- # the `BodyFixpoint` body-evaluator.
2392
+ # Evaluates `block`'s body once with each written outer local bound to the supplied `bindings` (block params /
2393
+ # `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the `BodyFixpoint`
2394
+ # body-evaluator.
2684
2395
  def block_exit_bindings(call_node, block, bindings, names)
2685
2396
  entry = build_block_entry_scope(call_node, block)
2686
2397
  entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
@@ -2688,11 +2399,9 @@ module Rigor
2688
2399
  names.to_h { |name| [name, exit_scope.local(name)] }
2689
2400
  end
2690
2401
 
2691
- # Names introduced by the block itself (parameters, numbered
2692
- # parameters via `BlockParameterBinder`, plus explicit
2693
- # `;`-prefixed block-locals on `BlockParametersNode`). Writes
2694
- # to these names are local to the block and MUST NOT be
2695
- # treated as captured rebinds of an outer local.
2402
+ # Names introduced by the block itself (parameters, numbered parameters via `BlockParameterBinder`, plus explicit
2403
+ # `;`-prefixed block-locals on `BlockParametersNode`). Writes to these names are local to the block and MUST NOT
2404
+ # be treated as captured rebinds of an outer local.
2696
2405
  def block_introduced_locals(block_node)
2697
2406
  introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
2698
2407
  params_root = block_node.parameters
@@ -2700,34 +2409,24 @@ module Rigor
2700
2409
  introduced
2701
2410
  end
2702
2411
 
2703
- # `Prism::BlockNode` is reached through {#eval_call}; the
2704
- # handler runs the body under `scope`, which the caller has
2705
- # already augmented with the block's parameter bindings. Effects
2706
- # do not leak past the block (the outer eval_call returns the
2707
- # caller's scope unchanged), but the body's local writes are
2708
- # threaded through subsequent statements *inside* the block so
2709
- # `each { |x| sum = x; sum.succ }` types `sum.succ` under the
2710
- # `sum: x` binding.
2412
+ # `Prism::BlockNode` is reached through {#eval_call}; the handler runs the body under `scope`, which the caller
2413
+ # has already augmented with the block's parameter bindings. Effects do not leak past the block (the outer
2414
+ # eval_call returns the caller's scope unchanged), but the body's local writes are threaded through subsequent
2415
+ # statements *inside* the block so `each { |x| sum = x; sum.succ }` types `sum.succ` under the `sum: x` binding.
2711
2416
  def eval_block(node)
2712
2417
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2713
2418
 
2714
2419
  sub_eval(node.body, scope)
2715
2420
  end
2716
2421
 
2717
- # Builds the entry scope for a block body. The block sees the
2718
- # outer scope's locals (Ruby's lexical scoping rule) and adds
2719
- # bindings for every named block parameter on top. Parameter
2720
- # types come from the receiving method's RBS signature when
2721
- # one is available; the rest default to `Dynamic[Top]`.
2422
+ # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
2423
+ # and adds bindings for every named block parameter on top. Parameter types come from the receiving method's RBS
2424
+ # signature when one is available; the rest default to `Dynamic[Top]`.
2722
2425
  #
2723
- # `;`-prefixed block-locals (`do |i; x|`) are bound to
2724
- # `Constant[nil]` so the inner read shadows any outer
2725
- # `x` per Ruby's semantics at runtime the block-local is
2726
- # a fresh nil-valued variable on every block invocation.
2727
- # Without this shadow, an inner `x.even?` before the first
2728
- # write would type-check against the OUTER `x` (e.g.
2729
- # `Integer`) when the runtime would actually `NoMethodError`
2730
- # on `nil`.
2426
+ # `;`-prefixed block-locals (`do |i; x|`) are bound to `Constant[nil]` so the inner read shadows any outer `x` per
2427
+ # Ruby's semantics — at runtime the block-local is a fresh nil-valued variable on every block invocation. Without
2428
+ # this shadow, an inner `x.even?` before the first write would type-check against the OUTER `x` (e.g. `Integer`)
2429
+ # when the runtime would actually `NoMethodError` on `nil`.
2731
2430
  def build_block_entry_scope(call_node, block_node)
2732
2431
  expected = expected_block_param_types_for(call_node)
2733
2432
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
@@ -2771,12 +2470,10 @@ module Rigor
2771
2470
  def eval_class_body(node, new_context)
2772
2471
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2773
2472
 
2774
- # Class/module bodies run in a fresh scope: the outer scope's
2775
- # locals are NOT visible inside `class Foo; ... end`. We keep
2776
- # the same Environment so RBS lookups continue to work, and
2777
- # simply drop the locals. Slice A-engine: `self` inside a
2778
- # class body is the class object itself, so we set
2779
- # `self_type` to `Singleton[<qualified>]`.
2473
+ # Class/module bodies run in a fresh scope: the outer scope's locals are NOT visible inside `class Foo; ...
2474
+ # end`. We keep the same Environment so RBS lookups continue to work, and simply drop the locals. Slice
2475
+ # A-engine: `self` inside a class body is the class object itself, so we set `self_type` to
2476
+ # `Singleton[<qualified>]`.
2780
2477
  fresh = build_fresh_body_scope
2781
2478
  body_self = self_type_for_class_body(new_context)
2782
2479
  fresh = fresh.with_self_type(body_self) if body_self
@@ -2792,43 +2489,49 @@ module Rigor
2792
2489
  source_path: scope.source_path
2793
2490
  )
2794
2491
  bindings = binder.bind(def_node)
2795
- # ADR-67 WD3 — override an undeclared parameter with its call-site
2796
- # inferred type (precision-additive; an RBS-declared parameter wins,
2797
- # the table is empty on a normal `check` run). The inferred type lives
2798
- # only as a body local, never as an RBS contract, so it cannot fire a
2799
- # parameter-boundary diagnostic (WD1, satisfied by construction).
2492
+ # ADR-67 WD3 — override an undeclared parameter with its call-site inferred type (precision-additive; an
2493
+ # RBS-declared parameter wins, the table is empty on a normal `check` run). The inferred type lives only as a
2494
+ # body local, never as an RBS contract, so it cannot fire a parameter-boundary diagnostic (WD1, satisfied by
2495
+ # construction).
2800
2496
  bindings = seed_inferred_param_types(bindings, def_node, singleton)
2801
2497
 
2802
- # Method bodies do NOT see the outer scope's locals. They start
2803
- # from a fresh scope with the same environment, then receive
2804
- # the parameter bindings. Slice 7 phase 2: instance defs ALSO
2805
- # seed their `ivars` map from the class-level accumulator so
2806
- # `def get; @x; end` reads the type that a sibling
2807
- # `def init; @x = 1; end` wrote.
2498
+ # Method bodies do NOT see the outer scope's locals. They start from a fresh scope with the same environment,
2499
+ # then receive the parameter bindings. Slice 7 phase 2: instance defs ALSO seed their `ivars` map from the
2500
+ # class-level accumulator so `def get; @x; end` reads the type that a sibling `def init; @x = 1; end` wrote.
2808
2501
  fresh = build_fresh_body_scope
2809
2502
  body_self = self_type_for_method_body(singleton: singleton)
2810
2503
  fresh = fresh.with_self_type(body_self) if body_self
2811
2504
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2812
2505
  fresh = seed_class_cvars(fresh)
2813
2506
  fresh = seed_program_globals(fresh)
2814
- # ADR-48 Struct slice 3 — install the method body's fold-safe-local set
2815
- # so a member read off a mutation-free local folds during the in-body
2816
- # walk (the call-return inference path is seeded separately).
2507
+ # ADR-48 Struct slice 3 — install the method body's fold-safe-local set so a member read off a mutation-free
2508
+ # local folds during the in-body walk (the call-return inference path is seeded separately).
2817
2509
  fresh = fresh.with_struct_fold_safe(
2818
2510
  StructFoldSafety.fold_safe_locals(
2819
2511
  def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2820
2512
  )
2821
2513
  )
2822
- bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
2514
+ bindings.reduce(fresh) { |acc, (name, type)| bind_param(acc, name, type) }
2823
2515
  end
2824
2516
 
2825
- # ADR-67 WD3consult the call-site parameter-inference table for this
2826
- # `def` and replace each undeclared (untyped) parameter binding with its
2827
- # inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2828
- # from the lexical class path the same triple
2829
- # {Inference::ParameterInferenceCollector} records. An RBS-declared
2830
- # parameter (a non-untyped binding) always wins. No-op when the table is
2831
- # empty (the normal `check` path), so the seed is byte-identical there.
2517
+ # ADR-82 root-enrichmentbind a method parameter, and for an *undeclared* (untyped) parameter seed its
2518
+ # provenance to `inferred_return_untyped`. An untyped param is the archetypal inference gap ([ADR-67](
2519
+ # docs/adr/67-parameter-type-inference.md): no call-site type flows in), so a `x.foo` receiver on it should
2520
+ # route to parameter inference rather than reporting no cause at all. Reuses the WD1 `local_origins`
2521
+ # channel, so WD6 then carries the cause through any chain rooted at the parameter (`x.foo.bar`). An
2522
+ # RBS-declared / call-site-inferred parameter (a non-untyped binding) keeps no origin it is not a hole.
2523
+ def bind_param(acc, name, type)
2524
+ bound = acc.with_local(name, type)
2525
+ return bound unless untyped_binding?(type)
2526
+
2527
+ bound.with_local_origin(name, DynamicOrigin::INFERRED_RETURN_UNTYPED)
2528
+ end
2529
+
2530
+ # ADR-67 WD3 — consult the call-site parameter-inference table for this `def` and replace each undeclared
2531
+ # (untyped) parameter binding with its inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2532
+ # from the lexical class path — the same triple {Inference::ParameterInferenceCollector} records. An RBS-declared
2533
+ # parameter (a non-untyped binding) always wins. No-op when the table is empty (the normal `check` path), so the
2534
+ # seed is byte-identical there.
2832
2535
  def seed_inferred_param_types(bindings, def_node, singleton)
2833
2536
  inferred = scope.param_inferred_types
2834
2537
  return bindings if inferred.empty?
@@ -2846,8 +2549,8 @@ module Rigor
2846
2549
  merged
2847
2550
  end
2848
2551
 
2849
- # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
2850
- # undeclared parameter — the only bindings ADR-67 WD3 overrides.
2552
+ # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an undeclared parameter — the only
2553
+ # bindings ADR-67 WD3 overrides.
2851
2554
  def untyped_binding?(type)
2852
2555
  type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
2853
2556
  end
@@ -2861,20 +2564,17 @@ module Rigor
2861
2564
  seeded = scope.class_ivars_for(path)
2862
2565
  return body_scope if seeded.empty?
2863
2566
 
2864
- # ADR-58 WD1 — the class-ivar index unions every `@x = …` write across
2865
- # the class flow-insensitively, so a ctor `@x = nil` seed makes a read
2866
- # in a *different* method type `T | nil`. That `nil` is
2867
- # declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
2868
- # marks each seeded ivar: `possible-nil-receiver` then declines to fire
2869
- # on the cross-method invariant unless a method-local write or
2870
- # narrowing makes the nil flow-live (which drops the mark).
2567
+ # ADR-58 WD1 — the class-ivar index unions every `@x = …` write across the class flow-insensitively, so a ctor
2568
+ # `@x = nil` seed makes a read in a *different* method type `T | nil`. That `nil` is declaration-sourced, not
2569
+ # flow-live, so `seed_declaration_sourced_ivar` marks each seeded ivar: `possible-nil-receiver` then declines to
2570
+ # fire on the cross-method invariant unless a method-local write or narrowing makes the nil flow-live (which
2571
+ # drops the mark).
2871
2572
  seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
2872
2573
  end
2873
2574
 
2874
- # Cvars are visible from BOTH instance and singleton method
2875
- # bodies of the enclosing class, so this seed is unconditional
2876
- # (no `singleton:` gate). At the top-level (no class context)
2877
- # the accumulator is empty and the seed is a no-op.
2575
+ # Cvars are visible from BOTH instance and singleton method bodies of the enclosing class, so this seed is
2576
+ # unconditional (no `singleton:` gate). At the top-level (no class context) the accumulator is empty and the seed
2577
+ # is a no-op.
2878
2578
  def seed_class_cvars(body_scope)
2879
2579
  path = current_class_path
2880
2580
  return body_scope if path.nil?
@@ -2885,11 +2585,9 @@ module Rigor
2885
2585
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
2886
2586
  end
2887
2587
 
2888
- # Globals are process-wide. The body scope already inherited
2889
- # the program-globals accumulator through `with_program_globals`;
2890
- # seeding here just materialises each entry into the body's
2891
- # `globals` map so reads observe a precise type without
2892
- # consulting the accumulator on every lookup.
2588
+ # Globals are process-wide. The body scope already inherited the program-globals accumulator through
2589
+ # `with_program_globals`; seeding here just materialises each entry into the body's `globals` map so reads observe
2590
+ # a precise type without consulting the accumulator on every lookup.
2893
2591
  def seed_program_globals(body_scope)
2894
2592
  seeded = scope.program_globals
2895
2593
  return body_scope if seeded.empty?
@@ -2897,19 +2595,14 @@ module Rigor
2897
2595
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
2898
2596
  end
2899
2597
 
2900
- # Slice A-declarations. Class- and method-bodies start from a
2901
- # fresh local-empty scope, but they MUST keep the
2902
- # `declared_types` table visible at the outer scope so the
2903
- # ScopeIndexer-populated declaration overrides
2904
- # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
2905
- # remain reachable from inside nested bodies.
2598
+ # Slice A-declarations. Class- and method-bodies start from a fresh local-empty scope, but they MUST keep the
2599
+ # `declared_types` table visible at the outer scope so the ScopeIndexer-populated declaration overrides
2600
+ # (`Prism::ConstantReadNode` for `module Foo` headers, etc.) remain reachable from inside nested bodies.
2906
2601
  def build_fresh_body_scope
2907
- # Single allocation instead of a deep `with_*` chain — this runs
2908
- # per class/method body on the main walk, so the chain's throwaway
2909
- # intermediate Scopes were a top `Scope#rebuild` source (ADR-44).
2910
- # Local-empty by design; the discovery index is inherited whole by
2911
- # reference (ADR-53 Track A), so a table added to the index can no
2912
- # longer be dropped here by a missed per-field copy.
2602
+ # Single allocation instead of a deep `with_*` chain — this runs per class/method body on the main walk, so the
2603
+ # chain's throwaway intermediate Scopes were a top `Scope#rebuild` source (ADR-44). Local-empty by design; the
2604
+ # discovery index is inherited whole by reference (ADR-53 Track A), so a table added to the index can no longer
2605
+ # be dropped here by a missed per-field copy.
2913
2606
  Scope.new(
2914
2607
  environment: scope.environment,
2915
2608
  locals: {}.freeze,
@@ -2925,16 +2618,11 @@ module Rigor
2925
2618
  def_receiver_targets_lexical_self?(def_node.receiver)
2926
2619
  end
2927
2620
 
2928
- # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
2929
- # `module Meta`) explicit-receiver def that semantically
2930
- # equals `def self.bar` because the receiver constant
2931
- # resolves to `self` at the def-site. Matched against the
2932
- # current class context's tail to cover both the
2933
- # `def OpenURI.x` form (single segment) and the
2934
- # `def OpenURI::Meta.x` form (qualified path). Cross-class
2935
- # receivers (`def Bar.baz` inside `module Foo` where the
2936
- # receiver names a different constant) are not promoted to
2937
- # singleton at this slice.
2621
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside `module Meta`) — explicit-receiver def that
2622
+ # semantically equals `def self.bar` because the receiver constant resolves to `self` at the def-site. Matched
2623
+ # against the current class context's tail to cover both the `def OpenURI.x` form (single segment) and the `def
2624
+ # OpenURI::Meta.x` form (qualified path). Cross-class receivers (`def Bar.baz` inside `module Foo` where the
2625
+ # receiver names a different constant) are not promoted to singleton at this slice.
2938
2626
  def def_receiver_targets_lexical_self?(receiver)
2939
2627
  return false if @class_context.empty?
2940
2628
 
@@ -2953,18 +2641,16 @@ module Rigor
2953
2641
  end
2954
2642
  end
2955
2643
 
2956
- # Slice A-engine. Inside a class body `class Foo; ...; end`,
2957
- # `self` is the class object `Singleton[Foo]`. Returns nil
2958
- # at the top level (no enclosing class).
2644
+ # Slice A-engine. Inside a class body `class Foo; ...; end`, `self` is the class object — `Singleton[Foo]`.
2645
+ # Returns nil at the top level (no enclosing class).
2959
2646
  def self_type_for_class_body(class_context)
2960
2647
  return nil if class_context.empty?
2961
2648
 
2962
2649
  Type::Combinator.singleton_of(class_context.map(&:name).join("::"))
2963
2650
  end
2964
2651
 
2965
- # Slice A-engine. Inside a method body, `self` depends on
2966
- # whether the def is on the singleton or instance side of the
2967
- # surrounding class:
2652
+ # Slice A-engine. Inside a method body, `self` depends on whether the def is on the singleton or instance side of
2653
+ # the surrounding class:
2968
2654
  #
2969
2655
  # - `def self.foo` or any def inside `class << self`: self is
2970
2656
  # the class object → `Singleton[Foo]`.
@@ -2995,16 +2681,11 @@ module Rigor
2995
2681
  target = singleton_constant_target(node.expression)
2996
2682
  return @class_context unless target
2997
2683
 
2998
- # `class << Foo` inside `class Foo` (the canonical
2999
- # pattern in Ruby's own time.rb) is semantically
3000
- # `class << self` replace the enclosing frame with a
3001
- # singleton frame for the same name so method
3002
- # registration and `self_type` lookup land on Foo.
3003
- # When the target names a different constant (rare
3004
- # cross-class form), append a fresh singleton frame
3005
- # tagged with the target FQN; the bodies are scoped to
3006
- # that target rather than to the lexical enclosing
3007
- # class.
2684
+ # `class << Foo` inside `class Foo` (the canonical pattern in Ruby's own time.rb) is semantically `class <<
2685
+ # self` replace the enclosing frame with a singleton frame for the same name so method registration and
2686
+ # `self_type` lookup land on Foo. When the target names a different constant (rare cross-class form), append a
2687
+ # fresh singleton frame tagged with the target FQN; the bodies are scoped to that target rather than to the
2688
+ # lexical enclosing class.
3008
2689
  if !@class_context.empty? && @class_context.last.name == target
3009
2690
  outer = @class_context[0..-2]
3010
2691
  outer + [ClassFrame.new(name: target, singleton: true)]
@@ -3025,10 +2706,8 @@ module Rigor
3025
2706
  end
3026
2707
  end
3027
2708
 
3028
- # The qualified name of the immediately-enclosing class (joining
3029
- # every nested `ClassFrame` with `::`). Returns `nil` for a
3030
- # top-level def with no enclosing class, which routes the
3031
- # parameter binder past RBS lookup.
2709
+ # The qualified name of the immediately-enclosing class (joining every nested `ClassFrame` with `::`). Returns
2710
+ # `nil` for a top-level def with no enclosing class, which routes the parameter binder past RBS lookup.
3032
2711
  def current_class_path
3033
2712
  return nil if @class_context.empty?
3034
2713
 
@@ -3041,27 +2720,23 @@ module Rigor
3041
2720
 
3042
2721
  # ----- helpers -----
3043
2722
 
3044
- # Explicit `return value` (including `return` inside a block, which in
3045
- # Ruby returns from the *enclosing method*). The control-transfer value
3046
- # is `Bot` a `return` produces no value at its own position but the
3047
- # returned expression's type is recorded into the active return sink so
3048
- # the method-return inference joins it with the body's tail type.
3049
- # Returns inside a nested `def`/lambda are barriers: `eval_def` clears
3050
- # the sink around the nested body, so this handler only ever appends a
3051
- # return that genuinely exits the method currently being inferred.
2723
+ # Explicit `return value` (including `return` inside a block, which in Ruby returns from the *enclosing method*).
2724
+ # The control-transfer value is `Bot` a `return` produces no value at its own position — but the returned
2725
+ # expression's type is recorded into the active return sink so the method-return inference joins it with the
2726
+ # body's tail type. Returns inside a nested `def`/lambda are barriers: `eval_def` clears the sink around the
2727
+ # nested body, so this handler only ever appends a return that genuinely exits the method currently being
2728
+ # inferred.
3052
2729
  def eval_return(node)
3053
2730
  sink = Thread.current[RETURN_SINK_KEY]
3054
2731
  record_return_value(node, sink) if sink
3055
2732
  [Type::Combinator.bot, scope]
3056
2733
  end
3057
2734
 
3058
- # A `break` transfers control to the loop exit (its flow value is `Bot`,
3059
- # like `return`). It records the current scope into the active loop's
3060
- # break sink so the loop join can recover a `break`-path binding the
3061
- # fall-through would drop (`flag = true; break` -> `flag` is `false |
3062
- # true` after the loop). nil sink = a `break` not inside an inferred
3063
- # loop body (a block targeting a method, or top-level) — left to the
3064
- # existing escaping-block / no-op handling.
2735
+ # A `break` transfers control to the loop exit (its flow value is `Bot`, like `return`). It records the current
2736
+ # scope into the active loop's break sink so the loop join can recover a `break`-path binding the fall-through
2737
+ # would drop (`flag = true; break` -> `flag` is `false | true` after the loop). nil sink = a `break` not inside an
2738
+ # inferred loop body (a block targeting a method, or top-level) left to the existing escaping-block / no-op
2739
+ # handling.
3065
2740
  def eval_break(node)
3066
2741
  sink = Thread.current[BREAK_SINK_KEY]
3067
2742
  sink << [node, scope] if sink
@@ -3070,10 +2745,9 @@ module Rigor
3070
2745
 
3071
2746
  def record_return_value(node, sink)
3072
2747
  args = node.arguments&.arguments || []
3073
- # `return` with no argument returns nil; `return a` records the
3074
- # argument's type; `return a, b, c` packs a Tuple in Ruby a
3075
- # multi-value return yields the array `[a, b, c]`, so the inferred
3076
- # return contributes the corresponding Tuple element-by-element.
2748
+ # `return` with no argument returns nil; `return a` records the argument's type; `return a, b, c` packs a Tuple
2749
+ # in Ruby a multi-value return yields the array `[a, b, c]`, so the inferred return contributes the
2750
+ # corresponding Tuple element-by-element.
3077
2751
  if args.empty?
3078
2752
  sink << Type::Combinator.constant_of(nil)
3079
2753
  elsif args.size == 1
@@ -3095,16 +2769,12 @@ module Rigor
3095
2769
  ).evaluate(node)
3096
2770
  end
3097
2771
 
3098
- # Slice 7 phase 14 — branch exit detection. Returns true
3099
- # when the branch's body unconditionally exits the
3100
- # surrounding control flow through a `return`, `next`,
3101
- # `break`, or `raise`. Used by `eval_if` / `eval_unless`
3102
- # to narrow the post-scope: when one branch exits, the
3103
- # surrounding scope can carry the OTHER branch's edge
3104
- # forward without nil-injection.
2772
+ # Slice 7 phase 14 — branch exit detection. Returns true when the branch's body unconditionally exits the
2773
+ # surrounding control flow through a `return`, `next`, `break`, or `raise`. Used by `eval_if` / `eval_unless` to
2774
+ # narrow the post-scope: when one branch exits, the surrounding scope can carry the OTHER branch's edge forward
2775
+ # without nil-injection.
3105
2776
  #
3106
- # The detection is intentionally conservative — it
3107
- # recognises only the most common patterns:
2777
+ # The detection is intentionally conservative — it recognises only the most common patterns:
3108
2778
  # - A `Prism::ReturnNode`, `NextNode`, `BreakNode`.
3109
2779
  # - A `Prism::CallNode` whose name is `:raise` or `:throw`.
3110
2780
  # - A `Prism::StatementsNode`, `Prism::ParenthesesNode`, or
@@ -3141,18 +2811,12 @@ module Rigor
3141
2811
  end
3142
2812
  end
3143
2813
 
3144
- # ADR-24 WD6 / slice 3 — generalised terminating-branch
3145
- # detection. `branch_unconditionally_exits?` recognises a
3146
- # branch SYNTACTICALLY (return / next / break / a call
3147
- # named raise / throw / exit / abort / fail). A branch
3148
- # whose *inferred type is `Bot`* also terminates it
3149
- # cannot produce a value, so control never falls through
3150
- # it — regardless of how it is spelled. The canonical
3151
- # case is a resolved guard helper (`fail_with_message(...)`)
3152
- # whose body always raises: ADR-24 slice 1 types the call
3153
- # `bot`, and this OR-test makes `helper(...) if x.nil?`
3154
- # narrow exactly like `raise ... if x.nil?`. The branch
3155
- # type is already computed by `eval_if` / `eval_unless`.
2814
+ # ADR-24 WD6 / slice 3 — generalised terminating-branch detection. `branch_unconditionally_exits?` recognises a
2815
+ # branch SYNTACTICALLY (return / next / break / a call named raise / throw / exit / abort / fail). A branch whose
2816
+ # *inferred type is `Bot`* also terminates it cannot produce a value, so control never falls through it —
2817
+ # regardless of how it is spelled. The canonical case is a resolved guard helper (`fail_with_message(...)`) whose
2818
+ # body always raises: ADR-24 slice 1 types the call `bot`, and this OR-test makes `helper(...) if x.nil?` narrow
2819
+ # exactly like `raise ... if x.nil?`. The branch type is already computed by `eval_if` / `eval_unless`.
3156
2820
  def branch_terminates?(branch_node, branch_type)
3157
2821
  branch_unconditionally_exits?(branch_node) ||
3158
2822
  branch_type.is_a?(Type::Bot)
@@ -3164,12 +2828,9 @@ module Rigor
3164
2828
  sub_eval(branch_node, branch_scope)
3165
2829
  end
3166
2830
 
3167
- # Joins two branch scopes at a control-flow merge point. Names
3168
- # bound in only one branch are nil-injected into the other side
3169
- # so the joined scope sees them as `T | nil` rather than dropping
3170
- # them outright. This implements the contract the Slice 3 phase 1
3171
- # `Scope#join` documentation defers to the statement-level
3172
- # evaluator.
2831
+ # Joins two branch scopes at a control-flow merge point. Names bound in only one branch are nil-injected into the
2832
+ # other side so the joined scope sees them as `T | nil` rather than dropping them outright. This implements the
2833
+ # contract the Slice 3 phase 1 `Scope#join` documentation defers to the statement-level evaluator.
3173
2834
  def join_with_nil_injection(scope_a, scope_b)
3174
2835
  nil_const = Type::Combinator.constant_of(nil)
3175
2836
  a_keys = scope_a.locals.keys
@@ -3182,21 +2843,17 @@ module Rigor
3182
2843
  aug_a.join(aug_b)
3183
2844
  end
3184
2845
 
3185
- # Generalises {#join_with_nil_injection} to N branches (case/when,
3186
- # begin/rescue chain). The reduce order does not affect the
3187
- # result because nil-injection commutes with union under
3188
- # `Scope#join`.
2846
+ # Generalises {#join_with_nil_injection} to N branches (case/when, begin/rescue chain). The reduce order does not
2847
+ # affect the result because nil-injection commutes with union under `Scope#join`.
3189
2848
  def reduce_scopes_with_nil_injection(scopes)
3190
2849
  scopes.reduce { |a, b| join_with_nil_injection(a, b) }
3191
2850
  end
3192
2851
 
3193
- # ---------------------------------------------------------------
3194
- # rescue variable binding helpers
2852
+ # --------------------------------------------------------------- rescue variable binding helpers
3195
2853
  # ---------------------------------------------------------------
3196
2854
 
3197
- # Returns `scope` extended with the rescue reference variable bound
3198
- # to the exception instance type. Leaves scope unchanged when the
3199
- # node carries no reference (bare `rescue` without `=> var`).
2855
+ # Returns `scope` extended with the rescue reference variable bound to the exception instance type. Leaves scope
2856
+ # unchanged when the node carries no reference (bare `rescue` without `=> var`).
3200
2857
  def bind_rescue_reference(rescue_node, scope)
3201
2858
  ref = rescue_node.reference
3202
2859
  return scope unless ref.is_a?(Prism::LocalVariableTargetNode)
@@ -3204,11 +2861,9 @@ module Rigor
3204
2861
  scope.with_local(ref.name, rescue_exception_type(rescue_node, scope))
3205
2862
  end
3206
2863
 
3207
- # Derives the exception instance type for a `RescueNode`. When the
3208
- # exceptions list is empty (bare `rescue`) the type is
3209
- # `StandardError`. When one or more exception classes are named the
3210
- # types are unioned. Falls back to `StandardError` for any class
3211
- # that cannot be resolved to a `Singleton` type.
2864
+ # Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
2865
+ # type is `StandardError`. When one or more exception classes are named the types are unioned. Falls back to
2866
+ # `StandardError` for any class that cannot be resolved to a `Singleton` type.
3212
2867
  def rescue_exception_type(rescue_node, scope)
3213
2868
  exceptions = rescue_node.exceptions
3214
2869
  if exceptions.empty?
@@ -3222,20 +2877,18 @@ module Rigor
3222
2877
  end
3223
2878
  end
3224
2879
 
3225
- # ---------------------------------------------------------------
3226
- # `case/in` pattern variable binding helpers
2880
+ # --------------------------------------------------------------- `case/in` pattern variable binding helpers
3227
2881
  # ---------------------------------------------------------------
3228
2882
 
3229
- # Builds the entry scope for an `in` branch by injecting every
3230
- # variable captured by the pattern as a local binding.
2883
+ # Builds the entry scope for an `in` branch by injecting every variable captured by the pattern as a local
2884
+ # binding.
3231
2885
  def apply_in_pattern_bindings(subject, pattern, scope)
3232
2886
  bindings = collect_in_pattern_bindings(subject, pattern, scope)
3233
2887
  bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
3234
2888
  end
3235
2889
 
3236
- # Returns an array of `[Symbol, Rigor::Type]` pairs for every
3237
- # variable captured by `pattern`. Unrecognised pattern nodes
3238
- # contribute no bindings (fail-soft).
2890
+ # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`. Unrecognised pattern
2891
+ # nodes contribute no bindings (fail-soft).
3239
2892
  def collect_in_pattern_bindings(subject, pattern, scope)
3240
2893
  case pattern
3241
2894
  when Prism::CapturePatternNode
@@ -3288,11 +2941,9 @@ module Rigor
3288
2941
  bindings
3289
2942
  end
3290
2943
 
3291
- # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of
3292
- # the unmatched elements; bind `rest` to `Array[untyped]` rather
3293
- # than the previous bare `untyped`. Per-element typing waits on
3294
- # subject-aware element-type extraction (the binder doesn't see
3295
- # the case subject).
2944
+ # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of the unmatched elements; bind `rest` to
2945
+ # `Array[untyped]` rather than the previous bare `untyped`. Per-element typing waits on subject-aware element-type
2946
+ # extraction (the binder doesn't see the case subject).
3296
2947
  def append_array_splat_binding(bindings, splat)
3297
2948
  return unless splat.is_a?(Prism::SplatNode)
3298
2949
 
@@ -3302,10 +2953,8 @@ module Rigor
3302
2953
  bindings << [target.name, Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
3303
2954
  end
3304
2955
 
3305
- # `{ key:, **rest }` binds `rest` to a Hash whose keys are
3306
- # Symbols (the only legal key shape for a hash pattern) and
3307
- # whose values are untyped (the binder can't see the subject's
3308
- # value type).
2956
+ # `{ key:, **rest }` binds `rest` to a Hash whose keys are Symbols (the only legal key shape for a hash pattern)
2957
+ # and whose values are untyped (the binder can't see the subject's value type).
3309
2958
  def hash_pattern_rest_type
3310
2959
  Type::Combinator.nominal_of(
3311
2960
  "Hash",
@@ -3313,14 +2962,12 @@ module Rigor
3313
2962
  )
3314
2963
  end
3315
2964
 
3316
- # ---------------------------------------------------------------
3317
- # named-capture regex binding (`MatchWriteNode`)
2965
+ # --------------------------------------------------------------- named-capture regex binding (`MatchWriteNode`)
3318
2966
  # ---------------------------------------------------------------
3319
2967
 
3320
- # `/(?<year>\d+)/ =~ str` — Prism emits a `MatchWriteNode` that
3321
- # wraps the `=~` call and lists the named-capture targets. Each
3322
- # target is bound to `String | nil` (the capture is absent as nil
3323
- # when the pattern doesn't match or the group didn't participate).
2968
+ # `/(?<year>\d+)/ =~ str` — Prism emits a `MatchWriteNode` that wraps the `=~` call and lists the named-capture
2969
+ # targets. Each target is bound to `String | nil` (the capture is absent as nil when the pattern doesn't match or
2970
+ # the group didn't participate).
3324
2971
  def eval_match_write(node)
3325
2972
  match_type, post_scope = sub_eval(node.call, scope)
3326
2973
  string_or_nil = Type::Combinator.union(
@@ -3335,23 +2982,19 @@ module Rigor
3335
2982
  [match_type, bound_scope]
3336
2983
  end
3337
2984
 
3338
- # ---------------------------------------------------------------
3339
- # shared type conversion helper
2985
+ # --------------------------------------------------------------- shared type conversion helper
3340
2986
  # ---------------------------------------------------------------
3341
2987
 
3342
- # Converts a `Singleton[ClassName]` (the class object) to the
3343
- # corresponding `Nominal[ClassName]` (an instance). Falls back to
3344
- # `untyped` for carriers that are not Singleton (e.g. Dynamic[Top]
3345
- # when the class could not be resolved).
2988
+ # Converts a `Singleton[ClassName]` (the class object) to the corresponding `Nominal[ClassName]` (an instance).
2989
+ # Falls back to `untyped` for carriers that are not Singleton (e.g. Dynamic[Top] when the class could not be
2990
+ # resolved).
3346
2991
  def singleton_to_nominal(type)
3347
2992
  type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
3348
2993
  end
3349
2994
 
3350
- # Returns the type to bind for a `CapturePatternNode`'s target.
3351
- # Plain class references collapse to the matching `Nominal[T]`;
3352
- # `AlternationPatternNode` (`Integer | String => x`) unions every
3353
- # alternate's resolved type. Anything else falls back to
3354
- # `untyped` (the conservative legacy behaviour).
2995
+ # Returns the type to bind for a `CapturePatternNode`'s target. Plain class references collapse to the matching
2996
+ # `Nominal[T]`; `AlternationPatternNode` (`Integer | String => x`) unions every alternate's resolved type.
2997
+ # Anything else falls back to `untyped` (the conservative legacy behaviour).
3355
2998
  def pattern_capture_type(value_node, scope)
3356
2999
  if value_node.is_a?(Prism::AlternationPatternNode)
3357
3000
  left = pattern_capture_type(value_node.left, scope)
@@ -3362,13 +3005,10 @@ module Rigor
3362
3005
  end
3363
3006
  end
3364
3007
 
3365
- # `in PatternA | PatternB` — Ruby requires both alternates to
3366
- # bind the same names, but the binder runs against the AST and
3367
- # cannot enforce that. We collect bindings from each side and
3368
- # merge by name, unioning types when both alternates contribute.
3369
- # Names that only one alternate contributes still surface (the
3370
- # parser would have rejected the case at compile time, so by the
3371
- # time we see it the user's intent is the merged set).
3008
+ # `in PatternA | PatternB` — Ruby requires both alternates to bind the same names, but the binder runs against the
3009
+ # AST and cannot enforce that. We collect bindings from each side and merge by name, unioning types when both
3010
+ # alternates contribute. Names that only one alternate contributes still surface (the parser would have rejected
3011
+ # the case at compile time, so by the time we see it the user's intent is the merged set).
3372
3012
  def collect_alternation_pattern_bindings(subject, pattern, scope)
3373
3013
  left = collect_in_pattern_bindings(subject, pattern.left, scope)
3374
3014
  right = collect_in_pattern_bindings(subject, pattern.right, scope)