rigortype 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (414) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/manual/04-diagnostics.md +7 -4
  4. data/docs/manual/06-baseline.md +5 -2
  5. data/docs/manual/15-type-protection-coverage.md +6 -4
  6. data/lib/rigor/analysis/baseline.rb +56 -96
  7. data/lib/rigor/analysis/buffer_binding.rb +13 -20
  8. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +29 -48
  9. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +35 -54
  10. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +23 -37
  11. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +16 -23
  12. data/lib/rigor/analysis/check_rules/rule_walk.rb +48 -71
  13. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +19 -28
  14. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +48 -67
  15. data/lib/rigor/analysis/check_rules.rb +174 -274
  16. data/lib/rigor/analysis/dependency_recorder.rb +26 -38
  17. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +13 -25
  18. data/lib/rigor/analysis/dependency_source_inference/builder.rb +17 -30
  19. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +15 -26
  20. data/lib/rigor/analysis/dependency_source_inference/index.rb +45 -76
  21. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +23 -40
  22. data/lib/rigor/analysis/dependency_source_inference/walker.rb +50 -83
  23. data/lib/rigor/analysis/dependency_source_inference.rb +6 -10
  24. data/lib/rigor/analysis/diagnostic.rb +48 -75
  25. data/lib/rigor/analysis/erb_template_detector.rb +13 -18
  26. data/lib/rigor/analysis/fact_store.rb +12 -22
  27. data/lib/rigor/analysis/incremental.rb +47 -63
  28. data/lib/rigor/analysis/incremental_session.rb +74 -100
  29. data/lib/rigor/analysis/project_scan.rb +12 -23
  30. data/lib/rigor/analysis/result.rb +3 -5
  31. data/lib/rigor/analysis/rule_catalog.rb +67 -95
  32. data/lib/rigor/analysis/run_stats.rb +38 -61
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +101 -160
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +121 -188
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +62 -96
  36. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -18
  37. data/lib/rigor/analysis/runner.rb +220 -333
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +30 -43
  39. data/lib/rigor/analysis/worker_session.rb +64 -102
  40. data/lib/rigor/ast/type_node.rb +6 -8
  41. data/lib/rigor/ast.rb +6 -10
  42. data/lib/rigor/bleeding_edge.rb +27 -35
  43. data/lib/rigor/builtins/hkt_builtins.rb +7 -14
  44. data/lib/rigor/builtins/imported_refinements.rb +106 -149
  45. data/lib/rigor/builtins/predefined_constant_refinements.rb +51 -65
  46. data/lib/rigor/builtins/regex_refinement.rb +32 -43
  47. data/lib/rigor/builtins/static_return_refinements.rb +57 -81
  48. data/lib/rigor/cache/descriptor.rb +58 -94
  49. data/lib/rigor/cache/incremental_snapshot.rb +32 -48
  50. data/lib/rigor/cache/rbs_cache_producer.rb +10 -15
  51. data/lib/rigor/cache/rbs_class_ancestor_table.rb +10 -16
  52. data/lib/rigor/cache/rbs_class_type_param_names.rb +10 -16
  53. data/lib/rigor/cache/rbs_constant_table.rb +8 -13
  54. data/lib/rigor/cache/rbs_descriptor.rb +8 -13
  55. data/lib/rigor/cache/rbs_environment.rb +11 -19
  56. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +11 -19
  57. data/lib/rigor/cache/rbs_known_class_names.rb +8 -12
  58. data/lib/rigor/cache/store.rb +96 -151
  59. data/lib/rigor/cli/annotate_command.rb +51 -88
  60. data/lib/rigor/cli/baseline_command.rb +10 -17
  61. data/lib/rigor/cli/check_command.rb +121 -199
  62. data/lib/rigor/cli/check_runner_factory.rb +8 -14
  63. data/lib/rigor/cli/ci_detector.rb +9 -14
  64. data/lib/rigor/cli/command.rb +8 -14
  65. data/lib/rigor/cli/coverage_command.rb +19 -26
  66. data/lib/rigor/cli/coverage_mutation.rb +14 -19
  67. data/lib/rigor/cli/coverage_report.rb +0 -2
  68. data/lib/rigor/cli/coverage_scan.rb +7 -11
  69. data/lib/rigor/cli/diagnostic_formats.rb +43 -62
  70. data/lib/rigor/cli/diff_command.rb +10 -20
  71. data/lib/rigor/cli/docs_command.rb +26 -40
  72. data/lib/rigor/cli/doctor_command.rb +8 -9
  73. data/lib/rigor/cli/explain_command.rb +4 -7
  74. data/lib/rigor/cli/fused_protection_renderer.rb +4 -5
  75. data/lib/rigor/cli/fused_protection_report.rb +6 -8
  76. data/lib/rigor/cli/lsp_command.rb +11 -19
  77. data/lib/rigor/cli/mcp_command.rb +4 -6
  78. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -4
  79. data/lib/rigor/cli/mutation_protection_report.rb +6 -9
  80. data/lib/rigor/cli/options.rb +11 -19
  81. data/lib/rigor/cli/plugin_command.rb +18 -30
  82. data/lib/rigor/cli/plugins_command.rb +29 -51
  83. data/lib/rigor/cli/plugins_renderer.rb +12 -20
  84. data/lib/rigor/cli/prism_colorizer.rb +13 -19
  85. data/lib/rigor/cli/protection_renderer.rb +5 -7
  86. data/lib/rigor/cli/protection_report.rb +27 -17
  87. data/lib/rigor/cli/renderable.rb +4 -6
  88. data/lib/rigor/cli/show_bleedingedge_command.rb +5 -8
  89. data/lib/rigor/cli/sig_gen_command.rb +14 -26
  90. data/lib/rigor/cli/skill_command.rb +30 -47
  91. data/lib/rigor/cli/skill_describe.rb +40 -64
  92. data/lib/rigor/cli/trace_command.rb +9 -16
  93. data/lib/rigor/cli/trace_renderer.rb +35 -51
  94. data/lib/rigor/cli/triage_command.rb +6 -10
  95. data/lib/rigor/cli/triage_renderer.rb +1 -2
  96. data/lib/rigor/cli/type_of_command.rb +14 -23
  97. data/lib/rigor/cli/type_of_renderer.rb +3 -5
  98. data/lib/rigor/cli/type_scan_command.rb +6 -10
  99. data/lib/rigor/cli/type_scan_renderer.rb +3 -5
  100. data/lib/rigor/cli/type_scan_report.rb +2 -3
  101. data/lib/rigor/cli/upgrade_command.rb +2 -3
  102. data/lib/rigor/cli.rb +16 -28
  103. data/lib/rigor/config_audit.rb +35 -44
  104. data/lib/rigor/configuration/dependencies.rb +41 -70
  105. data/lib/rigor/configuration/severity_profile.rb +28 -42
  106. data/lib/rigor/configuration.rb +150 -241
  107. data/lib/rigor/environment/bundle_sig_discovery.rb +74 -118
  108. data/lib/rigor/environment/class_registry.rb +19 -26
  109. data/lib/rigor/environment/constant_type_cache_holder.rb +14 -21
  110. data/lib/rigor/environment/hkt_registry_holder.rb +6 -12
  111. data/lib/rigor/environment/lockfile_resolver.rb +31 -47
  112. data/lib/rigor/environment/rbs_collection_discovery.rb +39 -63
  113. data/lib/rigor/environment/rbs_coverage_report.rb +24 -42
  114. data/lib/rigor/environment/rbs_loader.rb +420 -518
  115. data/lib/rigor/environment/reflection.rb +28 -53
  116. data/lib/rigor/environment/reporters.rb +13 -24
  117. data/lib/rigor/environment.rb +176 -291
  118. data/lib/rigor/flow_contribution/conflict.rb +16 -27
  119. data/lib/rigor/flow_contribution/element.rb +7 -12
  120. data/lib/rigor/flow_contribution/fact.rb +33 -57
  121. data/lib/rigor/flow_contribution/merge_result.rb +6 -9
  122. data/lib/rigor/flow_contribution/merger.rb +32 -47
  123. data/lib/rigor/flow_contribution.rb +37 -55
  124. data/lib/rigor/inference/acceptance.rb +133 -219
  125. data/lib/rigor/inference/block_parameter_binder.rb +52 -82
  126. data/lib/rigor/inference/body_fixpoint.rb +22 -30
  127. data/lib/rigor/inference/budget_trace.rb +43 -63
  128. data/lib/rigor/inference/builtins/array_catalog.rb +4 -6
  129. data/lib/rigor/inference/builtins/comparable_catalog.rb +4 -6
  130. data/lib/rigor/inference/builtins/complex_catalog.rb +13 -19
  131. data/lib/rigor/inference/builtins/date_catalog.rb +38 -67
  132. data/lib/rigor/inference/builtins/encoding_catalog.rb +22 -36
  133. data/lib/rigor/inference/builtins/enumerable_catalog.rb +4 -6
  134. data/lib/rigor/inference/builtins/exception_catalog.rb +38 -57
  135. data/lib/rigor/inference/builtins/hash_catalog.rb +9 -14
  136. data/lib/rigor/inference/builtins/method_catalog.rb +29 -48
  137. data/lib/rigor/inference/builtins/numeric_catalog.rb +13 -18
  138. data/lib/rigor/inference/builtins/pathname_catalog.rb +8 -13
  139. data/lib/rigor/inference/builtins/proc_catalog.rb +38 -62
  140. data/lib/rigor/inference/builtins/random_catalog.rb +21 -32
  141. data/lib/rigor/inference/builtins/range_catalog.rb +12 -21
  142. data/lib/rigor/inference/builtins/rational_catalog.rb +9 -15
  143. data/lib/rigor/inference/builtins/re_catalog.rb +29 -48
  144. data/lib/rigor/inference/builtins/set_catalog.rb +18 -27
  145. data/lib/rigor/inference/builtins/string_catalog.rb +12 -19
  146. data/lib/rigor/inference/builtins/struct_catalog.rb +18 -30
  147. data/lib/rigor/inference/builtins/time_catalog.rb +28 -44
  148. data/lib/rigor/inference/closure_escape_analyzer.rb +44 -67
  149. data/lib/rigor/inference/coverage_scanner.rb +13 -18
  150. data/lib/rigor/inference/def_return_typer.rb +8 -14
  151. data/lib/rigor/inference/dynamic_origin.rb +23 -20
  152. data/lib/rigor/inference/expression_typer.rb +774 -1152
  153. data/lib/rigor/inference/fallback.rb +8 -12
  154. data/lib/rigor/inference/fallback_tracer.rb +4 -10
  155. data/lib/rigor/inference/flow_tracer.rb +25 -36
  156. data/lib/rigor/inference/hkt_body.rb +45 -68
  157. data/lib/rigor/inference/hkt_body_parser.rb +33 -52
  158. data/lib/rigor/inference/hkt_reducer.rb +38 -59
  159. data/lib/rigor/inference/hkt_registry.rb +50 -76
  160. data/lib/rigor/inference/indexed_narrowing.rb +55 -81
  161. data/lib/rigor/inference/macro_block_self_type.rb +21 -34
  162. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +11 -18
  163. data/lib/rigor/inference/method_dispatcher/block_folding.rb +49 -83
  164. data/lib/rigor/inference/method_dispatcher/call_context.rb +16 -23
  165. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +13 -22
  166. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +307 -454
  167. data/lib/rigor/inference/method_dispatcher/data_folding.rb +24 -34
  168. data/lib/rigor/inference/method_dispatcher/file_folding.rb +22 -37
  169. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +52 -81
  170. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +51 -78
  171. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +60 -97
  172. data/lib/rigor/inference/method_dispatcher/math_folding.rb +23 -34
  173. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +18 -26
  174. data/lib/rigor/inference/method_dispatcher/method_folding.rb +37 -71
  175. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +114 -182
  176. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +122 -193
  177. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +15 -24
  178. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +64 -93
  179. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +31 -45
  180. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -9
  181. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +356 -511
  182. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +22 -32
  183. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +15 -21
  184. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +66 -82
  185. data/lib/rigor/inference/method_dispatcher/time_folding.rb +10 -15
  186. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -13
  187. data/lib/rigor/inference/method_dispatcher.rb +378 -524
  188. data/lib/rigor/inference/method_parameter_binder.rb +81 -114
  189. data/lib/rigor/inference/multi_target_binder.rb +51 -68
  190. data/lib/rigor/inference/mutation_widening.rb +115 -158
  191. data/lib/rigor/inference/narrowing.rb +509 -727
  192. data/lib/rigor/inference/origin_lookup.rb +38 -0
  193. data/lib/rigor/inference/parameter_inference_collector.rb +100 -100
  194. data/lib/rigor/inference/precision_scanner.rb +26 -35
  195. data/lib/rigor/inference/project_patched_methods.rb +20 -32
  196. data/lib/rigor/inference/project_patched_scanner.rb +23 -37
  197. data/lib/rigor/inference/protection_scanner.rb +16 -18
  198. data/lib/rigor/inference/rbs_type_translator.rb +49 -66
  199. data/lib/rigor/inference/scope_indexer.rb +473 -817
  200. data/lib/rigor/inference/statement_evaluator.rb +706 -1111
  201. data/lib/rigor/inference/struct_fold_safety.rb +34 -48
  202. data/lib/rigor/inference/synthetic_method.rb +9 -16
  203. data/lib/rigor/inference/synthetic_method_index.rb +20 -35
  204. data/lib/rigor/inference/synthetic_method_scanner.rb +63 -103
  205. data/lib/rigor/language_server/buffer_resolution.rb +6 -9
  206. data/lib/rigor/language_server/buffer_table.rb +11 -18
  207. data/lib/rigor/language_server/completion_provider.rb +69 -116
  208. data/lib/rigor/language_server/debouncer.rb +16 -25
  209. data/lib/rigor/language_server/diagnostic_publisher.rb +26 -40
  210. data/lib/rigor/language_server/document_symbol_provider.rb +14 -19
  211. data/lib/rigor/language_server/folding_range_provider.rb +11 -19
  212. data/lib/rigor/language_server/hover_provider.rb +8 -11
  213. data/lib/rigor/language_server/hover_renderer.rb +56 -93
  214. data/lib/rigor/language_server/loop.rb +14 -19
  215. data/lib/rigor/language_server/project_context.rb +46 -74
  216. data/lib/rigor/language_server/selection_range_provider.rb +9 -12
  217. data/lib/rigor/language_server/server.rb +55 -83
  218. data/lib/rigor/language_server/signature_help_provider.rb +29 -46
  219. data/lib/rigor/language_server/synchronized_writer.rb +4 -7
  220. data/lib/rigor/language_server/uri.rb +8 -13
  221. data/lib/rigor/language_server.rb +4 -6
  222. data/lib/rigor/mcp/loop.rb +2 -3
  223. data/lib/rigor/mcp/server.rb +4 -7
  224. data/lib/rigor/mcp.rb +3 -6
  225. data/lib/rigor/plugin/access_denied_error.rb +5 -8
  226. data/lib/rigor/plugin/additional_initializer.rb +21 -31
  227. data/lib/rigor/plugin/base.rb +335 -518
  228. data/lib/rigor/plugin/blueprint.rb +14 -23
  229. data/lib/rigor/plugin/box.rb +18 -29
  230. data/lib/rigor/plugin/fact_store.rb +16 -26
  231. data/lib/rigor/plugin/inflector.rb +37 -53
  232. data/lib/rigor/plugin/io_boundary.rb +33 -56
  233. data/lib/rigor/plugin/isolation.rb +42 -55
  234. data/lib/rigor/plugin/load_error.rb +10 -15
  235. data/lib/rigor/plugin/loader.rb +30 -49
  236. data/lib/rigor/plugin/macro/block_as_method.rb +20 -32
  237. data/lib/rigor/plugin/macro/heredoc_template.rb +35 -58
  238. data/lib/rigor/plugin/macro/nested_class_template.rb +22 -36
  239. data/lib/rigor/plugin/macro/trait_registry.rb +34 -51
  240. data/lib/rigor/plugin/macro.rb +10 -15
  241. data/lib/rigor/plugin/manifest.rb +85 -144
  242. data/lib/rigor/plugin/node_context.rb +14 -22
  243. data/lib/rigor/plugin/node_rule_walk.rb +49 -74
  244. data/lib/rigor/plugin/protocol_contract.rb +25 -39
  245. data/lib/rigor/plugin/registry.rb +132 -205
  246. data/lib/rigor/plugin/services.rb +21 -33
  247. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -19
  248. data/lib/rigor/plugin/trust_policy.rb +24 -38
  249. data/lib/rigor/plugin/type_node_resolver.rb +15 -24
  250. data/lib/rigor/protection/diagnostic_oracle.rb +11 -13
  251. data/lib/rigor/protection/mutation_scanner.rb +27 -35
  252. data/lib/rigor/protection/mutator.rb +50 -70
  253. data/lib/rigor/protection/test_suite_oracle.rb +20 -27
  254. data/lib/rigor/rbs_extended/conformance_checker.rb +48 -68
  255. data/lib/rigor/rbs_extended/hkt_directives.rb +28 -54
  256. data/lib/rigor/rbs_extended/reporter.rb +24 -40
  257. data/lib/rigor/rbs_extended.rb +107 -197
  258. data/lib/rigor/reflection.rb +68 -86
  259. data/lib/rigor/scope/discovery_index.rb +14 -19
  260. data/lib/rigor/scope.rb +255 -310
  261. data/lib/rigor/sig_gen/classification.rb +6 -10
  262. data/lib/rigor/sig_gen/generator.rb +197 -323
  263. data/lib/rigor/sig_gen/layout_index.rb +12 -20
  264. data/lib/rigor/sig_gen/method_candidate.rb +12 -17
  265. data/lib/rigor/sig_gen/observation_collector.rb +38 -70
  266. data/lib/rigor/sig_gen/observed_call.rb +13 -23
  267. data/lib/rigor/sig_gen/path_mapper.rb +17 -29
  268. data/lib/rigor/sig_gen/renderer.rb +7 -13
  269. data/lib/rigor/sig_gen/type_elaborator.rb +15 -28
  270. data/lib/rigor/sig_gen/write_result.rb +8 -16
  271. data/lib/rigor/sig_gen/writer.rb +95 -174
  272. data/lib/rigor/sig_gen.rb +3 -6
  273. data/lib/rigor/signature_path_audit.rb +24 -30
  274. data/lib/rigor/source/constant_path.rb +10 -14
  275. data/lib/rigor/source/literals.rb +31 -45
  276. data/lib/rigor/source/node_locator.rb +9 -11
  277. data/lib/rigor/source/node_walker.rb +9 -13
  278. data/lib/rigor/source.rb +3 -4
  279. data/lib/rigor/testing.rb +16 -20
  280. data/lib/rigor/triage/catalogue.rb +38 -62
  281. data/lib/rigor/triage.rb +31 -52
  282. data/lib/rigor/trinary.rb +9 -13
  283. data/lib/rigor/type/acceptance_router.rb +4 -6
  284. data/lib/rigor/type/accepts_result.rb +10 -14
  285. data/lib/rigor/type/app.rb +19 -27
  286. data/lib/rigor/type/bot.rb +4 -6
  287. data/lib/rigor/type/bound_method.rb +10 -15
  288. data/lib/rigor/type/combinator.rb +165 -257
  289. data/lib/rigor/type/constant.rb +23 -34
  290. data/lib/rigor/type/data_class.rb +10 -15
  291. data/lib/rigor/type/data_instance.rb +14 -20
  292. data/lib/rigor/type/difference.rb +21 -32
  293. data/lib/rigor/type/dynamic.rb +3 -5
  294. data/lib/rigor/type/hash_shape.rb +32 -18
  295. data/lib/rigor/type/integer_range.rb +11 -16
  296. data/lib/rigor/type/intersection.rb +27 -42
  297. data/lib/rigor/type/nominal.rb +10 -15
  298. data/lib/rigor/type/plain_lattice.rb +9 -13
  299. data/lib/rigor/type/refined.rb +67 -114
  300. data/lib/rigor/type/singleton.rb +4 -6
  301. data/lib/rigor/type/struct_class.rb +11 -16
  302. data/lib/rigor/type/struct_instance.rb +15 -21
  303. data/lib/rigor/type/tuple.rb +14 -19
  304. data/lib/rigor/type/union.rb +30 -42
  305. data/lib/rigor/type_node/generic.rb +14 -26
  306. data/lib/rigor/type_node/identifier.rb +12 -19
  307. data/lib/rigor/type_node.rb +3 -12
  308. data/lib/rigor/value_semantics.rb +16 -21
  309. data/lib/rigor/version.rb +1 -1
  310. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +18 -30
  311. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +16 -29
  312. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +12 -23
  313. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +22 -39
  314. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +26 -46
  315. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +51 -87
  316. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +10 -17
  317. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +26 -39
  318. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +118 -176
  319. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +30 -51
  320. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +57 -92
  321. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +71 -108
  322. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +13 -22
  323. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +15 -25
  324. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +8 -13
  325. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +16 -24
  326. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +30 -47
  327. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +79 -129
  328. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +36 -57
  329. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +27 -41
  330. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +16 -24
  331. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +148 -235
  332. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +3 -5
  333. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +8 -14
  334. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +9 -15
  335. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +4 -7
  336. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +23 -41
  337. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +3 -5
  338. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +6 -10
  339. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +8 -11
  340. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +30 -45
  341. data/plugins/rigor-devise/lib/rigor-devise.rb +3 -5
  342. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +43 -69
  343. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +26 -35
  344. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +3 -5
  345. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +30 -47
  346. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +3 -5
  347. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +67 -113
  348. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +29 -46
  349. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +3 -5
  350. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -20
  351. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +14 -23
  352. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +2 -3
  353. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +22 -37
  354. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +28 -46
  355. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +13 -17
  356. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -47
  357. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +52 -80
  358. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +21 -31
  359. data/plugins/rigor-graphql/lib/rigor-graphql.rb +3 -5
  360. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +11 -17
  361. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +20 -34
  362. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +45 -75
  363. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +22 -36
  364. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +22 -41
  365. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +17 -32
  366. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +6 -12
  367. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +4 -8
  368. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +17 -32
  369. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -18
  370. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +58 -92
  371. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +20 -34
  372. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +20 -31
  373. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +49 -82
  374. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +55 -92
  375. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +51 -90
  376. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +17 -30
  377. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +38 -70
  378. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +42 -74
  379. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +280 -497
  380. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +54 -89
  381. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +32 -54
  382. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +8 -13
  383. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +9 -16
  384. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +24 -39
  385. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +13 -22
  386. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +34 -56
  387. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +22 -42
  388. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +43 -75
  389. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +27 -39
  390. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +14 -19
  391. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +20 -34
  392. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +28 -47
  393. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +21 -37
  394. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +13 -22
  395. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +18 -31
  396. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +6 -10
  397. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +14 -29
  398. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +23 -41
  399. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +3 -5
  400. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +23 -38
  401. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +57 -96
  402. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +22 -35
  403. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +41 -69
  404. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +6 -10
  405. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +24 -41
  406. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +29 -50
  407. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +55 -88
  408. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +159 -262
  409. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +21 -29
  410. data/plugins/rigor-statesman/lib/rigor-statesman.rb +3 -5
  411. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +23 -37
  412. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +4 -6
  413. data/sig/rigor/scope.rbs +6 -0
  414. metadata +2 -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,9 +1818,8 @@ 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)
