rigortype 0.2.6 → 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 (432) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/manual/02-cli-reference.md +2 -1
  4. data/docs/manual/04-diagnostics.md +7 -4
  5. data/docs/manual/06-baseline.md +5 -2
  6. data/docs/manual/08-skills.md +21 -0
  7. data/docs/manual/15-type-protection-coverage.md +6 -4
  8. data/lib/rigor/analysis/baseline.rb +56 -96
  9. data/lib/rigor/analysis/buffer_binding.rb +13 -20
  10. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +29 -48
  11. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +35 -54
  12. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +23 -37
  13. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +16 -23
  14. data/lib/rigor/analysis/check_rules/rule_walk.rb +48 -71
  15. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +19 -28
  16. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +48 -67
  17. data/lib/rigor/analysis/check_rules.rb +174 -274
  18. data/lib/rigor/analysis/dependency_recorder.rb +26 -38
  19. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +13 -25
  20. data/lib/rigor/analysis/dependency_source_inference/builder.rb +17 -30
  21. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +15 -26
  22. data/lib/rigor/analysis/dependency_source_inference/index.rb +45 -76
  23. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +23 -40
  24. data/lib/rigor/analysis/dependency_source_inference/walker.rb +50 -83
  25. data/lib/rigor/analysis/dependency_source_inference.rb +6 -10
  26. data/lib/rigor/analysis/diagnostic.rb +48 -75
  27. data/lib/rigor/analysis/erb_template_detector.rb +13 -18
  28. data/lib/rigor/analysis/fact_store.rb +12 -22
  29. data/lib/rigor/analysis/incremental.rb +47 -63
  30. data/lib/rigor/analysis/incremental_session.rb +74 -100
  31. data/lib/rigor/analysis/project_scan.rb +12 -23
  32. data/lib/rigor/analysis/result.rb +3 -5
  33. data/lib/rigor/analysis/rule_catalog.rb +67 -95
  34. data/lib/rigor/analysis/run_stats.rb +38 -61
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +101 -160
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +121 -188
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +62 -96
  38. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -18
  39. data/lib/rigor/analysis/runner.rb +220 -333
  40. data/lib/rigor/analysis/self_call_resolution_recorder.rb +30 -43
  41. data/lib/rigor/analysis/worker_session.rb +64 -102
  42. data/lib/rigor/ast/type_node.rb +6 -8
  43. data/lib/rigor/ast.rb +6 -10
  44. data/lib/rigor/bleeding_edge.rb +27 -35
  45. data/lib/rigor/builtins/hkt_builtins.rb +7 -14
  46. data/lib/rigor/builtins/imported_refinements.rb +106 -149
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +51 -65
  48. data/lib/rigor/builtins/regex_refinement.rb +32 -43
  49. data/lib/rigor/builtins/static_return_refinements.rb +57 -81
  50. data/lib/rigor/cache/descriptor.rb +58 -94
  51. data/lib/rigor/cache/incremental_snapshot.rb +32 -48
  52. data/lib/rigor/cache/rbs_cache_producer.rb +10 -15
  53. data/lib/rigor/cache/rbs_class_ancestor_table.rb +10 -16
  54. data/lib/rigor/cache/rbs_class_type_param_names.rb +10 -16
  55. data/lib/rigor/cache/rbs_constant_table.rb +8 -13
  56. data/lib/rigor/cache/rbs_descriptor.rb +8 -13
  57. data/lib/rigor/cache/rbs_environment.rb +11 -19
  58. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +11 -19
  59. data/lib/rigor/cache/rbs_known_class_names.rb +8 -12
  60. data/lib/rigor/cache/store.rb +96 -151
  61. data/lib/rigor/cli/annotate_command.rb +51 -88
  62. data/lib/rigor/cli/baseline_command.rb +10 -17
  63. data/lib/rigor/cli/check_command.rb +121 -199
  64. data/lib/rigor/cli/check_runner_factory.rb +8 -14
  65. data/lib/rigor/cli/ci_detector.rb +9 -14
  66. data/lib/rigor/cli/command.rb +8 -14
  67. data/lib/rigor/cli/coverage_command.rb +45 -20
  68. data/lib/rigor/cli/coverage_mutation.rb +14 -19
  69. data/lib/rigor/cli/coverage_report.rb +0 -2
  70. data/lib/rigor/cli/coverage_scan.rb +7 -11
  71. data/lib/rigor/cli/diagnostic_formats.rb +43 -62
  72. data/lib/rigor/cli/diff_command.rb +10 -20
  73. data/lib/rigor/cli/docs_command.rb +26 -40
  74. data/lib/rigor/cli/doctor_command.rb +8 -9
  75. data/lib/rigor/cli/explain_command.rb +4 -7
  76. data/lib/rigor/cli/fused_protection_renderer.rb +4 -5
  77. data/lib/rigor/cli/fused_protection_report.rb +6 -8
  78. data/lib/rigor/cli/lsp_command.rb +11 -19
  79. data/lib/rigor/cli/mcp_command.rb +4 -6
  80. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -4
  81. data/lib/rigor/cli/mutation_protection_report.rb +6 -9
  82. data/lib/rigor/cli/options.rb +11 -19
  83. data/lib/rigor/cli/plugin_command.rb +18 -30
  84. data/lib/rigor/cli/plugins_command.rb +29 -51
  85. data/lib/rigor/cli/plugins_renderer.rb +12 -20
  86. data/lib/rigor/cli/prism_colorizer.rb +13 -19
  87. data/lib/rigor/cli/protection_renderer.rb +5 -7
  88. data/lib/rigor/cli/protection_report.rb +27 -17
  89. data/lib/rigor/cli/renderable.rb +4 -6
  90. data/lib/rigor/cli/show_bleedingedge_command.rb +5 -8
  91. data/lib/rigor/cli/sig_gen_command.rb +14 -26
  92. data/lib/rigor/cli/skill_command.rb +70 -36
  93. data/lib/rigor/cli/skill_describe.rb +40 -64
  94. data/lib/rigor/cli/trace_command.rb +9 -16
  95. data/lib/rigor/cli/trace_renderer.rb +35 -51
  96. data/lib/rigor/cli/triage_command.rb +6 -10
  97. data/lib/rigor/cli/triage_renderer.rb +1 -2
  98. data/lib/rigor/cli/type_of_command.rb +14 -23
  99. data/lib/rigor/cli/type_of_renderer.rb +3 -5
  100. data/lib/rigor/cli/type_scan_command.rb +6 -10
  101. data/lib/rigor/cli/type_scan_renderer.rb +3 -5
  102. data/lib/rigor/cli/type_scan_report.rb +2 -3
  103. data/lib/rigor/cli/upgrade_command.rb +2 -3
  104. data/lib/rigor/cli.rb +16 -28
  105. data/lib/rigor/config_audit.rb +35 -44
  106. data/lib/rigor/configuration/dependencies.rb +41 -70
  107. data/lib/rigor/configuration/severity_profile.rb +28 -42
  108. data/lib/rigor/configuration.rb +150 -241
  109. data/lib/rigor/environment/bundle_sig_discovery.rb +74 -118
  110. data/lib/rigor/environment/class_registry.rb +19 -26
  111. data/lib/rigor/environment/constant_type_cache_holder.rb +14 -21
  112. data/lib/rigor/environment/hkt_registry_holder.rb +6 -12
  113. data/lib/rigor/environment/lockfile_resolver.rb +31 -47
  114. data/lib/rigor/environment/rbs_collection_discovery.rb +39 -63
  115. data/lib/rigor/environment/rbs_coverage_report.rb +24 -42
  116. data/lib/rigor/environment/rbs_loader.rb +433 -503
  117. data/lib/rigor/environment/reflection.rb +28 -53
  118. data/lib/rigor/environment/reporters.rb +13 -24
  119. data/lib/rigor/environment.rb +176 -291
  120. data/lib/rigor/flow_contribution/conflict.rb +16 -27
  121. data/lib/rigor/flow_contribution/element.rb +7 -12
  122. data/lib/rigor/flow_contribution/fact.rb +33 -57
  123. data/lib/rigor/flow_contribution/merge_result.rb +6 -9
  124. data/lib/rigor/flow_contribution/merger.rb +32 -47
  125. data/lib/rigor/flow_contribution.rb +37 -55
  126. data/lib/rigor/inference/acceptance.rb +133 -219
  127. data/lib/rigor/inference/block_parameter_binder.rb +52 -82
  128. data/lib/rigor/inference/body_fixpoint.rb +22 -30
  129. data/lib/rigor/inference/budget_trace.rb +43 -63
  130. data/lib/rigor/inference/builtins/array_catalog.rb +4 -6
  131. data/lib/rigor/inference/builtins/comparable_catalog.rb +4 -6
  132. data/lib/rigor/inference/builtins/complex_catalog.rb +13 -19
  133. data/lib/rigor/inference/builtins/date_catalog.rb +38 -67
  134. data/lib/rigor/inference/builtins/encoding_catalog.rb +22 -36
  135. data/lib/rigor/inference/builtins/enumerable_catalog.rb +4 -6
  136. data/lib/rigor/inference/builtins/exception_catalog.rb +38 -57
  137. data/lib/rigor/inference/builtins/hash_catalog.rb +9 -14
  138. data/lib/rigor/inference/builtins/method_catalog.rb +29 -48
  139. data/lib/rigor/inference/builtins/numeric_catalog.rb +13 -18
  140. data/lib/rigor/inference/builtins/pathname_catalog.rb +8 -13
  141. data/lib/rigor/inference/builtins/proc_catalog.rb +38 -62
  142. data/lib/rigor/inference/builtins/random_catalog.rb +21 -32
  143. data/lib/rigor/inference/builtins/range_catalog.rb +12 -21
  144. data/lib/rigor/inference/builtins/rational_catalog.rb +9 -15
  145. data/lib/rigor/inference/builtins/re_catalog.rb +29 -48
  146. data/lib/rigor/inference/builtins/set_catalog.rb +18 -27
  147. data/lib/rigor/inference/builtins/string_catalog.rb +12 -19
  148. data/lib/rigor/inference/builtins/struct_catalog.rb +18 -30
  149. data/lib/rigor/inference/builtins/time_catalog.rb +28 -44
  150. data/lib/rigor/inference/closure_escape_analyzer.rb +44 -67
  151. data/lib/rigor/inference/coverage_scanner.rb +13 -18
  152. data/lib/rigor/inference/def_return_typer.rb +8 -14
  153. data/lib/rigor/inference/dynamic_origin.rb +23 -20
  154. data/lib/rigor/inference/expression_typer.rb +774 -1152
  155. data/lib/rigor/inference/fallback.rb +8 -12
  156. data/lib/rigor/inference/fallback_tracer.rb +4 -10
  157. data/lib/rigor/inference/flow_tracer.rb +25 -36
  158. data/lib/rigor/inference/hkt_body.rb +45 -68
  159. data/lib/rigor/inference/hkt_body_parser.rb +33 -52
  160. data/lib/rigor/inference/hkt_reducer.rb +38 -59
  161. data/lib/rigor/inference/hkt_registry.rb +50 -76
  162. data/lib/rigor/inference/indexed_narrowing.rb +55 -81
  163. data/lib/rigor/inference/macro_block_self_type.rb +21 -34
  164. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +11 -18
  165. data/lib/rigor/inference/method_dispatcher/block_folding.rb +49 -83
  166. data/lib/rigor/inference/method_dispatcher/call_context.rb +16 -23
  167. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +13 -22
  168. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +307 -454
  169. data/lib/rigor/inference/method_dispatcher/data_folding.rb +24 -34
  170. data/lib/rigor/inference/method_dispatcher/file_folding.rb +22 -37
  171. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +52 -81
  172. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +51 -78
  173. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +60 -97
  174. data/lib/rigor/inference/method_dispatcher/math_folding.rb +23 -34
  175. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +18 -26
  176. data/lib/rigor/inference/method_dispatcher/method_folding.rb +37 -71
  177. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +114 -182
  178. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +122 -193
  179. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +15 -24
  180. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +64 -93
  181. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +31 -45
  182. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -9
  183. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +356 -511
  184. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +22 -32
  185. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +15 -21
  186. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +66 -82
  187. data/lib/rigor/inference/method_dispatcher/time_folding.rb +10 -15
  188. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -13
  189. data/lib/rigor/inference/method_dispatcher.rb +378 -524
  190. data/lib/rigor/inference/method_parameter_binder.rb +81 -114
  191. data/lib/rigor/inference/multi_target_binder.rb +51 -68
  192. data/lib/rigor/inference/mutation_widening.rb +115 -158
  193. data/lib/rigor/inference/narrowing.rb +509 -727
  194. data/lib/rigor/inference/origin_lookup.rb +38 -0
  195. data/lib/rigor/inference/parameter_inference_collector.rb +100 -100
  196. data/lib/rigor/inference/precision_scanner.rb +26 -35
  197. data/lib/rigor/inference/project_patched_methods.rb +20 -32
  198. data/lib/rigor/inference/project_patched_scanner.rb +23 -37
  199. data/lib/rigor/inference/protection_scanner.rb +16 -18
  200. data/lib/rigor/inference/rbs_type_translator.rb +49 -66
  201. data/lib/rigor/inference/scope_indexer.rb +473 -817
  202. data/lib/rigor/inference/statement_evaluator.rb +706 -1115
  203. data/lib/rigor/inference/struct_fold_safety.rb +34 -48
  204. data/lib/rigor/inference/synthetic_method.rb +9 -16
  205. data/lib/rigor/inference/synthetic_method_index.rb +20 -35
  206. data/lib/rigor/inference/synthetic_method_scanner.rb +63 -103
  207. data/lib/rigor/language_server/buffer_resolution.rb +6 -9
  208. data/lib/rigor/language_server/buffer_table.rb +11 -18
  209. data/lib/rigor/language_server/completion_provider.rb +69 -116
  210. data/lib/rigor/language_server/debouncer.rb +16 -25
  211. data/lib/rigor/language_server/diagnostic_publisher.rb +26 -40
  212. data/lib/rigor/language_server/document_symbol_provider.rb +14 -19
  213. data/lib/rigor/language_server/folding_range_provider.rb +11 -19
  214. data/lib/rigor/language_server/hover_provider.rb +8 -11
  215. data/lib/rigor/language_server/hover_renderer.rb +56 -93
  216. data/lib/rigor/language_server/loop.rb +14 -19
  217. data/lib/rigor/language_server/project_context.rb +46 -74
  218. data/lib/rigor/language_server/selection_range_provider.rb +9 -12
  219. data/lib/rigor/language_server/server.rb +55 -83
  220. data/lib/rigor/language_server/signature_help_provider.rb +29 -46
  221. data/lib/rigor/language_server/synchronized_writer.rb +4 -7
  222. data/lib/rigor/language_server/uri.rb +8 -13
  223. data/lib/rigor/language_server.rb +4 -6
  224. data/lib/rigor/mcp/loop.rb +2 -3
  225. data/lib/rigor/mcp/server.rb +4 -7
  226. data/lib/rigor/mcp.rb +3 -6
  227. data/lib/rigor/plugin/access_denied_error.rb +5 -8
  228. data/lib/rigor/plugin/additional_initializer.rb +21 -31
  229. data/lib/rigor/plugin/base.rb +335 -518
  230. data/lib/rigor/plugin/blueprint.rb +14 -23
  231. data/lib/rigor/plugin/box.rb +18 -29
  232. data/lib/rigor/plugin/fact_store.rb +16 -26
  233. data/lib/rigor/plugin/inflector.rb +37 -53
  234. data/lib/rigor/plugin/io_boundary.rb +33 -56
  235. data/lib/rigor/plugin/isolation.rb +42 -55
  236. data/lib/rigor/plugin/load_error.rb +10 -15
  237. data/lib/rigor/plugin/loader.rb +30 -49
  238. data/lib/rigor/plugin/macro/block_as_method.rb +20 -32
  239. data/lib/rigor/plugin/macro/heredoc_template.rb +35 -58
  240. data/lib/rigor/plugin/macro/nested_class_template.rb +22 -36
  241. data/lib/rigor/plugin/macro/trait_registry.rb +34 -51
  242. data/lib/rigor/plugin/macro.rb +10 -15
  243. data/lib/rigor/plugin/manifest.rb +85 -144
  244. data/lib/rigor/plugin/node_context.rb +14 -22
  245. data/lib/rigor/plugin/node_rule_walk.rb +49 -74
  246. data/lib/rigor/plugin/protocol_contract.rb +25 -39
  247. data/lib/rigor/plugin/registry.rb +132 -205
  248. data/lib/rigor/plugin/services.rb +21 -33
  249. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -19
  250. data/lib/rigor/plugin/trust_policy.rb +24 -38
  251. data/lib/rigor/plugin/type_node_resolver.rb +15 -24
  252. data/lib/rigor/protection/diagnostic_oracle.rb +11 -13
  253. data/lib/rigor/protection/mutation_scanner.rb +27 -35
  254. data/lib/rigor/protection/mutator.rb +50 -70
  255. data/lib/rigor/protection/test_suite_oracle.rb +20 -27
  256. data/lib/rigor/rbs_extended/conformance_checker.rb +48 -68
  257. data/lib/rigor/rbs_extended/hkt_directives.rb +28 -54
  258. data/lib/rigor/rbs_extended/reporter.rb +24 -40
  259. data/lib/rigor/rbs_extended.rb +107 -197
  260. data/lib/rigor/reflection.rb +68 -86
  261. data/lib/rigor/scope/discovery_index.rb +14 -19
  262. data/lib/rigor/scope.rb +255 -310
  263. data/lib/rigor/sig_gen/classification.rb +6 -10
  264. data/lib/rigor/sig_gen/generator.rb +208 -309
  265. data/lib/rigor/sig_gen/layout_index.rb +12 -20
  266. data/lib/rigor/sig_gen/method_candidate.rb +15 -15
  267. data/lib/rigor/sig_gen/observation_collector.rb +38 -70
  268. data/lib/rigor/sig_gen/observed_call.rb +13 -23
  269. data/lib/rigor/sig_gen/path_mapper.rb +17 -29
  270. data/lib/rigor/sig_gen/renderer.rb +7 -13
  271. data/lib/rigor/sig_gen/type_elaborator.rb +15 -28
  272. data/lib/rigor/sig_gen/write_result.rb +8 -16
  273. data/lib/rigor/sig_gen/writer.rb +141 -173
  274. data/lib/rigor/sig_gen.rb +3 -6
  275. data/lib/rigor/signature_path_audit.rb +24 -30
  276. data/lib/rigor/source/constant_path.rb +10 -14
  277. data/lib/rigor/source/literals.rb +31 -45
  278. data/lib/rigor/source/node_locator.rb +9 -11
  279. data/lib/rigor/source/node_walker.rb +9 -13
  280. data/lib/rigor/source.rb +3 -4
  281. data/lib/rigor/testing.rb +16 -20
  282. data/lib/rigor/triage/catalogue.rb +38 -62
  283. data/lib/rigor/triage.rb +31 -52
  284. data/lib/rigor/trinary.rb +9 -13
  285. data/lib/rigor/type/acceptance_router.rb +4 -6
  286. data/lib/rigor/type/accepts_result.rb +10 -14
  287. data/lib/rigor/type/app.rb +19 -27
  288. data/lib/rigor/type/bot.rb +4 -6
  289. data/lib/rigor/type/bound_method.rb +10 -15
  290. data/lib/rigor/type/combinator.rb +165 -257
  291. data/lib/rigor/type/constant.rb +23 -34
  292. data/lib/rigor/type/data_class.rb +10 -15
  293. data/lib/rigor/type/data_instance.rb +14 -20
  294. data/lib/rigor/type/difference.rb +21 -32
  295. data/lib/rigor/type/dynamic.rb +3 -5
  296. data/lib/rigor/type/hash_shape.rb +32 -18
  297. data/lib/rigor/type/integer_range.rb +11 -16
  298. data/lib/rigor/type/intersection.rb +27 -42
  299. data/lib/rigor/type/nominal.rb +10 -15
  300. data/lib/rigor/type/plain_lattice.rb +9 -13
  301. data/lib/rigor/type/refined.rb +67 -114
  302. data/lib/rigor/type/singleton.rb +4 -6
  303. data/lib/rigor/type/struct_class.rb +11 -16
  304. data/lib/rigor/type/struct_instance.rb +15 -21
  305. data/lib/rigor/type/tuple.rb +14 -19
  306. data/lib/rigor/type/union.rb +30 -42
  307. data/lib/rigor/type_node/generic.rb +14 -26
  308. data/lib/rigor/type_node/identifier.rb +12 -19
  309. data/lib/rigor/type_node.rb +3 -12
  310. data/lib/rigor/value_semantics.rb +16 -21
  311. data/lib/rigor/version.rb +1 -1
  312. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +18 -30
  313. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +16 -29
  314. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +12 -23
  315. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +22 -39
  316. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +26 -46
  317. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +51 -87
  318. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +10 -17
  319. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +26 -39
  320. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +118 -176
  321. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +30 -51
  322. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +57 -92
  323. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +107 -83
  324. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +13 -22
  325. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +15 -25
  326. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +8 -13
  327. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +16 -24
  328. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +30 -47
  329. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +79 -129
  330. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +36 -57
  331. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +27 -41
  332. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +16 -24
  333. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +148 -236
  334. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +3 -5
  335. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +8 -14
  336. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +9 -15
  337. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +4 -7
  338. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +23 -41
  339. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +3 -5
  340. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +6 -10
  341. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +8 -11
  342. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +30 -45
  343. data/plugins/rigor-devise/lib/rigor-devise.rb +3 -5
  344. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +43 -69
  345. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +26 -35
  346. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +3 -5
  347. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +30 -47
  348. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +3 -5
  349. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +67 -113
  350. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +29 -46
  351. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +3 -5
  352. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -20
  353. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +14 -23
  354. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +2 -3
  355. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +22 -37
  356. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +28 -46
  357. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +13 -17
  358. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -47
  359. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +52 -80
  360. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +21 -31
  361. data/plugins/rigor-graphql/lib/rigor-graphql.rb +3 -5
  362. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +20 -36
  363. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +23 -30
  364. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +45 -75
  365. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +22 -36
  366. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +22 -41
  367. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +17 -32
  368. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +6 -12
  369. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +4 -8
  370. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +17 -32
  371. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -18
  372. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +58 -92
  373. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +20 -34
  374. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +20 -31
  375. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +49 -82
  376. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +55 -92
  377. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +51 -90
  378. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +17 -30
  379. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +38 -70
  380. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +42 -74
  381. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +280 -497
  382. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +54 -89
  383. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +32 -54
  384. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +8 -13
  385. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +9 -16
  386. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +24 -39
  387. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +13 -22
  388. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +34 -56
  389. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +22 -42
  390. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +43 -75
  391. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +27 -39
  392. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +14 -19
  393. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +20 -34
  394. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +28 -47
  395. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +21 -37
  396. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +13 -22
  397. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +18 -31
  398. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +6 -10
  399. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +14 -29
  400. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +23 -41
  401. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +3 -5
  402. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +23 -38
  403. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +57 -96
  404. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +22 -35
  405. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +41 -69
  406. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +6 -10
  407. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +24 -41
  408. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +29 -50
  409. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +55 -88
  410. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +182 -328
  411. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +21 -29
  412. data/plugins/rigor-statesman/lib/rigor-statesman.rb +3 -5
  413. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +23 -37
  414. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +4 -6
  415. data/sig/rigor/scope.rbs +6 -0
  416. data/skills/rigor-ask/SKILL.md +21 -1
  417. data/skills/rigor-baseline-reduce/SKILL.md +16 -0
  418. data/skills/rigor-ci-setup/SKILL.md +96 -249
  419. data/skills/rigor-doctor/SKILL.md +39 -49
  420. data/skills/rigor-doctor/references/01-checks.md +52 -0
  421. data/skills/rigor-editor-setup/SKILL.md +14 -0
  422. data/skills/rigor-mcp-setup/SKILL.md +14 -0
  423. data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
  424. data/skills/rigor-plugin-author/SKILL.md +16 -0
  425. data/skills/rigor-plugin-review/SKILL.md +174 -0
  426. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
  427. data/skills/rigor-plugin-tune/SKILL.md +21 -2
  428. data/skills/rigor-project-init/SKILL.md +16 -0
  429. data/skills/rigor-protection-uplift/SKILL.md +15 -0
  430. data/skills/rigor-rbs-setup/SKILL.md +15 -0
  431. data/skills/rigor-upgrade/SKILL.md +16 -0
  432. metadata +8 -4
@@ -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,15 +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.
785
- def eval_begin_primary(node)
786
- eval_begin_primary_under(node, scope)
787
- end
788
-
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.
789
704
  def eval_begin_primary_under(node, entry_scope)
790
705
  body_type, body_scope =
791
706
  if node.statements
@@ -802,17 +717,12 @@ module Rigor
802
717
  end
803
718
  end
804
719
 
805
- # B2.1 — return a widened entry scope when at least one
806
- # rescue arm in `rescue_chain` contains a `Prism::RetryNode`
807
- # AND that arm rebinds at least one local or ivar relative
808
- # to the original entry. Returns nil when no widening is
809
- # needed (no retry, or no rebinds reachable across the
810
- # 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).
811
723
  #
812
- # Always-safe: the widening can only LOSE precision; it
813
- # never invents a fact (Nominal envelope is a superset of
814
- # the Constant / shape carrier it widens from). Convergent
815
- # 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
816
726
  # widened form against the engine's current carrier set.
817
727
  def widen_entry_for_retry(entry_scope, rescue_chain)
818
728
  widened = nil
@@ -831,9 +741,8 @@ module Rigor
831
741
  def arm_contains_retry?(node)
832
742
  return false unless node.is_a?(Prism::Node)
833
743
  return true if node.is_a?(Prism::RetryNode)
834
- # Don't descend into nested blocks / defs / classes /
835
- # modules a `retry` inside a nested method body or
836
- # 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.
837
746
  return false if node.is_a?(Prism::DefNode) ||
838
747
  node.is_a?(Prism::ClassNode) ||
839
748
  node.is_a?(Prism::ModuleNode) ||
@@ -844,8 +753,7 @@ module Rigor
844
753
 
845
754
  def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
846
755
  scope_acc = accumulator
847
- # Walk every local visible in either side, compare types,
848
- # widen to Nominal envelope on a difference.
756
+ # Walk every local visible in either side, compare types, widen to Nominal envelope on a difference.
849
757
  local_keys = arm_post_scope.locals.keys | entry_scope.locals.keys
850
758
  local_keys.each do |name|
851
759
  pre = entry_scope.local(name)
@@ -868,8 +776,7 @@ module Rigor
868
776
  end
869
777
 
870
778
  def retry_widened_type(pre, post)
871
- # `pre` is nil when the local was introduced inside the
872
- # 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
873
780
  # primary body's entry — widen the post type itself.
874
781
  envelope = nominal_envelope_for(post)
875
782
  return envelope if pre.nil?
@@ -877,11 +784,9 @@ module Rigor
877
784
  nominal_envelope_for(Type::Combinator.union(pre, envelope))
878
785
  end
879
786
 
880
- # Nominal envelope of a value type: widens Constant /
881
- # Tuple / HashShape carriers to the underlying class's
882
- # `Nominal`, preserving everything else (`Nominal`,
883
- # `Union` of non-shape members, `Top`, `Dynamic`, `Bot`).
884
- # 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.
885
790
  def nominal_envelope_for(type)