@@ -2112,11 +1831,9 @@ module Rigor
2112
1831
  floor_arguments_at_positions(node, escaped, base_scope)
2113
1832
  end
2114
1833
 
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.
1834
+ # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
1835
+ # `Scope#user_def_for` reads; no ancestor walk (the boundary-escape idiom is same-class), keeping this off the hot
1836
+ # path for the overwhelming majority of self-calls that resolve to nothing escaping.
2120
1837
  def resolve_self_callee_def(node)
2121
1838
  class_name = enclosing_class_name_for(scope.self_type)
2122
1839
  return scope.top_level_def_for(node.name) if class_name.nil?
@@ -2130,11 +1847,10 @@ module Rigor
2130
1847
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
2131
1848
  end
2132
1849
 
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.
1850
+ # The set of `[name, position]` parameters of `def_node` whose content a block in the body escape-mutates.
1851
+ # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
1852
+ # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
1853
+ # non-escaping.
2138
1854
  def escaped_content_parameters(def_node)
2139
1855
  cache = (@escaped_param_cache ||= {}.compare_by_identity)
2140
1856
  cache[def_node] ||= compute_escaped_content_parameters(def_node)
@@ -2156,15 +1872,12 @@ module Rigor
2156
1872
  positions.slice(*mutated)
2157
1873
  end
2158
1874
 
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.
1875
+ # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
1876
+ # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
1877
+ # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
1878
+ # captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
1879
+ # unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
1880
+ # argument that was about to be precise.
2168
1881
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
2169
1882
  ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
2170
1883
  ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
@@ -2179,9 +1892,8 @@ module Rigor
2179
1892
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
2180
1893
  end
2181
1894
 
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.
1895
+ # `{ name => position }` for the required / optional positional parameters of a def. Keyword / rest / block
1896
+ # parameters are skipped the boundary-escape idiom passes a plain positional collection.
2185
1897
  def positional_parameter_positions(def_node)
2186
1898
  params = def_node.parameters
2187
1899
  return {} if params.nil?
@@ -2194,10 +1906,9 @@ module Rigor
2194
1906
  positions
2195
1907
  end
2196
1908
 
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.
1909
+ # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
1910
+ # the current scope a cheap pre-filter so the def resolution / body scan only runs for calls that could actually
1911
+ # floor something.
2201
1912
  def call_passes_local_argument?(node)
2202
1913
  args = node.arguments
2203
1914
  return false unless args.respond_to?(:arguments)
@@ -2232,12 +1943,10 @@ module Rigor
2232
1943
  end
2233
1944
  end
2234
1945
 
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.
1946
+ # Walk the receiver chain of `node` and fold the escaping-content widening of every block-bearing, escaping
1947
+ # receiver call into `base_scope`. Only receiver calls are walked — `node` itself is handled by the caller. A
1948
+ # `:non_escaping` receiver block is left to slice C's non-escaping write-back (which the receiver expression
1949
+ # evaluation already drives), so we only floor the escaping / unknown ones here.
2241
1950
  def widen_escaping_receiver_chain_captures(node, base_scope)