886
791
  members = type.is_a?(Type::Union) ? type.members : [type]
887
792
  widened = members.map { |m| nominal_envelope_member(m) }
@@ -920,92 +825,75 @@ module Rigor
920
825
  eval_branch_or_nil(node.statements, scope)
921
826
  end
922
827
 
923
- # `while pred; body; end` / `until pred; body; end`. The body
924
- # might run zero or more times, so half-bound names degrade to
925
- # `T | nil` in the post-loop scope. The loop expression itself
926
- # types as `Constant[nil]` (Slice 3 phase 1), reflecting the
927
- # 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.
928
831
  def eval_loop(node)
929
832
  _pred_type, post_pred = sub_eval(node.predicate, scope)
930
833
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
931
834
 
932
- # The historical single body pass joined with the pre-loop scope.
933
- # This continues to carry everything the fixpoint does NOT track:
934
- # receiver-mutation widening of non-rebound locals (`buf.push(i)`
935
- # widens `buf`'s Tuple), body-introduced locals' nil-injection, and
936
- # 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
937
838
  # rebound-local bindings it corrects.
938
839
  #
939
- # The pass runs under a break sink so a `break`-path binding
940
- # (`flag = true; break`) the fall-through `body_scope` drops is
941
- # 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.
942
842
  break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
943
843
  base_scope = join_with_nil_injection(post_pred, body_scope)
944
844
 
945
845
  rebound, body_first = loop_body_local_writes(node.statements, post_pred)
946
846
  names = rebound + body_first
947
847
 
948
- # Fast path: a loop whose body rebinds no local skips the rebind
949
- # fixpoint, but still needs the slice-C content writeback (a loop may
950
- # content-mutate a collection without rebinding any local — `acc <<
951
- # 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.
952
851
  if names.empty?
953
852
  fast = loop_content_writeback(node.statements, base_scope)
954
853
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
955
854
  end
956
855
 
957
856
  post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
958
- # Recover `break`-path bindings the fall-through dropped (`flag = true;
959
- # 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`).
960
859
  post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
961
860
  post_loop = narrow_loop_exit_edge(node, post_loop)
962
861
  [Type::Combinator.constant_of(nil), post_loop]
963
862
  end
964
863
 
965
- # The continuation scope for a loop whose body rebinds locals: the
966
- # ADR-56 slice-B rebind fixpoint overlaid on `base_scope`, then the
967
- # 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.
968
866
  def converged_loop_scope(node, post_pred, base_scope, names, body_first)
969
- # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
970
- # may compound (`d *= 2`), so the historical single body pass joined
971
- # with the pre-loop scope kept stale folded constants
972
- # (`d = 1; while …; d *= 2; end` `1 | 2`, never reaching `4, 8`).
973
- # Fold each body-written local's continuation binding through the same
974
- # capped fixpoint slice A uses for non-escaping block captures. Seed:
975
- # a pre-existing local seeds with its post-predicate binding; a local
976
- # FIRST assigned inside the body seeds with `nil` so the 0-iteration
977
- # 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.
978
873
  result = loop_rebind_fixpoint(node, post_pred, names, body_first)
979
- # Display-path re-record: the fixpoint's body re-evaluations fire
980
- # `on_enter` with the cap-N INTERMEDIATE assumptions, so the
981
- # last-visit-wins scope index would annotate loop-body lines with
982
- # stale pre-convergence constants. One extra pass from the converged
983
- # 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.
984
877
  record_converged_loop_body(node, post_pred, result, names, body_first)
985
878
  post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
986
- # ADR-56 slice C — loop-body receiver-content element-type join. A loop
987
- # that content-mutates a collection (`acc << n`) keeps only the seed's
988
- # element types after the single-pass widen; join the appended/stored
989
- # types into the continuation collection (pre-state read from
990
- # `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).
991
883
  loop_content_writeback(node.statements, post_loop)
992
884
  end
993
885
 
994
- # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
995
- # loop exits PRECISELY on the predicate's exit edge: `while` exits when
996
- # `pred` is falsey, `until` when `pred` is truthy. So after the loop the
997
- # predicate-assignment target carries the exit polarity `until line =
998
- # io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
999
- # 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
1000
890
  # `Narrowing.predicate_scopes` to the continuation scope.
1001
891
  #
1002
- # Guarded against `break`: a `break` exits the loop WITHOUT the predicate
1003
- # ever going false (`while line = gets; break if done; end` can leave
1004
- # `line` truthy on a `while`, or exit before the `until` predicate fires),
1005
- # so the exit-edge proof does not hold and the loop is left un-narrowed.
1006
- # `break` inside a NESTED loop/block does not target this loop, but a
1007
- # nested-loop `break` is rare in predicate-assignment loops and the
1008
- # 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.
1009
897
  def narrow_loop_exit_edge(node, post_loop)
1010
898
  return post_loop if loop_body_breaks?(node.statements)
1011
899
 
@@ -1013,10 +901,9 @@ module Rigor
1013
901
  node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
1014
902
  end
1015
903
 
1016
- # True when the loop body can `break` out of THIS loop. Conservatively
1017
- # treats any `BreakNode` under the body as a break for this loop (a
1018
- # break inside a nested loop/block actually targets the inner construct,
1019
- # 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).
1020
907
  def loop_body_breaks?(statements)
1021
908
  return false if statements.nil?
1022
909
 
@@ -1027,10 +914,8 @@ module Rigor
1027
914
  found
1028
915
  end
1029
916
 
1030
- # A `break` inside one of these nested constructs targets the inner
1031
- # construct (an inner loop, a block's method, a nested def), NOT the
1032
- # lexical loop — so the directly-targeting break scan does not descend
1033
- # 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.
1034
919
  BREAK_BOUNDARY_NODES = [
1035
920
  Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
1036
921
  Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
@@ -1038,11 +923,9 @@ module Rigor
1038
923
  ].freeze
1039
924
  private_constant :BREAK_BOUNDARY_NODES
1040
925
 
1041
- # The `BreakNode`s that lexically target THIS loop — reachable from the
1042
- # body without crossing a nested loop / block / def boundary. An
1043
- # identity-keyed Hash used as a membership set to filter the collected
1044
- # break scopes (the thread-local sink also collects breaks from nested
1045
- # 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).
1046
929
  def directly_targeting_breaks(statements)
1047
930
  found = {}.compare_by_identity
1048
931
  collect_direct_breaks(statements, found)
@@ -1060,10 +943,9 @@ module Rigor
1060
943
  end
1061
944
  end
1062
945
 
1063
- # Installs a fresh thread-local break sink around `yield` (a loop-body
1064
- # evaluation), returning `[collected, yield_result]`. Stacks: the
1065
- # previous sink is restored on exit so a nested loop's breaks do not
1066
- # 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.
1067
949
  def collect_break_scopes
1068
950
  previous = Thread.current[BREAK_SINK_KEY]
1069
951
  sink = []
@@ -1076,22 +958,19 @@ module Rigor
1076
958
  [sink, result]
1077
959
  end
1078
960
 
1079
- # Runs a loop body's single pass under a break sink. Returns the
1080
- # directly-targeting break set, the collected break scopes, and the
1081
- # fall-through body scope — the three inputs the continuation's
1082
- # {#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`.
1083
964
  def capture_loop_body_breaks(statements, entry)
1084
965
  targets = directly_targeting_breaks(statements)
1085
966
  sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
1086
967
  [targets, sink, body_scope]
1087
968
  end
1088
969
 
1089
- # Joins each directly-targeting break's body-written local bindings into
1090
- # the loop continuation, so a `break`-path binding the fall-through
1091
- # dropped is recovered (`flag = true; break` -> `flag` becomes `false |
1092
- # true`). Only loop-body-written names are joined an unchanged local
1093
- # unions to itself; a break-only-written local is already present via the
1094
- # 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.
1095
974
  def join_break_scopes(continuation, sink, targeting, names)
1096
975
  return continuation if sink.empty? || names.empty?
1097
976
 
@@ -1108,14 +987,11 @@ module Rigor
1108
987
  end
1109
988
  end
1110
989
 
1111
- # Joins loop-body content mutations into the continuation collection
1112
- # bindings. The mutator arguments are typed against `post_loop`, whose
1113
- # locals already carry the loop-body fixpoint widening (so an
1114
- # appended `n` that the loop decrements types `Integer`, not its
1115
- # entry `Constant[3]` otherwise only the first iteration's value
1116
- # would be captured, an unsound under-approximation). Pre-state is
1117
- # read from `post_loop` too. A loop body shares the surrounding scope,
1118
- # 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).
1119
995
  def loop_content_writeback(statements, post_loop)
1120
996
  return post_loop if statements.nil?
1121
997
 
@@ -1132,12 +1008,9 @@ module Rigor
1132
1008
  end
1133
1009
  end
1134
1010
 
1135
- # Re-evaluates the loop body once from the converged fixpoint
1136
- # bindings, solely for the `on_enter` side effect of re-recording
1137
- # the body's per-node entry scopes. Gated behind the
1138
- # display-path-only `converged_loop_recording` flag so the check
1139
- # path neither pays the extra body evaluation nor risks any
1140
- # 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.
1141
1014
  def record_converged_loop_body(node, post_pred, bindings, names, body_first)
1142
1015
  return unless @converged_loop_recording && @on_enter
1143
1016
 
@@ -1145,11 +1018,10 @@ module Rigor
1145
1018
  nil
1146
1019
  end
1147
1020
 
1148
- # Runs the slice-B loop-body rebind fixpoint, returning the per-name
1149
- # continuation binding. Seed: a pre-existing local seeds with its
1150
- # post-predicate binding; a local FIRST assigned inside the body seeds
1151
- # with `nil` so the 0-iteration path (the body may never run) degrades
1152
- # 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.
1153
1025
  def loop_rebind_fixpoint(node, post_pred, names, body_first)
1154
1026
  nil_const = Type::Combinator.constant_of(nil)
1155
1027
  seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
@@ -1161,13 +1033,10 @@ module Rigor
1161
1033
  )
1162
1034
  end
1163
1035
 
1164
- # Names of locals the loop body can rebind, partitioned into those
1165
- # already bound in `base_scope` (their pre-loop binding seeds the
1166
- # fixpoint) and those FIRST assigned inside the body (no pre-state, so
1167
- # they seed with `nil` for 0-iteration soundness). A loop body
1168
- # introduces no new binding scope — every write leaks to the
1169
- # surrounding scope — so unlike a block there is no introduced-name
1170
- # 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.
1171
1040
  def loop_body_local_writes(statements, base_scope)
1172
1041
  pre_existing = []
1173
1042
  body_first = []
@@ -1184,30 +1053,22 @@ module Rigor
1184
1053
  [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
1185
1054
  end
1186
1055
 
1187
- # Evaluates the loop body once with each fixpoint-tracked local bound
1188
- # to the supplied running assumption and returns the per-name exit
1189
- # 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`.
1190
1058
  #
1191
- # The body runs from `post_pred` overlaid with the assumptions, then
1192
- # narrowed by the predicate's loop-entry edge: a `while` body only
1193
- # runs when the predicate is TRUTHY, an `until` body only when it is
1194
- # FALSEY. Re-applying that narrowing per iteration keeps loop-carried
1195
- # narrowing sound without it, an accumulator whose rebind can
1196
- # introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
1197
- # `while prefix && …`) would re-enter the body with `nil` un-narrowed
1198
- # and false-fire `possible nil receiver` on the guarded re-read. The
1199
- # historical single body pass (which seeds these locals from their
1200
- # never-nil pre-loop binding) did not need this; the fixpoint, which
1201
- # 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.
1202
1066
  #
1203
- # A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
1204
- # into the body-entry scope: when the body runs it assigns the local
1205
- # before any use, exactly as the historical single body pass saw it.
1206
- # Its `nil` seed exists only to model the 0-iteration path and is kept
1207
- # as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
1208
- # back into the body re-evaluation would leak it past a condition-form
1209
- # assignment the engine does not thread into the branch (`if exps.size
1210
- # > (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.
1211
1072
  def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1212
1073
  overlaid = bindings.except(*body_first)
1213
1074
  entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
@@ -1217,16 +1078,12 @@ module Rigor
1217
1078
  names.to_h { |name| [name, exit_scope.local(name)] }
1218
1079
  end
1219
1080
 
1220
- # `for index in collection; body; end`. Unlike `each {}` blocks,
1221
- # `for` does NOT create a new variable scope: the index variable
1222
- # AND every local written in the body leak to the surrounding
1223
- # scope. The collection is evaluated once; the body runs zero or
1224
- # more times, so the post-loop scope is the join of the
1225
- # no-iteration scope (just `post_collection`) and the body scope,
1226
- # with half-bound names degraded to `T | nil` via nil-injection.
1227
- # The loop expression itself types as `Constant[nil]` (the common
1228
- # case where no `break VALUE` is observed), matching the policy
1229
- # `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`.
1230
1087
  def eval_for(node)
1231
1088
  coll_type, post_coll = sub_eval(node.collection, scope)
1232
1089
  element_type = for_iteration_element_type(coll_type)
@@ -1236,10 +1093,9 @@ module Rigor
1236
1093
  return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
1237
1094
  end
1238
1095
 
1239
- # Run the body pass under a break sink so a `break`-path binding the
1240
- # fall-through drops is recovered into the continuation (the `for`
1241
- # sibling of `eval_loop`'s break join; `for` has no fixpoint, so the
1242
- # 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).
1243
1099
  break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
1244
1100
  continuation = join_with_nil_injection(post_coll, body_scope)
1245
1101
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
@@ -1247,15 +1103,11 @@ module Rigor
1247
1103
  [Type::Combinator.constant_of(nil), continuation]
1248
1104
  end
1249
1105
 
1250
- # `for x in coll` is semantically `coll.each { |x| ... }`. We
1251
- # ask the method dispatcher for `coll.each`'s expected block
1252
- # parameter types that path consults RBS and the iterator
1253
- # dispatch table, which is more precise than the structural
1254
- # `collection_element_type` fallback (it knows, e.g., that
1255
- # `Hash[K, V]#each` yields `[K, V]` even when the receiver is
1256
- # not a literal Hash carrier in our local lattice). When the
1257
- # dispatcher returns nothing (no signature, unknown receiver)
1258
- # 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.
1259
1111
  def for_iteration_element_type(coll_type)
1260
1112
  structural = collection_element_type(coll_type)
1261
1113
  return structural unless structural.equal?(Type::Combinator.untyped)
@@ -1273,11 +1125,9 @@ module Rigor
1273
1125
  Type::Combinator.untyped
1274
1126
  end
1275
1127
 
1276
- # Binds the `for` index variable(s) into `scope`. A single
1277
- # `LocalVariableTargetNode` is bound to `element_type` (the
1278
- # per-iteration value the collection yields). A `MultiTargetNode`
1279
- # (`for a, b in pairs`) delegates to {MultiTargetBinder}, which
1280
- # 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.
1281
1131
  def bind_for_index(index_node, element_type, scope)
1282
1132
  case index_node
1283
1133
  when Prism::LocalVariableTargetNode
@@ -1290,13 +1140,10 @@ module Rigor
1290
1140
  end
1291
1141
  end
1292
1142
 
1293
- # Extracts the per-iteration element type from a collection
1294
- # carrier. `Tuple[T1..Tn]` yields the union of its elements;
1295
- # `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`;
1296
- # `Nominal[Hash, [K, V]]` yields `Tuple[K, V]` (Hash#each yields
1297
- # `[key, value]` pairs); `IntegerRange` yields `Integer`;
1298
- # `Constant<Range>` reads the literal range's element class.
1299
- # 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`.
1300
1147
  def collection_element_type(type)
1301
1148
  case type
1302
1149
  when Type::Tuple
@@ -1341,27 +1188,18 @@ module Rigor
1341
1188
  end
1342
1189
  end
1343
1190
 
1344
- # `a && b` / `a || b`. The LHS always runs, the RHS only
1345
- # sometimes runs. Slice 6 phase 1 narrows the RHS evaluation:
1346
- # `a && b` evaluates `b` under the truthy edge of `a`, and
1347
- # `a || b` evaluates `b` under the falsey edge of `a`. The
1348
- # narrowed RHS post-scope is joined with the LHS post-scope
1349
- # (RHS skipped) using nil-injection so half-bound names from
1350
- # the RHS still degrade to `T | nil`. The result type is
1351
- # edge-aware: `a && b` can only produce the falsey fragment of
1352
- # `a` when the RHS is skipped, while `a || b` can only produce
1353
- # 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.
1354
1197
  #
1355
- # When the RHS is a terminating branch — it `raise`s /
1356
- # `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
1357
- # inferred type is `Bot` (ADR-24 WD6: a divergent helper such
1358
- # as `a or fail_with_message(...)`, recognised via
1359
- # `branch_terminates?`) the post-OR / post-AND scope is the
1360
- # LHS-skipped edge alone: `a or raise` only survives when `a`
1361
- # was truthy, so subsequent statements observe `a` narrowed to
1362
- # its truthy fragment; the symmetric `a and raise` survives
1363
- # only when `a` was falsey. Same shape as the `eval_if` /
1364
- # `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.
1365
1203
  def eval_and_or(node)
1366
1204
  left_type, left_scope = sub_eval(node.left, scope)
1367
1205
  truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
@@ -1369,8 +1207,7 @@ module Rigor
1369
1207
  right_type, right_scope = sub_eval(node.right, rhs_entry)
1370
1208
 
1371
1209
  if branch_terminates?(node.right, right_type)
1372
- # Control never reaches any statement after `a or raise`
1373
- # 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.
1374
1211
  surviving_type =
1375
1212
  if node.is_a?(Prism::AndNode)
1376
1213
  Narrowing.narrow_falsey(left_type)
@@ -1393,23 +1230,18 @@ module Rigor
1393
1230
  ]
1394
1231
  end
1395
1232
 
1396
- # `(body)`. Threads scope through the inner expression so
1397
- # `(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]`.
1398
1234
  def eval_parentheses(node)
1399
1235
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
1400
1236
 
1401
1237
  sub_eval(node.body, scope)
1402
1238
  end
1403
1239
 
1404
- # `class Foo; body; end` and `module Foo; body; end`. The class
1405
- # body runs in a fresh scope (Ruby's class scope does not see
1406
- # the outer locals), and the StatementEvaluator pushes a new
1407
- # `ClassFrame` so nested `def`s know their lexical owner. The
1408
- # outer scope is unchanged on exit because Ruby's class
1409
- # definition does not bind any local in the enclosing scope.
1410
- # The class body's value is the value of its last statement
1411
- # (`Constant[nil]` for an empty body); we discard the body's
1412
- # 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.
1413
1245
  def eval_class_or_module(node)
1414
1246
  name = Source::ConstantPath.qualified_name(node.constant_path)
1415
1247
  new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
@@ -1417,45 +1249,30 @@ module Rigor
1417
1249
  [body_type, scope]
1418
1250
  end
1419
1251
 
1420
- # `class << expr; body; end`. When `expr` is `self`, the body
1421
- # defines class methods on the immediate enclosing class the
1422
- # innermost frame flips to `singleton: true` so a nested
1423
- # `def foo` resolves through `singleton_method` rather than
1424
- # `instance_method`. For non-`self` expressions we cannot
1425
- # statically resolve the receiver, so we keep the existing
1426
- # context and accept that nested defs degrade to the
1427
- # `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.
1428
1256
  def eval_singleton_class(node)
1429
1257
  new_context = singleton_context_for(node)
1430
1258
  body_type, _body_scope = eval_class_body(node, new_context)
1431
1259
  [body_type, scope]
1432
1260
  end
1433
1261
 
1434
- # `def name(params); body; end`. Builds the method-entry scope
1435
- # by binding the parameter list (RBS-driven where available, or
1436
- # `Dynamic[Top]` for the slice 3 phase 2 fallback) into a fresh
1437
- # scope, then evaluates the body under that scope. The outer
1438
- # scope is left unchanged: a `def` does not introduce a binding
1439
- # in its enclosing scope. Ruby evaluates `def` to the method's
1440
- # 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]`.
1441
1266
  def eval_def(node)
1442
1267
  body_scope = build_method_entry_scope(node)
1443
- # Parameter default value expressions (e.g. `self.x` in
1444
- # `def copy(x: self.x)`) execute when the method is
1445
- # *invoked*, not when the `def` is read; their `self` is
1446
- # the instance receiver, not the surrounding class body.
1447
- # Walk the parameters subtree under `body_scope` so the
1448
- # scope-index records the instance `self_type` for every
1449
- # node inside parameter defaults. `propagate` would
1450
- # otherwise drop them to the outer class-body scope (where
1451
- # `self_type` is `singleton(C)`), making `self.foo` look
1452
- # like a singleton-side call. Observed surfacing 915 false
1453
- # positives in `prism-1.9.0`'s auto-generated `copy`
1454
- # methods alone.
1455
- # A nested `def` is a return barrier: its body's `return`s belong to
1456
- # the inner method, not the one currently being inferred. Suspend the
1457
- # return sink across the nested body so `eval_return` does not record
1458
- # 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.
1459
1276
  outer_sink = Thread.current[RETURN_SINK_KEY]
1460
1277
  Thread.current[RETURN_SINK_KEY] = nil
1461
1278
  begin
@@ -1467,145 +1284,98 @@ module Rigor
1467
1284
  [Type::Combinator.constant_of(node.name), scope]
1468
1285
  end
1469
1286
 
1470
- # `recv.foo(args) { |params| body }` and friends. The call
1471
- # type comes from `Scope#type_of` (which routes through
1472
- # `ExpressionTyper#call_type_for` and is itself block-aware
1473
- # since Slice 6 phase C sub-phase 2: it builds the block-entry
1474
- # scope from the receiving method's RBS signature, types the
1475
- # block body, and threads the body's type into
1476
- # `MethodDispatcher.dispatch`'s `block_type:` so generic
1477
- # 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
1478
1291
  # `Array[String]`).
1479
1292
  #
1480
- # The handler still re-evaluates the block under its entry
1481
- # scope so the per-node scope index sees the bindings on the
1482
- # `on_enter` callback path. Block effects do NOT leak into the
1483
- # post-call scope: a block-local write is observed only
1484
- # inside the block body. The receiver and arguments still
1485
- # 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.
1486
1297
  def eval_call(node)
1487
1298
  call_type = scope.type_of(node, tracer: tracer)
1488
- # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
1489
- # returns the memo; the engine otherwise types the call `Dynamic[top]`.
1490
- # Compute the joined memo type from the block's content mutations of
1491
- # 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.
1492
1302
  call_type = each_with_object_return(node, call_type)
1493
1303
  evaluate_block_if_present(node)
1494
- # `ruby2_keywords def foo(...)` (and similar wrappers like
1495
- # `private def`, `public def`, `module_function def`) parse
1496
- # the def as the call's positional argument; the
1497
- # ExpressionTyper#type_of_def handler types it as
1498
- # `Constant[:foo]` without walking the body. Without
1499
- # explicitly evaluating the argument-position def, the body's
1500
- # scope-index entries inherit the outer class-body
1501
- # `self_type = singleton(C)` from `ScopeIndexer.propagate`,
1502
- # so `self.helper` inside reports `undefined method 'helper'
1503
- # for singleton(C)`. Walking each argument-position def under
1504
- # the current evaluator (not a sub_eval — the def's effects
1505
- # do not bind into the surrounding scope) populates the
1506
- # scope index with the correct instance / singleton
1507
- # `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.
1508
1311
  evaluate_def_arguments(node)
1509
1312
  post_scope = record_closure_escape_if_any(node)
1510
- # ADR-56 slice A — non-escaping block captured-local write-back.
1511
- # A `:non_escaping` block (each / times / upto / map …) that
1512
- # rebinds an outer local must not leave that local's pre-call
1513
- # binding unmodified in the continuation scope; the spec MUST in
1514
- # § "Fact stability and mutation" names captured locals a
1515
- # first-class invalidation category. (The escaping / unknown path
1516
- # 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`.)
1517
1317
  post_scope = write_back_block_captures(node, post_scope)
1518
1318
  post_scope = apply_rbs_extended_assertions(node, post_scope)
1519
1319
  post_scope = apply_plugin_assertions(node, post_scope)
1520
1320
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
1521
- # Flow-folding G1 / G2 — widen a local- or instance-variable
1522
- # binding when the call is an in-place mutator on it (e.g.
1523
- # `arms << x`, `@tags << hashtag`). Stops a literal-shape
1524
- # carrier (`Tuple` / `HashShape`) from outliving its
1525
- # justification when the value is mutated. Always-safe
1526
- # (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).
1527
1324
  post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1528
- # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
1529
- # self-call resolves to a user method that CONTENT-mutates one of its
1530
- # parameters inside an escaping block (the `build_option_parser(opts)`
1531
- # idiom the callee returns an `OptionParser` whose
1532
- # `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
1533
- # the matching caller-argument local. The callee's escape is invisible
1534
- # across the boundary, so without this the caller's `options` keeps its
1535
- # seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
1536
- # fires only when the resolved callee actually escape-mutates that
1537
- # parameter (not for every self-call), and sound — only loses
1538
- # 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.
1539
1332
  post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1540
- # Same always-safe rationale as `widen_after_call` above —
1541
- # propagates outer-scope local / ivar widening from block body
1542
- # 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 }`).
1543
1335
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1544
- # ADR-56 slice C — receiver-content element-type join. Joins
1545
- # appended / stored element / key / value types into the
1546
- # continuation collection so `out = [0]; arr.each { |x| out << x }`
1547
- # types `Array[0 | Integer]`, not `Array[0]`. Same always-safe
1548
- # 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).
1549
1339
  post_scope = content_writeback_block_captures(node, post_scope)
1550
- # Indexed-collection narrowing — drop any
1551
- # `receiver[key] ||= default` narrowing the analyzer
1552
- # recorded earlier when an intervening `[]=` writes the
1553
- # same slot or any other mutator runs against the
1554
- # 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).
1555
1343
  post_scope = IndexedNarrowing.invalidate_after_call(call_node: node, current_scope: post_scope)
1556
- # Single-hop method-chain narrowing — drop every
1557
- # `(receiver, *)` chain narrowing rooted at the call's
1558
- # outer stable receiver (any-call-against-the-root
1559
- # invalidation rule, B2 from the slice's design
1560
- # notes). Calls whose outer receiver is itself a chain
1561
- # node (e.g. `x.last << y`) do NOT drop narrowings
1562
- # keyed on `x` — only direct calls against the root
1563
- # 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.
1564
1348
  post_scope = IndexedNarrowing.invalidate_chain_after_call(call_node: node, current_scope: post_scope)
1565
- # B2.2 — intervening method call ivar invalidation.
1566
- # An implicit-self / self-receiver call could mutate any
1567
- # ivar of the enclosing class (we cannot prove purity
1568
- # without an effect system). Reset each ivar whose
1569
- # current local binding has narrowed below the class-ivar
1570
- # seed back to the seed itself, so a subsequent
1571
- # `if @flag` predicate observes the seed's union (not the
1572
- # pre-call narrowed value). Always-safe (only widens; no
1573
- # new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
1574
- # § "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.
1575
1354
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
1576
- # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
1577
- # narrowed to non-nil on a successful-match edge; a later call
1578
- # that itself runs a regex match rebinds them, so the narrowed
1579
- # facts must be dropped. We forget them only when the call is
1580
- # match-CAPABLE (a regex-matching method, or an implicit-self /
1581
- # unknown-receiver call whose body we cannot prove match-free).
1582
- # A call provably match-free on a known receiver — `$3.to_i`,
1583
- # `year < 50` — does NOT clobber, so the multi-statement
1584
- # `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
1585
1360
  # while a genuinely interposed match still invalidates.