2242
1951
  receiver = node.receiver
2243
1952
  acc = base_scope
@@ -2251,28 +1960,23 @@ module Rigor
2251
1960
  acc
2252
1961
  end
2253
1962
 
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.
1963
+ # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks). An escaping / unknown block that
1964
+ # CONTENT-mutates a captured outer local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a stored
1965
+ # proc) previously left that local's content untouched only its narrowing was dropped, so a constant seed
1966
+ # (`options = {}`, `s = ""`) survived and its element fold (`options[:format]` -> `"text"`, `s.empty?` -> `true`)
1967
+ # was unsoundly precise.
2261
1968
  #
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.
1969
+ # An escaping block may run later and any number of times, so joining a bounded evidence set is not sound (unlike
1970
+ # slice C's non-escaping join): the sound continuation is the bare-collection floor — Array ->
1971
+ # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String -> `String`. The seed's element/key/value
1972
+ # precision is forgotten; only the carrier survives. Read-only captures and locals the block merely rebinds
1973
+ # (already floored by `drop_captured_narrowing`) are untouched.
2269
1974
  def widen_escaping_content_captures(block_node, post_scope)
2270
1975
  body = block_node.body
2271
1976
  return post_scope if body.nil?
2272
1977
 
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).
1978
+ # Transitive case first: the body may content-mutate a captured local through a self-call rather than a direct
1979
+ # `local[k] = v` write, which `collect_content_mutations` cannot see (see below).
2276
1980
  post_scope = floor_block_body_callee_escaped_args(body, post_scope)
2277
1981
 
2278
1982
  mutations = collect_content_mutations(body)
@@ -2284,20 +1988,15 @@ module Rigor
2284
1988
  end
2285
1989
  end
2286
1990
 
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.
1991
+ # Inside an ESCAPING block body, a captured outer local can be content- mutated transitively: the body is (or
1992
+ # contains) a self-call that escape-mutates one of its arguments. The canonical shape is the CLI's own
1993
+ # `OptionParser.new { |opts| define_options(opts, options) }` the block body is a bare `define_options(opts,
1994
+ # options)` whose `options` parameter is escape-mutated inside ITS nested `opts.on { options[:k] = v }` blocks.
1995
+ # `collect_content_mutations` only sees direct `local[k] = v` writes in THIS body, so it misses the transitive
1996
+ # write and the captured Hash keeps its literal-false seed (folding the caller's `options[:mutation]` guard to an
1997
+ # always-falsey constant). Reuse the cross-method-boundary callee-escaped-argument floor (the same gate the
1998
+ # receiver-chain path uses at the call site) on every self-call in the body. Sound only ever floors a captured
1999
+ # local passed as an argument to a callee that demonstrably escape-mutates the matching parameter.
2301
2000
  def floor_block_body_callee_escaped_args(body, post_scope)
2302
2001
  acc = post_scope
2303
2002
  Source::NodeWalker.each(body) do |descendant|
@@ -2306,9 +2005,8 @@ module Rigor
2306
2005
  acc
2307
2006
  end
2308
2007
 
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).
2008
+ # The Dynamic-floor carrier for a content-mutated escaping capture, or nil when the pre-state is not a recognised
2009
+ # mutable collection (leave it alone e.g. an already-`Dynamic` binding or an unknown shape).
2312
2010
  def content_floor_for(type)
2313
2011
  return nil if type.nil?
2314
2012
 
@@ -2334,14 +2032,10 @@ module Rigor
2334
2032
  :unknown
2335
2033
  end
2336
2034
 
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.
2035
+ # Sub-phase 3c. Replace the outer-local types that the block body can rebind with `Dynamic[Top]`. The conservative
2036
+ # drop matches the spec line "facts about locals it can write become unstable after the escape point": rather than
2037
+ # synthesise the union of the block's write types (which the current pass does not yet expose), we discard the
2038
+ # narrowed binding altogether. A future sub-phase MAY refine this to the union of the block's actual writes.
2345
2039
  def drop_captured_narrowing(block_node, base_scope)
2346
2040
  names = captured_local_writes(block_node, base_scope)
2347
2041
  return base_scope if names.empty?
@@ -2349,13 +2043,11 @@ module Rigor
2349
2043
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
2350
2044
  end
2351
2045
 
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.
2046
+ # Names of outer locals the block body can REBIND, across every local-write form: plain `=`
2047
+ # (`LocalVariableWriteNode`), the operator / `||=` / `&&=` compound forms, and a multi-assign target (`x, y = ...`
2048
+ # `LocalVariableTargetNode` under `MultiWriteNode`). Block-introduced names (parameters, numbered params,
2049
+ # `;`-locals) and names not bound in the outer scope are excluded — a write to either is not a captured rebind of
2050
+ # an outer variable.
2359
2051
  LOCAL_WRITE_NODES = [
2360
2052
  Prism::LocalVariableWriteNode,
2361
2053
  Prism::LocalVariableOperatorWriteNode,
@@ -2381,18 +2073,14 @@ module Rigor
2381
2073
  outer_writes.uniq
2382
2074
  end
2383
2075
 
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
2076
+ # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
2077
+ # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
2078
+ # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
2079
+ # `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
2391
2080
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2392
2081
  #
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.
2082
+ # Fast path: a block writing no outer local leaves `post_scope` byte-identical (the overwhelming majority of
2083
+ # blocks), so this costs one extra `captured_local_writes` walk and nothing else.
2396
2084
  def write_back_block_captures(call_node, post_scope)
2397
2085
  block = call_node.block
2398
2086
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2412,20 +2100,16 @@ module Rigor
2412
2100
  result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
2413
2101
  end
2414
2102
 
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]`).
2103
+ # ADR-56 slice C — receiver-content element-type join. After the rebind write-back and
2104
+ # `MutationWidening.widen_after_block` (which forgets a content-mutated collection's literal arity but keeps only
2105
+ # the SEED's element types), join the appended/stored element / key / value types INTO the continuation
2106
+ # collection's parameter, so `out = [0]; arr.each { |x| out << x }` types `out` as `Array[0 | Integer]` (sound)
2107
+ # rather than `Array[0]` (the B1 under-approximation: the runtime array is `[0, 1, 2, 3]`).
2423
2108
  #
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.
2109
+ # Pre-state is read from `post_scope` so a local that is BOTH rebound and content-mutated composes: the rebind
2110
+ # fixpoint result feeds the content join. The block body is typed once for argument evidence; the floor is
2111
+ # `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the sound empty-seed behaviour). Always sound — only ever
2112
+ # widens.
2429
2113
  def content_writeback_block_captures(call_node, post_scope)