1586
1361
  post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1587
1362
  [call_type, post_scope]
1588
1363
  end
1589
1364
 
1590
- # Method names that (may) run a regex match and therefore rebind
1591
- # the `$~` family. Conservative over-approximation a few set
1592
- # 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.
1593
1367
  MATCH_CAPABLE_METHODS = %i[
1594
1368
  =~ match match? gsub gsub! sub sub! scan split slice slice!
1595
1369
  [] partition rpartition index rindex === grep grep_v
1596
1370
  ].freeze
1597
1371
  private_constant :MATCH_CAPABLE_METHODS
1598
1372
 
1599
- # True when `node` could rebind the regex match-data globals:
1600
- # a known regex-matching method by name, or an implicit-self /
1601
- # self-receiver call whose body we cannot inspect for an internal
1602
- # match. An explicit-receiver call to a non-matching method
1603
- # (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
1604
- # the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
1605
- # narrowed globals. The over-approximation is one-directional: a
1606
- # user method that secretly matches on an explicit receiver is the
1607
- # only escape, and re-narrowing on the next real guard recovers —
1608
- # 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.
1609
1379
  def match_capable_call?(node)
1610
1380
  return true unless node.is_a?(Prism::CallNode)
1611
1381
  return true if MATCH_CAPABLE_METHODS.include?(node.name)
@@ -1614,12 +1384,9 @@ module Rigor
1614
1384
  receiver.nil? || receiver.is_a?(Prism::SelfNode)
1615
1385
  end
1616
1386
 
1617
- # Returns a scope with each ivar's narrowed local binding
1618
- # widened back to its class-ivar seed value when the call
1619
- # is one that could plausibly mutate ivars on the enclosing
1620
- # class (implicit-self or explicit `self.foo`). External-
1621
- # receiver calls (`obj.method`) cannot reach the caller's
1622
- # 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.
1623
1390
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
1624
1391
  return current_scope unless intervening_call_candidate?(call_node)
1625
1392
 
@@ -1659,11 +1426,9 @@ module Rigor
1659
1426
  end
1660
1427
  end
1661
1428
 
1662
- # v0.0.3 — recognises a small catalogue of RSpec
1663
- # matcher patterns as assert-shaped narrows on the
1664
- # local passed to `expect(...)`. The pattern is
1665
- # matched purely on AST shape; no RBS for RSpec is
1666
- # 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).
1667
1432
  #
1668
1433
  # Recognised today:
1669
1434
  #
@@ -1678,11 +1443,9 @@ module Rigor
1678
1443
  # `be_an_instance_of`, subtype-permitting
1679
1444
  # otherwise).
1680
1445
  #
1681
- # Anything else is silently passed through. Symmetric
1682
- # negative class assertions (`not_to be_a(C)`) and
1683
- # narrowing TO `NilClass` are intentionally NOT
1684
- # modelled: they are rarely useful in practice and
1685
- # 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.
1686
1449
  def apply_rspec_matcher_narrowing(call_node, current_scope)
1687
1450
  narrow = rspec_matcher_narrowing_request(call_node)
1688
1451
  return current_scope if narrow.nil?
@@ -1695,10 +1458,8 @@ module Rigor
1695
1458
  current_scope.with_local(local_name, narrowed)
1696
1459
  end
1697
1460
 
1698
- # Decodes an `expect(x).<chain>` outer call into a
1699
- # narrowing request hash, or `nil` when the shape is
1700
- # not recognised. The hash carries `:local` (the local
1701
- # 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.
1702
1463
  def rspec_matcher_narrowing_request(call_node)
1703
1464
  local_name = rspec_expectation_target(call_node)
1704
1465
  return nil if local_name.nil?
@@ -1729,8 +1490,7 @@ module Rigor
1729
1490
  end
1730
1491
  end
1731
1492
 
1732
- # `be_a` / `be_kind_of` / `be_an_instance_of` accept a
1733
- # single class argument — either a `ConstantReadNode`
1493
+ # `be_a` / `be_kind_of` / `be_an_instance_of` accept a single class argument — either a `ConstantReadNode`
1734
1494
  # (`Integer`) or a `ConstantPathNode` (`Rigor::Type::Nominal`).
1735
1495
  def rspec_be_a_narrow(matcher, local_name, exact:)
1736
1496
  args = matcher.arguments&.arguments || []
@@ -1753,11 +1513,8 @@ module Rigor
1753
1513
  end
1754
1514
  end
1755
1515
 
1756
- # Returns the local name passed to `expect(...)` when
1757
- # the receiver chain matches `expect(<local>)` exactly,
1758
- # or nil otherwise. Centralised so each per-matcher
1759
- # decoder can short-circuit on a non-matching outer
1760
- # 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.
1761
1518
  def rspec_expectation_target(call_node)
1762
1519
  receiver = call_node.receiver
1763
1520
  return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
@@ -1780,10 +1537,8 @@ module Rigor
1780
1537
  matcher
1781
1538
  end
1782
1539
 
1783
- # True when `call_node`'s sole argument is an
1784
- # implicit-self matcher call with the given name and
1785
- # no positional arguments — used by the no-arg
1786
- # 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`).
1787
1542
  def rspec_matcher_argument?(call_node, matcher_name)
1788
1543
  matcher = rspec_matcher_node(call_node)
1789
1544
  return false if matcher.nil?
@@ -1792,16 +1547,11 @@ module Rigor
1792
1547
  matcher.arguments.nil? || matcher.arguments.arguments.empty?
1793
1548
  end
1794
1549
 
1795
- # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the
1796
- # post-return facts the merger produces for an
1797
- # `RBS::Extended`-annotated call. Reads through
1798
- # `RbsExtended.read_flow_contribution` so the bundle
1799
- # carries the canonical `Rigor::FlowContribution::Fact`
1800
- # rows for `:always` assert directives (the slice-4a
1801
- # routing places conditional asserts on `truthy_facts` /
1802
- # `falsey_facts`, which `Narrowing.predicate_scopes`
1803
- # consumes). Plugin `:always` assertions are handled by
1804
- # 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.
1805
1555
  def apply_rbs_extended_assertions(call_node, current_scope)
1806
1556
  method_def = resolve_call_method(call_node, current_scope)
1807
1557
  return current_scope if method_def.nil?
@@ -1818,31 +1568,20 @@ module Rigor
1818
1568
  end
1819
1569
  end
1820
1570
 
1821
- # ADR-7 § "Slice 4-A" / T.bind priority slice 2 — applies
1822
- # the post-return facts plugin contributions produce. This
1823
- # is the sibling of {apply_rbs_extended_assertions}: the
1824
- # carrier (`Rigor::FlowContribution::Fact`) and the
1825
- # downstream narrowing path (`apply_post_return_fact` →
1826
- # `apply_self_post_return_fact`) are the same; only the
1827
- # *source* of the bundle changes (RBS::Extended vs the
1828
- # 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`).
1829
1575
  #
1830
- # `:self`-targeted facts narrow `scope.self_type` for the
1831
- # surrounding scope. In a block body, the surrounding
1832
- # scope is the block's own scope, so the narrowing applies
1833
- # to the rest of the block — exactly the contract Sorbet's
1834
- # `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.
1835
1579
  #
1836
- # `:parameter`-targeted facts only land when the called
1837
- # method has an authoritative RBS sig (via
1838
- # `resolve_call_method`); plugins recognising their own
1839
- # synthetic call shapes (e.g. `T.assert_type!`) have no
1840
- # method_def and the parameter facts silently skip the
1841
- # plugin's own diagnostics_for_file path covers those
1842
- # cases. The full plugin-side parameter-targeting story
1843
- # (PHPStan-style Type-Specifying Extensions on
1844
- # plugin-recognised calls) lives behind a follow-up slice
1845
- # 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.
1846
1585
  def apply_plugin_assertions(call_node, current_scope)
1847
1586
  registry = current_scope.environment&.plugin_registry
1848
1587
  return current_scope if registry.nil? || registry.empty?
@@ -1860,16 +1599,14 @@ module Rigor
1860
1599
  end
1861
1600
  end
1862
1601
 
1863
- # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
1864
- # narrowing from the method-gated `narrowing_facts` DSL, wrapped as
1865
- # a facts-only `FlowContribution`, swallowing per-plugin
1866
- # 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.
1867
1605
  EMPTY_CONTRIBUTIONS = [].freeze
1868
1606
  private_constant :EMPTY_CONTRIBUTIONS
1869
1607
 
1870
- # Fast-exit guard: skip if no plugin declares a `narrowing_facts` rule, or if
1871
- # no registered method-name gate matches the call. See
1872
- # `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.
1873
1610
  def collect_plugin_contributions(registry, call_node, current_scope)
1874
1611
  index = registry.contribution_index
1875
1612
  relevant = index.for_statement
@@ -1881,10 +1618,9 @@ module Rigor
1881
1618
  collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1882
1619
  end
1883
1620
 
1884
- # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
1885
- # Visits only plugins in `for_statement` (declare a `narrowing_facts` rule),
1886
- # further gated by the method-name Set probe so the common no-candidate
1887
- # 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.
1888
1624
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1889
1625
  result = nil
1890
1626
  relevant.each do |plugin|
@@ -1927,19 +1663,13 @@ module Rigor
1927
1663
  end
1928
1664
  end
1929
1665
 
1930
- # Slice 4b-2 — applies a single post-return Fact to the
1931
- # scope. Mirrors `Narrowing#apply_fact_to_scope` (Fact
1932
- # variant of the v0.0.2 `apply_assert_effect`); shares the
1933
- # narrowing logic via `Narrowing.narrow_for_fact` so the
1934
- # predicate / assert / plugin paths all converge on the
1935
- # 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.
1936
1669
  #
1937
- # v0.1.8 Pillar 2 Slice 1 added the `:local` target_kind
1938
- # branch so plugins recognising bespoke call shapes
1939
- # (`expect(x).to be_a(T)`) can directly narrow a named
1940
- # local in the surrounding scope, bypassing the
1941
- # parameter-name lookup that requires an authoritative RBS
1942
- # 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).
1943
1673
  def apply_post_return_fact(fact, call_node, current_scope, method_def)
1944
1674
  return apply_local_post_return_fact(fact, current_scope) if fact.target_kind == :local
1945
1675
 
@@ -1955,12 +1685,9 @@ module Rigor
1955
1685
  current_scope.with_local(local_name, narrowed)
1956
1686
  end
1957
1687
 
1958
- # v0.1.8 Pillar 2 Slice 1 — narrows the named local directly
1959
- # without consulting the call node's argument list. The fact's
1960
- # `target_name` is the local-variable name as written in
1961
- # source. Silently no-ops when the local is unbound in the
1962
- # current scope (the plugin's named local may have already
1963
- # 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).
1964
1691
  def apply_local_post_return_fact(fact, current_scope)
1965
1692
  local_name = fact.target_name
1966
1693
  current_type = current_scope.local(local_name)
@@ -1970,9 +1697,8 @@ module Rigor
1970
1697
  current_scope.with_local(local_name, narrowed)
1971
1698
  end
1972
1699
 
1973
- # v0.1.1 Track 1 slice 3 — `assert self is T` post-return
1974
- # narrowing for the four supported receiver shapes (mirrors
1975
- # `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`).
1976
1702
  def apply_self_post_return_fact(fact, receiver_node, current_scope)
1977
1703
  case receiver_node
1978
1704
  when nil, Prism::SelfNode
@@ -1998,8 +1724,7 @@ module Rigor
1998
1724
  end
1999
1725
  end
2000
1726
 
2001
- # `:self` routes to the call receiver; otherwise we look
2002
- # 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.
2003
1728
  def fact_target_node(fact, call_node, method_def)
2004
1729
  if fact.target_kind == :self
2005
1730
  call_node.receiver
@@ -2009,12 +1734,9 @@ module Rigor
2009
1734
  end
2010
1735
 
2011
1736
  def lookup_post_return_arg(call_node, method_def, target_name)
2012
- # Plugin-source contributions arrive without an
2013
- # authoritative method_def (the plugin recognised the
2014
- # call shape directly). Parameter-targeting falls back
2015
- # to "no narrow" in that case — the wider plugin-side
2016
- # parameter mapping (`:local` / `:argument_at`) is a
2017
- # 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.
2018
1740
  return nil if method_def.nil?
2019
1741
 
2020
1742
  arguments = call_node.arguments&.arguments || []
@@ -2034,8 +1756,8 @@ module Rigor
2034
1756
  sub_eval(block, block_entry)
2035
1757
  end
2036
1758
 
2037
- # Slice 6 phase C sub-phase 3b/3c. When the call carries a
2038
- # 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:
2039
1761
  #
2040
1762
  # - 3b: attach a `dynamic_origin` `closure_escape` fact to the
2041
1763
  # post-call scope so consumers can see that the closure may
@@ -2049,19 +1771,15 @@ module Rigor
2049
1771
  # only reads (without writing) are also untouched: read-only
2050
1772
  # captures cannot rebind the outer variable.
2051
1773
  #
2052
- # A `:non_escaping` classification (or any block-less call)
2053
- # leaves the post-call scope unchanged.
1774
+ # A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
2054
1775
  def record_closure_escape_if_any(node)
2055
- # ADR-57 slice 3 work-item 1: an escaping block can also be attached
2056
- # to a RECEIVER call in a chain rather than to `node` itself the
2057
- # canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
2058
- # .parse!(argv)` idiom, where the content-mutating block hangs off
2059
- # `OptionParser.new` but the statement-level call node is the chained
2060
- # `.parse!`. A receiver call is evaluated as an expression, never as a
2061
- # statement, so its block never reaches this escape handler on its own.
2062
- # Fold each escaping receiver-chain block's content widening into the
2063
- # continuation here so the captured collection is floored regardless of
2064
- # 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.
2065
1783
  post_scope = widen_escaping_receiver_chain_captures(node, scope)