2430
2114
  block = call_node.block
2431
2115
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2444,13 +2128,11 @@ module Rigor
2444
2128
  end
2445
2129
  end
2446
2130
 
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.
2131
+ # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
2132
+ # block has mutated it through the `acc` alias. Compute the joined memo type the same way captured- local content
2133
+ # mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
2134
+ # memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
2135
+ # is not a collection.
2454
2136
  def each_with_object_return(call_node, call_type)
2455
2137
  return call_type unless call_node.name == :each_with_object
2456
2138
 
@@ -2466,8 +2148,8 @@ module Rigor
2466
2148
  body = block.body
2467
2149
  return call_type if body.nil?
2468
2150
 
2469
- # The memo alias is a block-local (depth 0) — collect content
2470
- # mutations on it directly rather than via the captured-local walk.
2151
+ # The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
2152
+ # captured-local walk.
2471
2153
  calls = body_content_mutations_on(body, memo_param)
2472
2154
  return call_type if calls.empty?
2473
2155
 
@@ -2477,9 +2159,8 @@ module Rigor
2477
2159
  joined || call_type
2478
2160
  end
2479
2161
 
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.
2162
+ # The name of the memo block parameter (the SECOND positional param of an `each_with_object` block), or nil when
2163
+ # the block does not bind a second positional param.
2483
2164
  def each_with_object_memo_param(block)
2484
2165
  params_root = block.parameters
2485
2166
  return nil unless params_root.is_a?(Prism::BlockParametersNode)
@@ -2494,8 +2175,7 @@ module Rigor
2494
2175
  second.respond_to?(:name) ? second.name : nil
2495
2176
  end
2496
2177
 
2497
- # Content-mutator calls on a block-local receiver `var_name`
2498
- # (depth 0) within `body`.
2178
+ # Content-mutator calls on a block-local receiver `var_name` (depth 0) within `body`.
2499
2179
  def body_content_mutations_on(body, var_name)
2500
2180
  calls = []
2501
2181
  Source::NodeWalker.each(body) do |descendant|
@@ -2511,15 +2191,14 @@ module Rigor
2511
2191
  calls
2512
2192
  end
2513
2193
 
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.
2194
+ # Joins content evidence for a memo / param given its pre-state and a list of mutator calls, dispatching Array vs
2195
+ # Hash by the mutator set.
2516
2196
  def join_content_for_param(calls, pre_state, block_entry)
2517
2197
  return nil if pre_state.nil?
2518
2198
 
2519
2199
  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.
2200
+ # String carries no element parameter; mutating `<<`/`concat` makes the constant value unsound (`s = "a"; s <<
2201
+ # x` runtime `"a"`), so widen to the nominal base. Sound — only widens.
2523
2202
  Type::Combinator.nominal_of("String")
2524
2203
  elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2525
2204
  join_hash_param(calls, pre_state, block_entry)
@@ -2539,9 +2218,8 @@ module Rigor
2539
2218
  return nil unless arrayish?(pre_state)
2540
2219
 
2541
2220
  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.
2221
+ # Index-write on an array (`a[i] += v`) introduces no new element evidence we can cheaply attribute — the
2222
+ # array-arity forget already widened the binding; contribute nothing.
2545
2223
  next [] if index_write?(c)
2546
2224
 
2547
2225
  MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
@@ -2549,11 +2227,9 @@ module Rigor
2549
2227
  MutationWidening.join_array_content(pre_state, added)
2550
2228
  end
2551
2229
 
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).
2230
+ # Walks the block body for content-mutator calls (`<<`, `push`, `[]=`, …) whose receiver is a captured outer local
2231
+ # (depth >= 1), returning `{ name => [call_node, ...] }`. Mirrors the `MutationWidening.widen_after_block` walk
2232
+ # (descends into nested blocks; the depth check keeps nested block-locals out).
2557
2233
  def collect_content_mutations(body)
2558
2234
  mutations = Hash.new { |h, k| h[k] = [] }
2559
2235
  Source::NodeWalker.each(body) do |descendant|
@@ -2563,11 +2239,9 @@ module Rigor
2563
2239
  mutations
2564
2240
  end
2565
2241
 
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`).
2242
+ # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a multi-assign target) that mutate a collection's
2243
+ # CONTENT without a `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the OrWrite even though the
2244
+ # appended values land on the nested array — leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2571
2245
  INDEX_WRITE_NODES = [
2572
2246
  Prism::IndexOrWriteNode,
2573
2247
  Prism::IndexAndWriteNode,
@@ -2576,10 +2250,8 @@ module Rigor
2576
2250
  ].freeze
2577
2251
  private_constant :INDEX_WRITE_NODES
2578
2252
 
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.
2253
+ # `[receiver_name, node]` when `node` is a content mutation whose receiver is a local variable satisfying `accept`
2254
+ # (depth predicate), else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the index-write node forms.
2583
2255
  def content_mutation_target(node)
2584
2256
  is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2585
2257
  return [nil, nil] unless is_call_mutator || index_write?(node)
@@ -2591,11 +2263,9 @@ module Rigor
2591
2263
  [receiver.name, node]
2592
2264
  end
2593
2265
 
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`.
2266
+ # Computes the joined continuation collection type for one captured local from its content-mutator calls. Returns
2267
+ # `nil` (no overlay) when the pre-state is neither an Array-ish nor a Hash-ish binding — e.g. a String
2268
+ # accumulator, whose `<<` carries no element parameter and whose binding already types as `String`.
2599
2269
  def join_content_for_local(name, calls, post_scope, block_entry)
2600
2270
  join_content_for_param(calls, post_scope.local(name), block_entry)
2601
2271
  end
@@ -2633,11 +2303,9 @@ module Rigor
2633
2303
  (type.is_a?(Type::Nominal) && type.class_name == "String")
2634
2304
  end
2635
2305
 
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.
2306
+ # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or an index-write node (`h[k] ||= v`), typed in
2307
+ # the block-entry scope. For an index-write the stored value is opaque (the appended values often land on a NESTED
2308
+ # collection via `h[k] << v`), so the value is floored to `untyped` — sound: it only ever widens the value param.
2641
2309
  # Returns `[]` for other forms.
2642
2310
  def hash_pair_types(node, block_entry)
2643
2311
  if index_write?(node)
@@ -2664,10 +2332,9 @@ module Rigor
2664
2332
  nil
2665
2333
  end
2666
2334
 
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.
2335
+ # Argument types for a content-mutator call, typed against the block-entry scope (block params bound). A
2336
+ # sub-evaluator over `block_entry` keeps the argument typing flow-correct for params / `;`-locals without leaking
2337
+ # into the outer scope.
2671
2338
  def content_arg_types(call_node, block_entry)
2672
2339
  arguments = call_node.arguments
2673
2340
  return [] if arguments.nil?
@@ -2677,10 +2344,9 @@ module Rigor
2677
2344
  []
2678
2345
  end
2679
2346
 
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.
2347
+ # Evaluates `block`'s body once with each written outer local bound to the supplied `bindings` (block params /
2348
+ # `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the `BodyFixpoint`
2349
+ # body-evaluator.
2684
2350
  def block_exit_bindings(call_node, block, bindings, names)
2685
2351
  entry = build_block_entry_scope(call_node, block)
2686
2352
  entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
@@ -2688,11 +2354,9 @@ module Rigor
2688
2354
  names.to_h { |name| [name, exit_scope.local(name)] }
2689
2355
  end
2690
2356
 
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.
2357
+ # Names introduced by the block itself (parameters, numbered parameters via `BlockParameterBinder`, plus explicit
2358
+ # `;`-prefixed block-locals on `BlockParametersNode`). Writes to these names are local to the block and MUST NOT
2359
+ # be treated as captured rebinds of an outer local.
2696
2360
  def block_introduced_locals(block_node)
2697
2361
  introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
2698
2362
  params_root = block_node.parameters
@@ -2700,34 +2364,24 @@ module Rigor
2700
2364
  introduced
2701
2365
  end
2702
2366
 
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.
2367
+ # `Prism::BlockNode` is reached through {#eval_call}; the handler runs the body under `scope`, which the caller
2368
+ # has already augmented with the block's parameter bindings. Effects do not leak past the block (the outer
2369
+ # eval_call returns the caller's scope unchanged), but the body's local writes are threaded through subsequent
2370
+ # statements *inside* the block so `each { |x| sum = x; sum.succ }` types `sum.succ` under the `sum: x` binding.
2711
2371
  def eval_block(node)
2712
2372
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2713
2373
 
2714
2374
  sub_eval(node.body, scope)
2715
2375
  end
2716
2376
 
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]`.
2377
+ # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
2378
+ # and adds bindings for every named block parameter on top. Parameter types come from the receiving method's RBS
2379
+ # signature when one is available; the rest default to `Dynamic[Top]`.
2722
2380
  #
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`.
2381
+ # `;`-prefixed block-locals (`do |i; x|`) are bound to `Constant[nil]` so the inner read shadows any outer `x` per
2382
+ # Ruby's semantics — at runtime the block-local is a fresh nil-valued variable on every block invocation. Without
2383
+ # this shadow, an inner `x.even?` before the first write would type-check against the OUTER `x` (e.g. `Integer`)
2384
+ # when the runtime would actually `NoMethodError` on `nil`.
2731
2385
  def build_block_entry_scope(call_node, block_node)
2732
2386
  expected = expected_block_param_types_for(call_node)
2733
2387
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
@@ -2771,12 +2425,10 @@ module Rigor
2771
2425
  def eval_class_body(node, new_context)
2772
2426
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2773
2427
 
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>]`.
2428
+ # Class/module bodies run in a fresh scope: the outer scope's locals are NOT visible inside `class Foo; ...
2429
+ # end`. We keep the same Environment so RBS lookups continue to work, and simply drop the locals. Slice
2430
+ # A-engine: `self` inside a class body is the class object itself, so we set `self_type` to
2431
+ # `Singleton[<qualified>]`.
2780
2432
  fresh = build_fresh_body_scope
2781
2433
  body_self = self_type_for_class_body(new_context)
2782
2434
  fresh = fresh.with_self_type(body_self) if body_self
@@ -2792,43 +2444,49 @@ module Rigor
2792
2444
  source_path: scope.source_path
2793
2445
  )
2794
2446
  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).
2447
+ # ADR-67 WD3 — override an undeclared parameter with its call-site inferred type (precision-additive; an
2448
+ # RBS-declared parameter wins, the table is empty on a normal `check` run). The inferred type lives only as a
2449
+ # body local, never as an RBS contract, so it cannot fire a parameter-boundary diagnostic (WD1, satisfied by
2450
+ # construction).
2800
2451
  bindings = seed_inferred_param_types(bindings, def_node, singleton)
2801
2452
 
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.
2453
+ # Method bodies do NOT see the outer scope's locals. They start from a fresh scope with the same environment,
2454
+ # then receive the parameter bindings. Slice 7 phase 2: instance defs ALSO seed their `ivars` map from the
2455
+ # class-level accumulator so `def get; @x; end` reads the type that a sibling `def init; @x = 1; end` wrote.
2808
2456
  fresh = build_fresh_body_scope
2809
2457
  body_self = self_type_for_method_body(singleton: singleton)
2810
2458
  fresh = fresh.with_self_type(body_self) if body_self
2811
2459
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2812
2460
  fresh = seed_class_cvars(fresh)
2813
2461
  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).
2462
+ # ADR-48 Struct slice 3 — install the method body's fold-safe-local set so a member read off a mutation-free
2463
+ # local folds during the in-body walk (the call-return inference path is seeded separately).
2817
2464
  fresh = fresh.with_struct_fold_safe(
2818
2465
  StructFoldSafety.fold_safe_locals(
2819
2466
  def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2820
2467
  )
2821
2468
  )
2822
- bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
2469
+ bindings.reduce(fresh) { |acc, (name, type)| bind_param(acc, name, type) }
2470
+ end
2471
+
2472
+ # ADR-82 root-enrichment — bind a method parameter, and for an *undeclared* (untyped) parameter seed its
2473
+ # provenance to `inferred_return_untyped`. An untyped param is the archetypal inference gap ([ADR-67](
2474
+ # docs/adr/67-parameter-type-inference.md): no call-site type flows in), so a `x.foo` receiver on it should
2475
+ # route to parameter inference rather than reporting no cause at all. Reuses the WD1 `local_origins`
2476
+ # channel, so WD6 then carries the cause through any chain rooted at the parameter (`x.foo.bar`). An
2477
+ # RBS-declared / call-site-inferred parameter (a non-untyped binding) keeps no origin — it is not a hole.
2478
+ def bind_param(acc, name, type)
2479
+ bound = acc.with_local(name, type)
2480
+ return bound unless untyped_binding?(type)
2481
+
2482
+ bound.with_local_origin(name, DynamicOrigin::INFERRED_RETURN_UNTYPED)
2823
2483
  end
2824
2484
 
2825
- # ADR-67 WD3 — consult 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.
2485
+ # ADR-67 WD3 — consult the call-site parameter-inference table for this `def` and replace each undeclared
2486
+ # (untyped) parameter binding with its inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2487
+ # from the lexical class path the same triple {Inference::ParameterInferenceCollector} records. An RBS-declared
2488
+ # parameter (a non-untyped binding) always wins. No-op when the table is empty (the normal `check` path), so the
2489
+ # seed is byte-identical there.
2832
2490
  def seed_inferred_param_types(bindings, def_node, singleton)
2833
2491
  inferred = scope.param_inferred_types
2834
2492
  return bindings if inferred.empty?
@@ -2846,8 +2504,8 @@ module Rigor
2846
2504
  merged
2847
2505
  end
2848
2506
 
2849
- # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
2850
- # undeclared parameter — the only bindings ADR-67 WD3 overrides.
2507
+ # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an undeclared parameter — the only
2508
+ # bindings ADR-67 WD3 overrides.
2851
2509
  def untyped_binding?(type)
2852
2510
  type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
2853
2511
  end
@@ -2861,20 +2519,17 @@ module Rigor
2861
2519
  seeded = scope.class_ivars_for(path)
2862
2520
  return body_scope if seeded.empty?
2863
2521
 
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).
2522
+ # ADR-58 WD1 — the class-ivar index unions every `@x = …` write across the class flow-insensitively, so a ctor
2523
+ # `@x = nil` seed makes a read in a *different* method type `T | nil`. That `nil` is declaration-sourced, not
2524
+ # flow-live, so `seed_declaration_sourced_ivar` marks each seeded ivar: `possible-nil-receiver` then declines to
2525
+ # fire on the cross-method invariant unless a method-local write or narrowing makes the nil flow-live (which
2526
+ # drops the mark).
2871
2527
  seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
2872
2528
  end
2873
2529
 
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.
2530
+ # Cvars are visible from BOTH instance and singleton method bodies of the enclosing class, so this seed is
2531
+ # unconditional (no `singleton:` gate). At the top-level (no class context) the accumulator is empty and the seed
2532
+ # is a no-op.
2878
2533
  def seed_class_cvars(body_scope)
2879
2534
  path = current_class_path
2880
2535
  return body_scope if path.nil?