2066
1784
 
2067
1785
  return post_scope unless node.block.is_a?(Prism::BlockNode)
@@ -2082,15 +1800,13 @@ module Rigor
2082
1800
  )
2083
1801
  end
2084
1802
 
2085
- # Floor each caller-argument local whose matching parameter the resolved
2086
- # callee escape-mutates (see the call-site comment). Only self-dispatch
2087
- # calls resolving to a discovered user def are considered; the per-def
2088
- # "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.
2089
1806
  def widen_callee_escaped_argument_captures(node, base_scope)
2090
1807
  # Apply to the statement call AND every call in its receiver chain: the
2091
- # `build_option_parser(options).parse!(argv)` idiom puts the escape-
2092
- # mutating helper call in the RECEIVER position, where its argument is
2093
- # 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.
2094
1810
  acc = floor_callee_escaped_args_for_call(node, base_scope)
2095
1811
  receiver = node.receiver
2096
1812
  while receiver.is_a?(Prism::CallNode)
@@ -2102,9 +1818,8 @@ module Rigor
2102
1818
 
2103
1819
  def floor_callee_escaped_args_for_call(node, base_scope)
2104
1820
  return base_scope unless self_dispatch_call?(node)
2105
- # Fast path — the floor only ever touches a local passed as an
2106
- # argument, so a call with no arguments cannot floor anything. Skip the
2107
- # 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).
2108
1823
  return base_scope unless call_passes_local_argument?(node)
2109
1824
 
2110
1825
  def_node = resolve_self_callee_def(node)
@@ -2116,11 +1831,9 @@ module Rigor
2116
1831
  floor_arguments_at_positions(node, escaped, base_scope)
2117
1832
  end
2118
1833
 
2119
- # The user def a self-dispatch `node` resolves to in the enclosing class,
2120
- # or nil. Reuses the discovery index `Scope#user_def_for` reads; no
2121
- # ancestor walk (the boundary-escape idiom is same-class), keeping this
2122
- # off the hot path for the overwhelming majority of self-calls that
2123
- # 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.
2124
1837
  def resolve_self_callee_def(node)
2125
1838
  class_name = enclosing_class_name_for(scope.self_type)
2126
1839
  return scope.top_level_def_for(node.name) if class_name.nil?
@@ -2134,11 +1847,10 @@ module Rigor
2134
1847
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
2135
1848
  end
2136
1849
 
2137
- # The set of `[name, position]` parameters of `def_node` whose content a
2138
- # block in the body escape-mutates. Memoised per def node (the body walk
2139
- # is otherwise repeated at every call site). A parameter is "escape-
2140
- # mutated" when a `param[k] = v` / `param << x` mutation on it appears
2141
- # 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.
2142
1854
  def escaped_content_parameters(def_node)
2143
1855
  cache = (@escaped_param_cache ||= {}.compare_by_identity)
2144
1856
  cache[def_node] ||= compute_escaped_content_parameters(def_node)
@@ -2160,15 +1872,12 @@ module Rigor
2160
1872
  positions.slice(*mutated)
2161
1873
  end
2162
1874
 
2163
- # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
2164
- # non-escaping verdict, used when scanning a callee body where the block-
2165
- # owning call's receiver TYPE is not available. A call whose method name
2166
- # is a known structural iterator (`each` / `map` / `tap` / …) runs its
2167
- # block synchronously and does not retain it, so its captured mutations
2168
- # are not a cross-boundary escape. Any other name (`on`, `subscribe`,
2169
- # `define_method`, an unknown DSL hook) is treated as escaping — sound,
2170
- # since mis-classifying a truly-non-escaping call only floors an argument
2171
- # 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.
2172
1881
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
2173
1882
  ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
2174
1883
  ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
@@ -2183,9 +1892,8 @@ module Rigor
2183
1892
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
2184
1893
  end
2185
1894
 
2186
- # `{ name => position }` for the required / optional positional
2187
- # parameters of a def. Keyword / rest / block parameters are skipped —
2188
- # 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.
2189
1897
  def positional_parameter_positions(def_node)
2190
1898
  params = def_node.parameters
2191
1899
  return {} if params.nil?
@@ -2198,10 +1906,9 @@ module Rigor
2198
1906
  positions
2199
1907
  end
2200
1908
 
2201
- # True when at least one argument of `node` is a bare local-variable read
2202
- # (positional or keyword value) bound in the current scope a cheap
2203
- # pre-filter so the def resolution / body scan only runs for calls that
2204
- # 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.
2205
1912
  def call_passes_local_argument?(node)
2206
1913
  args = node.arguments
2207
1914
  return false unless args.respond_to?(:arguments)
@@ -2236,12 +1943,10 @@ module Rigor
2236
1943
  end
2237
1944
  end
2238
1945
 
2239
- # Walk the receiver chain of `node` and fold the escaping-content
2240
- # widening of every block-bearing, escaping receiver call into
2241
- # `base_scope`. Only receiver calls are walked `node` itself is handled
2242
- # by the caller. A `:non_escaping` receiver block is left to slice C's
2243
- # non-escaping write-back (which the receiver expression evaluation
2244
- # 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.
2245
1950
  def widen_escaping_receiver_chain_captures(node, base_scope)
2246
1951
  receiver = node.receiver
2247
1952
  acc = base_scope
@@ -2255,28 +1960,23 @@ module Rigor
2255
1960
  acc
2256
1961
  end
2257
1962
 
2258
- # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
2259
- # An escaping / unknown block that CONTENT-mutates a captured outer
2260
- # local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
2261
- # stored proc) previously left that local's content untouched only its
2262
- # narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
2263
- # survived and its element fold (`options[:format]` -> `"text"`,
2264
- # `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.
2265
1968
  #
2266
- # An escaping block may run later and any number of times, so joining a
2267
- # bounded evidence set is not sound (unlike slice C's non-escaping
2268
- # join): the sound continuation is the bare-collection floor Array ->
2269
- # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
2270
- # `String`. The seed's element/key/value precision is forgotten; only
2271
- # the carrier survives. Read-only captures and locals the block merely
2272
- # 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.
2273
1974
  def widen_escaping_content_captures(block_node, post_scope)
2274
1975
  body = block_node.body
2275
1976
  return post_scope if body.nil?
2276
1977
 
2277
- # Transitive case first: the body may content-mutate a captured local
2278
- # through a self-call rather than a direct `local[k] = v` write, which
2279
- # `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).
2280
1980
  post_scope = floor_block_body_callee_escaped_args(body, post_scope)
2281
1981
 
2282
1982
  mutations = collect_content_mutations(body)
@@ -2288,20 +1988,15 @@ module Rigor
2288
1988
  end
2289
1989
  end
2290
1990
 
2291
- # Inside an ESCAPING block body, a captured outer local can be content-
2292
- # mutated transitively: the body is (or contains) a self-call that
2293
- # escape-mutates one of its arguments. The canonical shape is the CLI's
2294
- # own `OptionParser.new { |opts| define_options(opts, options) }` — the
2295
- # block body is a bare `define_options(opts, options)` whose `options`
2296
- # parameter is escape-mutated inside ITS nested `opts.on { options[:k] =
2297
- # v }` blocks. `collect_content_mutations` only sees direct `local[k] =
2298
- # v` writes in THIS body, so it misses the transitive write and the
2299
- # captured Hash keeps its literal-false seed (folding the caller's
2300
- # `options[:mutation]` guard to an always-falsey constant). Reuse the
2301
- # cross-method-boundary callee-escaped-argument floor (the same gate the
2302
- # receiver-chain path uses at the call site) on every self-call in the
2303
- # body. Sound — only ever floors a captured local passed as an argument
2304
- # 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.
2305
2000
  def floor_block_body_callee_escaped_args(body, post_scope)
2306
2001
  acc = post_scope
2307
2002
  Source::NodeWalker.each(body) do |descendant|
@@ -2310,9 +2005,8 @@ module Rigor
2310
2005
  acc
2311
2006
  end
2312
2007
 
2313
- # The Dynamic-floor carrier for a content-mutated escaping capture, or
2314
- # nil when the pre-state is not a recognised mutable collection (leave
2315
- # 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).
2316
2010
  def content_floor_for(type)
2317
2011
  return nil if type.nil?
2318
2012
 
@@ -2338,14 +2032,10 @@ module Rigor
2338
2032
  :unknown
2339
2033
  end
2340
2034
 
2341
- # Sub-phase 3c. Replace the outer-local types that the block
2342
- # body can rebind with `Dynamic[Top]`. The conservative drop
2343
- # matches the spec line "facts about locals it can write
2344
- # become unstable after the escape point": rather than
2345
- # synthesise the union of the block's write types (which the
2346
- # current pass does not yet expose), we discard the narrowed
2347
- # binding altogether. A future sub-phase MAY refine this to
2348
- # 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.
2349
2039
  def drop_captured_narrowing(block_node, base_scope)
2350
2040
  names = captured_local_writes(block_node, base_scope)
2351
2041
  return base_scope if names.empty?
@@ -2353,13 +2043,11 @@ module Rigor
2353
2043
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
2354
2044
  end
2355
2045
 