@@ -2885,11 +2540,9 @@ module Rigor
2885
2540
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
2886
2541
  end
2887
2542
 
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.
2543
+ # Globals are process-wide. The body scope already inherited the program-globals accumulator through
2544
+ # `with_program_globals`; seeding here just materialises each entry into the body's `globals` map so reads observe
2545
+ # a precise type without consulting the accumulator on every lookup.
2893
2546
  def seed_program_globals(body_scope)
2894
2547
  seeded = scope.program_globals
2895
2548
  return body_scope if seeded.empty?
@@ -2897,19 +2550,14 @@ module Rigor
2897
2550
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
2898
2551
  end
2899
2552
 
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.
2553
+ # Slice A-declarations. Class- and method-bodies start from a fresh local-empty scope, but they MUST keep the
2554
+ # `declared_types` table visible at the outer scope so the ScopeIndexer-populated declaration overrides
2555
+ # (`Prism::ConstantReadNode` for `module Foo` headers, etc.) remain reachable from inside nested bodies.
2906
2556
  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.
2557
+ # Single allocation instead of a deep `with_*` chain — this runs per class/method body on the main walk, so the
2558
+ # chain's throwaway intermediate Scopes were a top `Scope#rebuild` source (ADR-44). Local-empty by design; the
2559
+ # discovery index is inherited whole by reference (ADR-53 Track A), so a table added to the index can no longer
2560
+ # be dropped here by a missed per-field copy.
2913
2561
  Scope.new(
2914
2562
  environment: scope.environment,
2915
2563
  locals: {}.freeze,
@@ -2925,16 +2573,11 @@ module Rigor
2925
2573
  def_receiver_targets_lexical_self?(def_node.receiver)
2926
2574
  end
2927
2575
 
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.
2576
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside `module Meta`) — explicit-receiver def that
2577
+ # semantically equals `def self.bar` because the receiver constant resolves to `self` at the def-site. Matched
2578
+ # against the current class context's tail to cover both the `def OpenURI.x` form (single segment) and the `def
2579
+ # OpenURI::Meta.x` form (qualified path). Cross-class receivers (`def Bar.baz` inside `module Foo` where the
2580
+ # receiver names a different constant) are not promoted to singleton at this slice.
2938
2581
  def def_receiver_targets_lexical_self?(receiver)
2939
2582
  return false if @class_context.empty?
2940
2583
 
@@ -2953,18 +2596,16 @@ module Rigor
2953
2596
  end
2954
2597
  end
2955
2598
 
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).
2599
+ # Slice A-engine. Inside a class body `class Foo; ...; end`, `self` is the class object — `Singleton[Foo]`.
2600
+ # Returns nil at the top level (no enclosing class).
2959
2601
  def self_type_for_class_body(class_context)
2960
2602
  return nil if class_context.empty?
2961
2603
 
2962
2604
  Type::Combinator.singleton_of(class_context.map(&:name).join("::"))
2963
2605
  end
2964
2606
 
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:
2607
+ # Slice A-engine. Inside a method body, `self` depends on whether the def is on the singleton or instance side of
2608
+ # the surrounding class:
2968
2609
  #
2969
2610
  # - `def self.foo` or any def inside `class << self`: self is
2970
2611
  # the class object → `Singleton[Foo]`.
@@ -2995,16 +2636,11 @@ module Rigor
2995
2636
  target = singleton_constant_target(node.expression)
2996
2637
  return @class_context unless target
2997
2638
 
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.
2639
+ # `class << Foo` inside `class Foo` (the canonical pattern in Ruby's own time.rb) is semantically `class <<
2640
+ # self` replace the enclosing frame with a singleton frame for the same name so method registration and
2641
+ # `self_type` lookup land on Foo. When the target names a different constant (rare cross-class form), append a
2642
+ # fresh singleton frame tagged with the target FQN; the bodies are scoped to that target rather than to the
2643
+ # lexical enclosing class.
3008
2644
  if !@class_context.empty? && @class_context.last.name == target
3009
2645
  outer = @class_context[0..-2]
3010
2646
  outer + [ClassFrame.new(name: target, singleton: true)]
@@ -3025,10 +2661,8 @@ module Rigor
3025
2661
  end
3026
2662
  end
3027
2663
 
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.
2664
+ # The qualified name of the immediately-enclosing class (joining every nested `ClassFrame` with `::`). Returns
2665
+ # `nil` for a top-level def with no enclosing class, which routes the parameter binder past RBS lookup.
3032
2666
  def current_class_path
3033
2667
  return nil if @class_context.empty?
3034
2668
 
@@ -3041,27 +2675,23 @@ module Rigor
3041
2675
 
3042
2676
  # ----- helpers -----
3043
2677
 
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.
2678
+ # Explicit `return value` (including `return` inside a block, which in Ruby returns from the *enclosing method*).
2679
+ # The control-transfer value is `Bot` a `return` produces no value at its own position — but the returned
2680
+ # expression's type is recorded into the active return sink so the method-return inference joins it with the
2681
+ # body's tail type. Returns inside a nested `def`/lambda are barriers: `eval_def` clears the sink around the
2682
+ # nested body, so this handler only ever appends a return that genuinely exits the method currently being
2683
+ # inferred.
3052
2684
  def eval_return(node)
3053
2685
  sink = Thread.current[RETURN_SINK_KEY]
3054
2686
  record_return_value(node, sink) if sink
3055
2687
  [Type::Combinator.bot, scope]
3056
2688
  end
3057
2689
 
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.
2690
+ # A `break` transfers control to the loop exit (its flow value is `Bot`, like `return`). It records the current
2691
+ # scope into the active loop's break sink so the loop join can recover a `break`-path binding the fall-through
2692
+ # would drop (`flag = true; break` -> `flag` is `false | true` after the loop). nil sink = a `break` not inside an
2693
+ # inferred loop body (a block targeting a method, or top-level) left to the existing escaping-block / no-op
2694
+ # handling.
3065
2695
  def eval_break(node)
3066
2696
  sink = Thread.current[BREAK_SINK_KEY]
3067
2697
  sink << [node, scope] if sink
@@ -3070,10 +2700,9 @@ module Rigor
3070
2700
 
3071
2701
  def record_return_value(node, sink)
3072
2702
  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.
2703
+ # `return` with no argument returns nil; `return a` records the argument's type; `return a, b, c` packs a Tuple
2704
+ # in Ruby a multi-value return yields the array `[a, b, c]`, so the inferred return contributes the
2705
+ # corresponding Tuple element-by-element.
3077
2706
  if args.empty?
3078
2707
  sink << Type::Combinator.constant_of(nil)
3079
2708
  elsif args.size == 1
@@ -3095,16 +2724,12 @@ module Rigor
3095
2724
  ).evaluate(node)
3096
2725
  end
3097
2726
 
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.
2727
+ # Slice 7 phase 14 — branch exit detection. Returns true when the branch's body unconditionally exits the
2728
+ # surrounding control flow through a `return`, `next`, `break`, or `raise`. Used by `eval_if` / `eval_unless` to
2729
+ # narrow the post-scope: when one branch exits, the surrounding scope can carry the OTHER branch's edge forward
2730
+ # without nil-injection.
3105
2731
  #
3106
- # The detection is intentionally conservative — it
3107
- # recognises only the most common patterns:
2732
+ # The detection is intentionally conservative — it recognises only the most common patterns:
3108
2733
  # - A `Prism::ReturnNode`, `NextNode`, `BreakNode`.
3109
2734
  # - A `Prism::CallNode` whose name is `:raise` or `:throw`.
3110
2735
  # - A `Prism::StatementsNode`, `Prism::ParenthesesNode`, or
@@ -3141,18 +2766,12 @@ module Rigor
3141
2766
  end
3142
2767
  end
3143
2768
 
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`.
2769
+ # ADR-24 WD6 / slice 3 — generalised terminating-branch detection. `branch_unconditionally_exits?` recognises a
2770
+ # branch SYNTACTICALLY (return / next / break / a call named raise / throw / exit / abort / fail). A branch whose
2771
+ # *inferred type is `Bot`* also terminates it cannot produce a value, so control never falls through it —
2772
+ # regardless of how it is spelled. The canonical case is a resolved guard helper (`fail_with_message(...)`) whose
2773
+ # body always raises: ADR-24 slice 1 types the call `bot`, and this OR-test makes `helper(...) if x.nil?` narrow
2774
+ # exactly like `raise ... if x.nil?`. The branch type is already computed by `eval_if` / `eval_unless`.
3156
2775
  def branch_terminates?(branch_node, branch_type)
3157
2776
  branch_unconditionally_exits?(branch_node) ||
3158
2777
  branch_type.is_a?(Type::Bot)
@@ -3164,12 +2783,9 @@ module Rigor
3164
2783
  sub_eval(branch_node, branch_scope)
3165
2784
  end
3166
2785
 
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.
2786
+ # Joins two branch scopes at a control-flow merge point. Names bound in only one branch are nil-injected into the
2787
+ # other side so the joined scope sees them as `T | nil` rather than dropping them outright. This implements the
2788
+ # contract the Slice 3 phase 1 `Scope#join` documentation defers to the statement-level evaluator.
3173
2789
  def join_with_nil_injection(scope_a, scope_b)
3174
2790
  nil_const = Type::Combinator.constant_of(nil)
3175
2791
  a_keys = scope_a.locals.keys
@@ -3182,21 +2798,17 @@ module Rigor
3182
2798
  aug_a.join(aug_b)
3183
2799
  end
3184
2800
 
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`.
2801
+ # Generalises {#join_with_nil_injection} to N branches (case/when, begin/rescue chain). The reduce order does not
2802
+ # affect the result because nil-injection commutes with union under `Scope#join`.
3189
2803
  def reduce_scopes_with_nil_injection(scopes)
3190
2804
  scopes.reduce { |a, b| join_with_nil_injection(a, b) }
3191
2805
  end
3192
2806
 
3193
- # ---------------------------------------------------------------
3194
- # rescue variable binding helpers
2807
+ # --------------------------------------------------------------- rescue variable binding helpers
3195
2808
  # ---------------------------------------------------------------
3196
2809
 
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`).
2810
+ # Returns `scope` extended with the rescue reference variable bound to the exception instance type. Leaves scope
2811
+ # unchanged when the node carries no reference (bare `rescue` without `=> var`).
3200
2812
  def bind_rescue_reference(rescue_node, scope)
3201
2813
  ref = rescue_node.reference
3202
2814
  return scope unless ref.is_a?(Prism::LocalVariableTargetNode)
@@ -3204,11 +2816,9 @@ module Rigor
3204
2816
  scope.with_local(ref.name, rescue_exception_type(rescue_node, scope))
3205
2817
  end
3206
2818
 
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.
2819
+ # Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
2820
+ # type is `StandardError`. When one or more exception classes are named the types are unioned. Falls back to
2821
+ # `StandardError` for any class that cannot be resolved to a `Singleton` type.
3212
2822
  def rescue_exception_type(rescue_node, scope)
3213
2823
  exceptions = rescue_node.exceptions
3214
2824
  if exceptions.empty?
@@ -3222,20 +2832,18 @@ module Rigor
3222
2832
  end
3223
2833
  end
3224
2834
 
3225
- # ---------------------------------------------------------------
3226
- # `case/in` pattern variable binding helpers
2835
+ # --------------------------------------------------------------- `case/in` pattern variable binding helpers
3227
2836
  # ---------------------------------------------------------------
3228
2837
 
3229
- # Builds the entry scope for an `in` branch by injecting every
3230
- # variable captured by the pattern as a local binding.
2838
+ # Builds the entry scope for an `in` branch by injecting every variable captured by the pattern as a local
2839
+ # binding.
3231
2840
  def apply_in_pattern_bindings(subject, pattern, scope)
3232
2841
  bindings = collect_in_pattern_bindings(subject, pattern, scope)
3233
2842
  bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
3234
2843
  end
3235
2844
 
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).
2845
+ # Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`. Unrecognised pattern
2846
+ # nodes contribute no bindings (fail-soft).
3239
2847
  def collect_in_pattern_bindings(subject, pattern, scope)
3240
2848
  case pattern
3241
2849
  when Prism::CapturePatternNode
@@ -3288,11 +2896,9 @@ module Rigor
3288
2896
  bindings
3289
2897
  end
3290
2898
 
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).
2899
+ # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of the unmatched elements; bind `rest` to
2900
+ # `Array[untyped]` rather than the previous bare `untyped`. Per-element typing waits on subject-aware element-type
2901
+ # extraction (the binder doesn't see the case subject).
3296
2902
  def append_array_splat_binding(bindings, splat)
3297
2903
  return unless splat.is_a?(Prism::SplatNode)
3298
2904
 
@@ -3302,10 +2908,8 @@ module Rigor
3302
2908
  bindings << [target.name, Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
3303
2909
  end
3304
2910
 
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).
2911
+ # `{ key:, **rest }` binds `rest` to a Hash whose keys are Symbols (the only legal key shape for a hash pattern)
2912
+ # and whose values are untyped (the binder can't see the subject's value type).
3309
2913
  def hash_pattern_rest_type
3310
2914
  Type::Combinator.nominal_of(
3311
2915
  "Hash",
@@ -3313,14 +2917,12 @@ module Rigor
3313
2917
  )
3314
2918
  end
3315
2919
 
3316
- # ---------------------------------------------------------------
3317
- # named-capture regex binding (`MatchWriteNode`)
2920
+ # --------------------------------------------------------------- named-capture regex binding (`MatchWriteNode`)
3318
2921
  # ---------------------------------------------------------------
3319
2922
 
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).
2923
+ # `/(?<year>\d+)/ =~ str` — Prism emits a `MatchWriteNode` that wraps the `=~` call and lists the named-capture
2924
+ # targets. Each target is bound to `String | nil` (the capture is absent as nil when the pattern doesn't match or
2925
+ # the group didn't participate).
3324
2926
  def eval_match_write(node)
3325
2927
  match_type, post_scope = sub_eval(node.call, scope)
3326
2928
  string_or_nil = Type::Combinator.union(
@@ -3335,23 +2937,19 @@ module Rigor
3335
2937
  [match_type, bound_scope]
3336
2938
  end
3337
2939
 
3338
- # ---------------------------------------------------------------
3339
- # shared type conversion helper
2940
+ # --------------------------------------------------------------- shared type conversion helper
3340
2941
  # ---------------------------------------------------------------
3341
2942
 
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).
2943
+ # Converts a `Singleton[ClassName]` (the class object) to the corresponding `Nominal[ClassName]` (an instance).
2944
+ # Falls back to `untyped` for carriers that are not Singleton (e.g. Dynamic[Top] when the class could not be
2945
+ # resolved).
3346
2946
  def singleton_to_nominal(type)
3347
2947
  type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
3348
2948
  end
3349
2949
 
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).
2950
+ # Returns the type to bind for a `CapturePatternNode`'s target. Plain class references collapse to the matching
2951
+ # `Nominal[T]`; `AlternationPatternNode` (`Integer | String => x`) unions every alternate's resolved type.
2952
+ # Anything else falls back to `untyped` (the conservative legacy behaviour).
3355
2953
  def pattern_capture_type(value_node, scope)
3356
2954
  if value_node.is_a?(Prism::AlternationPatternNode)
3357
2955
  left = pattern_capture_type(value_node.left, scope)
@@ -3362,13 +2960,10 @@ module Rigor
3362
2960
  end
3363
2961
  end
3364
2962
 
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).
2963
+ # `in PatternA | PatternB` — Ruby requires both alternates to bind the same names, but the binder runs against the
2964
+ # AST and cannot enforce that. We collect bindings from each side and merge by name, unioning types when both
2965
+ # alternates contribute. Names that only one alternate contributes still surface (the parser would have rejected
2966
+ # the case at compile time, so by the time we see it the user's intent is the merged set).
3372
2967
  def collect_alternation_pattern_bindings(subject, pattern, scope)
3373
2968
  left = collect_in_pattern_bindings(subject, pattern.left, scope)
3374
2969
  right = collect_in_pattern_bindings(subject, pattern.right, scope)