2356
- # Names of outer locals the block body can REBIND, across every
2357
- # local-write form: plain `=` (`LocalVariableWriteNode`), the
2358
- # operator / `||=` / `&&=` compound forms, and a multi-assign target
2359
- # (`x, y = ...` `LocalVariableTargetNode` under `MultiWriteNode`).
2360
- # Block-introduced names (parameters, numbered params, `;`-locals) and
2361
- # names not bound in the outer scope are excluded — a write to either
2362
- # 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.
2363
2051
  LOCAL_WRITE_NODES = [
2364
2052
  Prism::LocalVariableWriteNode,
2365
2053
  Prism::LocalVariableOperatorWriteNode,
@@ -2385,18 +2073,14 @@ module Rigor
2385
2073
  outer_writes.uniq
2386
2074
  end
2387
2075
 
2388
- # ADR-56 slice A. For a `:non_escaping` block, fold the continuation
2389
- # binding of every outer local the body can rebind back into
2390
- # `post_scope`. The binding is a capped fixpoint (cap 3) over the
2391
- # block body re-evaluated under the running per-name assumption,
2392
- # joined with the pre-call binding (kept as a constituent so the
2393
- # 0-iteration path — `[].each { … }` — stays sound), value-pinned-
2394
- # 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
2395
2080
  # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2396
2081
  #
2397
- # Fast path: a block writing no outer local leaves `post_scope`
2398
- # byte-identical (the overwhelming majority of blocks), so this costs
2399
- # 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.
2400
2084
  def write_back_block_captures(call_node, post_scope)
2401
2085
  block = call_node.block
2402
2086
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2416,20 +2100,16 @@ module Rigor
2416
2100
  result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
2417
2101
  end
2418
2102
 
2419
- # ADR-56 slice C — receiver-content element-type join. After the
2420
- # rebind write-back and `MutationWidening.widen_after_block` (which
2421
- # forgets a content-mutated collection's literal arity but keeps only
2422
- # the SEED's element types), join the appended/stored element / key /
2423
- # value types INTO the continuation collection's parameter, so
2424
- # `out = [0]; arr.each { |x| out << x }` types `out` as
2425
- # `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
2426
- # 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]`).
2427
2108
  #
2428
- # Pre-state is read from `post_scope` so a local that is BOTH rebound
2429
- # and content-mutated composes: the rebind fixpoint result feeds the
2430
- # content join. The block body is typed once for argument evidence;
2431
- # the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
2432
- # 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.
2433
2113
  def content_writeback_block_captures(call_node, post_scope)
2434
2114
  block = call_node.block
2435
2115
  return post_scope unless block.is_a?(Prism::BlockNode)
@@ -2448,13 +2128,11 @@ module Rigor
2448
2128
  end
2449
2129
  end
2450
2130
 
2451
- # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
2452
- # the return is the memo object after the block has mutated it through
2453
- # the `acc` alias. Compute the joined memo type the same way captured-
2454
- # local content mutations are joined: pre-state = the memo argument's
2455
- # type, added evidence = the content-mutator args on the memo block
2456
- # param. Returns `call_type` unchanged for any other call, a missing
2457
- # 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.
2458
2136
  def each_with_object_return(call_node, call_type)
2459
2137
  return call_type unless call_node.name == :each_with_object
2460
2138
 
@@ -2470,8 +2148,8 @@ module Rigor
2470
2148
  body = block.body
2471
2149
  return call_type if body.nil?
2472
2150
 
2473
- # The memo alias is a block-local (depth 0) — collect content
2474
- # 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.
2475
2153
  calls = body_content_mutations_on(body, memo_param)
2476
2154
  return call_type if calls.empty?
2477
2155
 
@@ -2481,9 +2159,8 @@ module Rigor
2481
2159
  joined || call_type
2482
2160
  end
2483
2161
 
2484
- # The name of the memo block parameter (the SECOND positional param of
2485
- # an `each_with_object` block), or nil when the block does not bind a
2486
- # 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.
2487
2164
  def each_with_object_memo_param(block)
2488
2165
  params_root = block.parameters
2489
2166
  return nil unless params_root.is_a?(Prism::BlockParametersNode)
@@ -2498,8 +2175,7 @@ module Rigor
2498
2175
  second.respond_to?(:name) ? second.name : nil
2499
2176
  end
2500
2177
 
2501
- # Content-mutator calls on a block-local receiver `var_name`
2502
- # (depth 0) within `body`.
2178
+ # Content-mutator calls on a block-local receiver `var_name` (depth 0) within `body`.
2503
2179
  def body_content_mutations_on(body, var_name)
2504
2180
  calls = []
2505
2181
  Source::NodeWalker.each(body) do |descendant|
@@ -2515,15 +2191,14 @@ module Rigor
2515
2191
  calls
2516
2192
  end
2517
2193
 
2518
- # Joins content evidence for a memo / param given its pre-state and a
2519
- # 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.
2520
2196
  def join_content_for_param(calls, pre_state, block_entry)
2521
2197
  return nil if pre_state.nil?
2522
2198
 
2523
2199
  if stringish?(pre_state)
2524
- # String carries no element parameter; mutating `<<`/`concat`
2525
- # makes the constant value unsound (`s = "a"; s << x` runtime
2526
- # `"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.
2527
2202
  Type::Combinator.nominal_of("String")
2528
2203
  elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2529
2204
  join_hash_param(calls, pre_state, block_entry)
@@ -2543,9 +2218,8 @@ module Rigor
2543
2218
  return nil unless arrayish?(pre_state)
2544
2219
 
2545
2220
  added = calls.flat_map do |c|
2546
- # Index-write on an array (`a[i] += v`) introduces no new element
2547
- # evidence we can cheaply attribute the array-arity forget
2548
- # 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.
2549
2223
  next [] if index_write?(c)
2550
2224
 
2551
2225
  MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
@@ -2553,11 +2227,9 @@ module Rigor
2553
2227
  MutationWidening.join_array_content(pre_state, added)
2554
2228
  end
2555
2229
 
2556
- # Walks the block body for content-mutator calls (`<<`, `push`,
2557
- # `[]=`, ) whose receiver is a captured outer local (depth >= 1),
2558
- # returning `{ name => [call_node, ...] }`. Mirrors the
2559
- # `MutationWidening.widen_after_block` walk (descends into nested
2560
- # 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).
2561
2233
  def collect_content_mutations(body)
2562
2234
  mutations = Hash.new { |h, k| h[k] = [] }
2563
2235
  Source::NodeWalker.each(body) do |descendant|
@@ -2567,11 +2239,9 @@ module Rigor
2567
2239
  mutations
2568
2240
  end
2569
2241
 
2570
- # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
2571
- # multi-assign target) that mutate a collection's CONTENT without a
2572
- # `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
2573
- # OrWrite even though the appended values land on the nested array —
2574
- # 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`).
2575
2245
  INDEX_WRITE_NODES = [
2576
2246
  Prism::IndexOrWriteNode,
2577
2247
  Prism::IndexAndWriteNode,
@@ -2580,10 +2250,8 @@ module Rigor
2580
2250
  ].freeze
2581
2251
  private_constant :INDEX_WRITE_NODES
2582
2252
 
2583
- # `[receiver_name, node]` when `node` is a content mutation whose
2584
- # receiver is a local variable satisfying `accept` (depth predicate),
2585
- # else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
2586
- # 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.
2587
2255
  def content_mutation_target(node)
2588
2256
  is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2589
2257
  return [nil, nil] unless is_call_mutator || index_write?(node)
@@ -2595,11 +2263,9 @@ module Rigor
2595
2263
  [receiver.name, node]
2596
2264
  end
2597
2265
 
2598
- # Computes the joined continuation collection type for one captured
2599
- # local from its content-mutator calls. Returns `nil` (no overlay)
2600
- # when the pre-state is neither an Array-ish nor a Hash-ish binding
2601
- # e.g. a String accumulator, whose `<<` carries no element parameter
2602
- # 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`.
2603
2269
  def join_content_for_local(name, calls, post_scope, block_entry)
2604
2270
  join_content_for_param(calls, post_scope.local(name), block_entry)
2605
2271
  end
@@ -2637,11 +2303,9 @@ module Rigor
2637
2303
  (type.is_a?(Type::Nominal) && type.class_name == "String")
2638
2304
  end
2639
2305
 
2640
- # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
2641
- # an index-write node (`h[k] ||= v`), typed in the block-entry scope.
2642
- # For an index-write the stored value is opaque (the appended values
2643
- # often land on a NESTED collection via `h[k] << v`), so the value is
2644
- # 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.
2645
2309
  # Returns `[]` for other forms.
2646
2310
  def hash_pair_types(node, block_entry)
2647
2311
  if index_write?(node)
@@ -2668,10 +2332,9 @@ module Rigor
2668
2332
  nil
2669
2333
  end
2670
2334
 
2671
- # Argument types for a content-mutator call, typed against the
2672
- # block-entry scope (block params bound). A sub-evaluator over
2673
- # `block_entry` keeps the argument typing flow-correct for params /
2674
- # `;`-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.
2675
2338
  def content_arg_types(call_node, block_entry)
2676
2339
  arguments = call_node.arguments
2677
2340
  return [] if arguments.nil?
@@ -2681,10 +2344,9 @@ module Rigor
2681
2344
  []
2682
2345
  end
2683
2346
 
2684
- # Evaluates `block`'s body once with each written outer local bound to
2685
- # the supplied `bindings` (block params / `;`-locals re-bound as
2686
- # usual) and returns the per-name exit binding for `names`. Used as
2687
- # 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.
2688
2350
  def block_exit_bindings(call_node, block, bindings, names)
2689
2351
  entry = build_block_entry_scope(call_node, block)
2690
2352
  entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
@@ -2692,11 +2354,9 @@ module Rigor
2692
2354
  names.to_h { |name| [name, exit_scope.local(name)] }
2693
2355
  end
2694
2356
 
2695
- # Names introduced by the block itself (parameters, numbered
2696
- # parameters via `BlockParameterBinder`, plus explicit
2697
- # `;`-prefixed block-locals on `BlockParametersNode`). Writes
2698
- # to these names are local to the block and MUST NOT be
2699
- # 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.
2700
2360
  def block_introduced_locals(block_node)
2701
2361
  introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
2702
2362
  params_root = block_node.parameters
@@ -2704,34 +2364,24 @@ module Rigor
2704
2364
  introduced
2705
2365
  end
2706
2366
 
2707
- # `Prism::BlockNode` is reached through {#eval_call}; the
2708
- # handler runs the body under `scope`, which the caller has
2709
- # already augmented with the block's parameter bindings. Effects
2710
- # do not leak past the block (the outer eval_call returns the
2711
- # caller's scope unchanged), but the body's local writes are
2712
- # threaded through subsequent statements *inside* the block so
2713
- # `each { |x| sum = x; sum.succ }` types `sum.succ` under the
2714
- # `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.
2715
2371
  def eval_block(node)
2716
2372
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2717
2373
 
2718
2374
  sub_eval(node.body, scope)
2719
2375
  end
2720
2376
 
2721
- # Builds the entry scope for a block body. The block sees the
2722
- # outer scope's locals (Ruby's lexical scoping rule) and adds
2723
- # bindings for every named block parameter on top. Parameter
2724
- # types come from the receiving method's RBS signature when
2725
- # 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]`.
2726
2380
  #
2727
- # `;`-prefixed block-locals (`do |i; x|`) are bound to
2728
- # `Constant[nil]` so the inner read shadows any outer
2729
- # `x` per Ruby's semantics at runtime the block-local is
2730
- # a fresh nil-valued variable on every block invocation.
2731
- # Without this shadow, an inner `x.even?` before the first
2732
- # write would type-check against the OUTER `x` (e.g.
2733
- # `Integer`) when the runtime would actually `NoMethodError`
2734
- # 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`.
2735
2385
  def build_block_entry_scope(call_node, block_node)
2736
2386
  expected = expected_block_param_types_for(call_node)
2737
2387
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
@@ -2775,12 +2425,10 @@ module Rigor
2775
2425
  def eval_class_body(node, new_context)
2776
2426
  return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
2777
2427
 
2778
- # Class/module bodies run in a fresh scope: the outer scope's
2779
- # locals are NOT visible inside `class Foo; ... end`. We keep
2780
- # the same Environment so RBS lookups continue to work, and
2781
- # simply drop the locals. Slice A-engine: `self` inside a
2782
- # class body is the class object itself, so we set
2783
- # `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>]`.
2784
2432
  fresh = build_fresh_body_scope
2785
2433
  body_self = self_type_for_class_body(new_context)
2786
2434
  fresh = fresh.with_self_type(body_self) if body_self
@@ -2796,43 +2444,49 @@ module Rigor
2796
2444
  source_path: scope.source_path
2797
2445
  )
2798
2446
  bindings = binder.bind(def_node)
2799
- # ADR-67 WD3 — override an undeclared parameter with its call-site
2800
- # inferred type (precision-additive; an RBS-declared parameter wins,
2801
- # the table is empty on a normal `check` run). The inferred type lives
2802
- # only as a body local, never as an RBS contract, so it cannot fire a
2803
- # 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).
2804
2451
  bindings = seed_inferred_param_types(bindings, def_node, singleton)
2805
2452
 
2806
- # Method bodies do NOT see the outer scope's locals. They start
2807
- # from a fresh scope with the same environment, then receive
2808
- # the parameter bindings. Slice 7 phase 2: instance defs ALSO
2809
- # seed their `ivars` map from the class-level accumulator so
2810
- # `def get; @x; end` reads the type that a sibling
2811
- # `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.
2812
2456
  fresh = build_fresh_body_scope
2813
2457
  body_self = self_type_for_method_body(singleton: singleton)
2814
2458
  fresh = fresh.with_self_type(body_self) if body_self
2815
2459
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2816
2460
  fresh = seed_class_cvars(fresh)
2817
2461
  fresh = seed_program_globals(fresh)
2818
- # ADR-48 Struct slice 3 — install the method body's fold-safe-local set
2819
- # so a member read off a mutation-free local folds during the in-body
2820
- # 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).
2821
2464
  fresh = fresh.with_struct_fold_safe(
2822
2465
  StructFoldSafety.fold_safe_locals(
2823
2466
  def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2824
2467
  )
2825
2468
  )
2826
- 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)
2827
2483
  end
2828
2484
 
2829
- # ADR-67 WD3 — consult the call-site parameter-inference table for this
2830
- # `def` and replace each undeclared (untyped) parameter binding with its
2831
- # inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2832
- # from the lexical class path the same triple
2833
- # {Inference::ParameterInferenceCollector} records. An RBS-declared
2834
- # parameter (a non-untyped binding) always wins. No-op when the table is
2835
- # 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.
2836
2490
  def seed_inferred_param_types(bindings, def_node, singleton)
2837
2491
  inferred = scope.param_inferred_types
2838
2492
  return bindings if inferred.empty?
@@ -2850,8 +2504,8 @@ module Rigor
2850
2504
  merged
2851
2505
  end
2852
2506
 
2853
- # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
2854
- # 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.
2855
2509
  def untyped_binding?(type)
2856
2510
  type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
2857
2511
  end
@@ -2865,20 +2519,17 @@ module Rigor
2865
2519
  seeded = scope.class_ivars_for(path)
2866
2520
  return body_scope if seeded.empty?
2867
2521
 
2868
- # ADR-58 WD1 — the class-ivar index unions every `@x = …` write across
2869
- # the class flow-insensitively, so a ctor `@x = nil` seed makes a read
2870
- # in a *different* method type `T | nil`. That `nil` is
2871
- # declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
2872
- # marks each seeded ivar: `possible-nil-receiver` then declines to fire
2873
- # on the cross-method invariant unless a method-local write or
2874
- # 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).
2875
2527
  seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
2876
2528
  end
2877
2529
 
2878
- # Cvars are visible from BOTH instance and singleton method
2879
- # bodies of the enclosing class, so this seed is unconditional
2880
- # (no `singleton:` gate). At the top-level (no class context)
2881
- # 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.
2882
2533
  def seed_class_cvars(body_scope)
2883
2534
  path = current_class_path
2884
2535
  return body_scope if path.nil?
@@ -2889,11 +2540,9 @@ module Rigor
2889
2540
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
2890
2541
  end
2891
2542
 
2892
- # Globals are process-wide. The body scope already inherited
2893
- # the program-globals accumulator through `with_program_globals`;
2894
- # seeding here just materialises each entry into the body's
2895
- # `globals` map so reads observe a precise type without
2896
- # 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.
2897
2546
  def seed_program_globals(body_scope)
2898
2547
  seeded = scope.program_globals
2899
2548
  return body_scope if seeded.empty?
@@ -2901,19 +2550,14 @@ module Rigor
2901
2550
  seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
2902
2551
  end
2903
2552
 
2904
- # Slice A-declarations. Class- and method-bodies start from a
2905
- # fresh local-empty scope, but they MUST keep the
2906
- # `declared_types` table visible at the outer scope so the
2907
- # ScopeIndexer-populated declaration overrides
2908
- # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
2909
- # 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.
2910
2556
  def build_fresh_body_scope
2911
- # Single allocation instead of a deep `with_*` chain — this runs
2912
- # per class/method body on the main walk, so the chain's throwaway
2913
- # intermediate Scopes were a top `Scope#rebuild` source (ADR-44).
2914
- # Local-empty by design; the discovery index is inherited whole by
2915
- # reference (ADR-53 Track A), so a table added to the index can no
2916
- # 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.
2917
2561
  Scope.new(
2918
2562
  environment: scope.environment,
2919
2563
  locals: {}.freeze,
@@ -2929,16 +2573,11 @@ module Rigor
2929
2573
  def_receiver_targets_lexical_self?(def_node.receiver)
2930
2574
  end
2931
2575
 
2932
- # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
2933
- # `module Meta`) explicit-receiver def that semantically
2934
- # equals `def self.bar` because the receiver constant
2935
- # resolves to `self` at the def-site. Matched against the
2936
- # current class context's tail to cover both the
2937
- # `def OpenURI.x` form (single segment) and the
2938
- # `def OpenURI::Meta.x` form (qualified path). Cross-class
2939
- # receivers (`def Bar.baz` inside `module Foo` where the
2940
- # receiver names a different constant) are not promoted to
2941
- # 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.
2942
2581
  def def_receiver_targets_lexical_self?(receiver)
2943
2582
  return false if @class_context.empty?
2944
2583
 
@@ -2957,18 +2596,16 @@ module Rigor
2957
2596
  end
2958
2597
  end
2959
2598
 
2960
- # Slice A-engine. Inside a class body `class Foo; ...; end`,
2961
- # `self` is the class object `Singleton[Foo]`. Returns nil
2962
- # 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).
2963
2601
  def self_type_for_class_body(class_context)
2964
2602
  return nil if class_context.empty?
2965
2603
 
2966
2604
  Type::Combinator.singleton_of(class_context.map(&:name).join("::"))
2967
2605
  end
2968
2606
 
2969
- # Slice A-engine. Inside a method body, `self` depends on
2970
- # whether the def is on the singleton or instance side of the
2971
- # 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:
2972
2609
  #
2973
2610
  # - `def self.foo` or any def inside `class << self`: self is
2974
2611
  # the class object → `Singleton[Foo]`.
@@ -2999,16 +2636,11 @@ module Rigor
2999
2636
  target = singleton_constant_target(node.expression)
3000
2637
  return @class_context unless target
3001
2638
 
3002
- # `class << Foo` inside `class Foo` (the canonical
3003
- # pattern in Ruby's own time.rb) is semantically
3004
- # `class << self` replace the enclosing frame with a
3005
- # singleton frame for the same name so method
3006
- # registration and `self_type` lookup land on Foo.
3007
- # When the target names a different constant (rare
3008
- # cross-class form), append a fresh singleton frame
3009
- # tagged with the target FQN; the bodies are scoped to
3010
- # that target rather than to the lexical enclosing
3011
- # 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.
3012
2644
  if !@class_context.empty? && @class_context.last.name == target
3013
2645
  outer = @class_context[0..-2]
3014
2646
  outer + [ClassFrame.new(name: target, singleton: true)]
@@ -3029,10 +2661,8 @@ module Rigor
3029
2661
  end
3030
2662
  end
3031
2663
 
3032
- # The qualified name of the immediately-enclosing class (joining
3033
- # every nested `ClassFrame` with `::`). Returns `nil` for a
3034
- # top-level def with no enclosing class, which routes the
3035
- # 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.
3036
2666
  def current_class_path
3037
2667
  return nil if @class_context.empty?
3038
2668
 
@@ -3045,27 +2675,23 @@ module Rigor
3045
2675
 
3046
2676
  # ----- helpers -----
3047
2677
 
3048
- # Explicit `return value` (including `return` inside a block, which in
3049
- # Ruby returns from the *enclosing method*). The control-transfer value
3050
- # is `Bot` a `return` produces no value at its own position but the
3051
- # returned expression's type is recorded into the active return sink so
3052
- # the method-return inference joins it with the body's tail type.
3053
- # Returns inside a nested `def`/lambda are barriers: `eval_def` clears
3054
- # the sink around the nested body, so this handler only ever appends a
3055
- # 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.
3056
2684
  def eval_return(node)
3057
2685
  sink = Thread.current[RETURN_SINK_KEY]
3058
2686
  record_return_value(node, sink) if sink
3059
2687
  [Type::Combinator.bot, scope]
3060
2688
  end
3061
2689
 
3062
- # A `break` transfers control to the loop exit (its flow value is `Bot`,
3063
- # like `return`). It records the current scope into the active loop's
3064
- # break sink so the loop join can recover a `break`-path binding the
3065
- # fall-through would drop (`flag = true; break` -> `flag` is `false |
3066
- # true` after the loop). nil sink = a `break` not inside an inferred
3067
- # loop body (a block targeting a method, or top-level) — left to the
3068
- # 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.
3069
2695
  def eval_break(node)
3070
2696
  sink = Thread.current[BREAK_SINK_KEY]
3071
2697
  sink << [node, scope] if sink
@@ -3074,10 +2700,9 @@ module Rigor
3074
2700
 
3075
2701
  def record_return_value(node, sink)
3076
2702
  args = node.arguments&.arguments || []
3077
- # `return` with no argument returns nil; `return a` records the
3078
- # argument's type; `return a, b, c` packs a Tuple in Ruby a
3079
- # multi-value return yields the array `[a, b, c]`, so the inferred
3080
- # 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.
3081
2706
  if args.empty?
3082
2707
  sink << Type::Combinator.constant_of(nil)
3083
2708
  elsif args.size == 1
@@ -3099,16 +2724,12 @@ module Rigor
3099
2724
  ).evaluate(node)
3100
2725
  end
3101
2726
 
3102
- # Slice 7 phase 14 — branch exit detection. Returns true
3103
- # when the branch's body unconditionally exits the
3104
- # surrounding control flow through a `return`, `next`,
3105
- # `break`, or `raise`. Used by `eval_if` / `eval_unless`
3106
- # to narrow the post-scope: when one branch exits, the
3107
- # surrounding scope can carry the OTHER branch's edge
3108
- # 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.
3109
2731
  #
3110
- # The detection is intentionally conservative — it
3111
- # recognises only the most common patterns:
2732
+ # The detection is intentionally conservative — it recognises only the most common patterns:
3112
2733
  # - A `Prism::ReturnNode`, `NextNode`, `BreakNode`.
3113
2734
  # - A `Prism::CallNode` whose name is `:raise` or `:throw`.
3114
2735
  # - A `Prism::StatementsNode`, `Prism::ParenthesesNode`, or
@@ -3145,18 +2766,12 @@ module Rigor
3145
2766
  end
3146
2767
  end
3147
2768
 
3148
- # ADR-24 WD6 / slice 3 — generalised terminating-branch
3149
- # detection. `branch_unconditionally_exits?` recognises a
3150
- # branch SYNTACTICALLY (return / next / break / a call
3151
- # named raise / throw / exit / abort / fail). A branch
3152
- # whose *inferred type is `Bot`* also terminates it
3153
- # cannot produce a value, so control never falls through
3154
- # it — regardless of how it is spelled. The canonical
3155
- # case is a resolved guard helper (`fail_with_message(...)`)
3156
- # whose body always raises: ADR-24 slice 1 types the call
3157
- # `bot`, and this OR-test makes `helper(...) if x.nil?`
3158
- # narrow exactly like `raise ... if x.nil?`. The branch
3159
- # 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`.
3160
2775
  def branch_terminates?(branch_node, branch_type)
3161
2776
  branch_unconditionally_exits?(branch_node) ||
3162
2777
  branch_type.is_a?(Type::Bot)
@@ -3168,12 +2783,9 @@ module Rigor
3168
2783
  sub_eval(branch_node, branch_scope)
3169
2784
  end
3170
2785
 
3171
- # Joins two branch scopes at a control-flow merge point. Names
3172
- # bound in only one branch are nil-injected into the other side
3173
- # so the joined scope sees them as `T | nil` rather than dropping
3174
- # them outright. This implements the contract the Slice 3 phase 1
3175
- # `Scope#join` documentation defers to the statement-level
3176
- # 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.
3177
2789
  def join_with_nil_injection(scope_a, scope_b)
3178
2790
  nil_const = Type::Combinator.constant_of(nil)
3179
2791
  a_keys = scope_a.locals.keys
@@ -3186,21 +2798,17 @@ module Rigor
3186
2798
  aug_a.join(aug_b)
3187
2799
  end
3188
2800
 
3189
- # Generalises {#join_with_nil_injection} to N branches (case/when,
3190
- # begin/rescue chain). The reduce order does not affect the
3191
- # result because nil-injection commutes with union under
3192
- # `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`.
3193
2803
  def reduce_scopes_with_nil_injection(scopes)
3194
2804
  scopes.reduce { |a, b| join_with_nil_injection(a, b) }
3195
2805
  end
3196
2806
 
3197
- # ---------------------------------------------------------------
3198
- # rescue variable binding helpers
2807
+ # --------------------------------------------------------------- rescue variable binding helpers
3199
2808
  # ---------------------------------------------------------------
3200
2809
 
3201
- # Returns `scope` extended with the rescue reference variable bound
3202
- # to the exception instance type. Leaves scope unchanged when the
3203
- # 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`).
3204
2812
  def bind_rescue_reference(rescue_node, scope)
3205
2813
  ref = rescue_node.reference
3206
2814
  return scope unless ref.is_a?(Prism::LocalVariableTargetNode)
@@ -3208,11 +2816,9 @@ module Rigor
3208
2816
  scope.with_local(ref.name, rescue_exception_type(rescue_node, scope))
3209
2817
  end
3210
2818
 
3211
- # Derives the exception instance type for a `RescueNode`. When the
3212
- # exceptions list is empty (bare `rescue`) the type is
3213
- # `StandardError`. When one or more exception classes are named the
3214
- # types are unioned. Falls back to `StandardError` for any class
3215
- # 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.
3216
2822
  def rescue_exception_type(rescue_node, scope)
3217
2823
  exceptions = rescue_node.exceptions
3218
2824
  if exceptions.empty?
@@ -3226,20 +2832,18 @@ module Rigor
3226
2832
  end
3227
2833
  end
3228
2834
 
3229
- # ---------------------------------------------------------------
3230
- # `case/in` pattern variable binding helpers
2835
+ # --------------------------------------------------------------- `case/in` pattern variable binding helpers
3231
2836
  # ---------------------------------------------------------------
3232
2837
 
3233
- # Builds the entry scope for an `in` branch by injecting every
3234
- # 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.
3235
2840
  def apply_in_pattern_bindings(subject, pattern, scope)
3236
2841
  bindings = collect_in_pattern_bindings(subject, pattern, scope)
3237
2842
  bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
3238
2843
  end
3239
2844
 
3240
- # Returns an array of `[Symbol, Rigor::Type]` pairs for every
3241
- # variable captured by `pattern`. Unrecognised pattern nodes
3242
- # 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).
3243
2847
  def collect_in_pattern_bindings(subject, pattern, scope)
3244
2848
  case pattern
3245
2849
  when Prism::CapturePatternNode
@@ -3292,11 +2896,9 @@ module Rigor
3292
2896
  bindings
3293
2897
  end
3294
2898
 
3295
- # `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of
3296
- # the unmatched elements; bind `rest` to `Array[untyped]` rather
3297
- # than the previous bare `untyped`. Per-element typing waits on
3298
- # subject-aware element-type extraction (the binder doesn't see
3299
- # 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).
3300
2902
  def append_array_splat_binding(bindings, splat)
3301
2903
  return unless splat.is_a?(Prism::SplatNode)
3302
2904
 
@@ -3306,10 +2908,8 @@ module Rigor
3306
2908
  bindings << [target.name, Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
3307
2909
  end
3308
2910
 
3309
- # `{ key:, **rest }` binds `rest` to a Hash whose keys are
3310
- # Symbols (the only legal key shape for a hash pattern) and
3311
- # whose values are untyped (the binder can't see the subject's
3312
- # 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).
3313
2913
  def hash_pattern_rest_type
3314
2914
  Type::Combinator.nominal_of(
3315
2915
  "Hash",
@@ -3317,14 +2917,12 @@ module Rigor
3317
2917
  )
3318
2918
  end
3319
2919
 
3320
- # ---------------------------------------------------------------
3321
- # named-capture regex binding (`MatchWriteNode`)
2920
+ # --------------------------------------------------------------- named-capture regex binding (`MatchWriteNode`)
3322
2921
  # ---------------------------------------------------------------
3323
2922
 
3324
- # `/(?<year>\d+)/ =~ str` — Prism emits a `MatchWriteNode` that
3325
- # wraps the `=~` call and lists the named-capture targets. Each
3326
- # target is bound to `String | nil` (the capture is absent as nil
3327
- # 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).
3328
2926
  def eval_match_write(node)
3329
2927
  match_type, post_scope = sub_eval(node.call, scope)
3330
2928
  string_or_nil = Type::Combinator.union(
@@ -3339,23 +2937,19 @@ module Rigor
3339
2937
  [match_type, bound_scope]
3340
2938
  end
3341
2939
 
3342
- # ---------------------------------------------------------------
3343
- # shared type conversion helper
2940
+ # --------------------------------------------------------------- shared type conversion helper
3344
2941
  # ---------------------------------------------------------------
3345
2942
 
3346
- # Converts a `Singleton[ClassName]` (the class object) to the
3347
- # corresponding `Nominal[ClassName]` (an instance). Falls back to
3348
- # `untyped` for carriers that are not Singleton (e.g. Dynamic[Top]
3349
- # 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).
3350
2946
  def singleton_to_nominal(type)
3351
2947
  type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
3352
2948
  end
3353
2949
 
3354
- # Returns the type to bind for a `CapturePatternNode`'s target.
3355
- # Plain class references collapse to the matching `Nominal[T]`;
3356
- # `AlternationPatternNode` (`Integer | String => x`) unions every
3357
- # alternate's resolved type. Anything else falls back to
3358
- # `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).
3359
2953
  def pattern_capture_type(value_node, scope)
3360
2954
  if value_node.is_a?(Prism::AlternationPatternNode)
3361
2955
  left = pattern_capture_type(value_node.left, scope)
@@ -3366,13 +2960,10 @@ module Rigor
3366
2960
  end
3367
2961
  end
3368
2962
 
3369
- # `in PatternA | PatternB` — Ruby requires both alternates to
3370
- # bind the same names, but the binder runs against the AST and
3371
- # cannot enforce that. We collect bindings from each side and
3372
- # merge by name, unioning types when both alternates contribute.
3373
- # Names that only one alternate contributes still surface (the
3374
- # parser would have rejected the case at compile time, so by the
3375
- # 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).
3376
2967
  def collect_alternation_pattern_bindings(subject, pattern, scope)
3377
2968
  left = collect_in_pattern_bindings(subject, pattern.left, scope)
3378
2969
  right = collect_in_pattern_bindings(subject, pattern.right, scope)