rigortype 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (433) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/04-diagnostics.md +7 -4
  11. data/docs/manual/06-baseline.md +5 -2
  12. data/docs/manual/07-plugins.md +1 -1
  13. data/docs/manual/14-rails-quickstart.md +4 -2
  14. data/docs/manual/15-type-protection-coverage.md +27 -4
  15. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  16. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  17. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  18. data/lib/rigor/analysis/baseline.rb +56 -96
  19. data/lib/rigor/analysis/buffer_binding.rb +13 -20
  20. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +29 -48
  21. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +35 -54
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +23 -37
  23. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +16 -23
  24. data/lib/rigor/analysis/check_rules/rule_walk.rb +48 -71
  25. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +19 -28
  26. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +48 -67
  27. data/lib/rigor/analysis/check_rules.rb +174 -274
  28. data/lib/rigor/analysis/dependency_recorder.rb +26 -38
  29. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +13 -25
  30. data/lib/rigor/analysis/dependency_source_inference/builder.rb +17 -30
  31. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +15 -26
  32. data/lib/rigor/analysis/dependency_source_inference/index.rb +45 -76
  33. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +23 -40
  34. data/lib/rigor/analysis/dependency_source_inference/walker.rb +50 -83
  35. data/lib/rigor/analysis/dependency_source_inference.rb +6 -10
  36. data/lib/rigor/analysis/diagnostic.rb +48 -75
  37. data/lib/rigor/analysis/erb_template_detector.rb +13 -18
  38. data/lib/rigor/analysis/fact_store.rb +12 -22
  39. data/lib/rigor/analysis/incremental.rb +47 -63
  40. data/lib/rigor/analysis/incremental_session.rb +74 -100
  41. data/lib/rigor/analysis/project_scan.rb +12 -23
  42. data/lib/rigor/analysis/result.rb +3 -5
  43. data/lib/rigor/analysis/rule_catalog.rb +67 -95
  44. data/lib/rigor/analysis/run_stats.rb +38 -61
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +101 -160
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +121 -188
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +62 -96
  48. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -18
  49. data/lib/rigor/analysis/runner.rb +220 -333
  50. data/lib/rigor/analysis/self_call_resolution_recorder.rb +30 -43
  51. data/lib/rigor/analysis/worker_session.rb +64 -102
  52. data/lib/rigor/ast/type_node.rb +6 -8
  53. data/lib/rigor/ast.rb +6 -10
  54. data/lib/rigor/bleeding_edge.rb +27 -35
  55. data/lib/rigor/builtins/hkt_builtins.rb +7 -14
  56. data/lib/rigor/builtins/imported_refinements.rb +106 -149
  57. data/lib/rigor/builtins/predefined_constant_refinements.rb +51 -65
  58. data/lib/rigor/builtins/regex_refinement.rb +32 -43
  59. data/lib/rigor/builtins/static_return_refinements.rb +57 -81
  60. data/lib/rigor/cache/descriptor.rb +58 -94
  61. data/lib/rigor/cache/incremental_snapshot.rb +32 -48
  62. data/lib/rigor/cache/rbs_cache_producer.rb +10 -15
  63. data/lib/rigor/cache/rbs_class_ancestor_table.rb +10 -16
  64. data/lib/rigor/cache/rbs_class_type_param_names.rb +10 -16
  65. data/lib/rigor/cache/rbs_constant_table.rb +8 -13
  66. data/lib/rigor/cache/rbs_descriptor.rb +8 -13
  67. data/lib/rigor/cache/rbs_environment.rb +11 -19
  68. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +11 -19
  69. data/lib/rigor/cache/rbs_known_class_names.rb +8 -12
  70. data/lib/rigor/cache/store.rb +248 -186
  71. data/lib/rigor/cli/annotate_command.rb +51 -88
  72. data/lib/rigor/cli/baseline_command.rb +10 -17
  73. data/lib/rigor/cli/check_command.rb +121 -199
  74. data/lib/rigor/cli/check_runner_factory.rb +8 -14
  75. data/lib/rigor/cli/ci_detector.rb +9 -14
  76. data/lib/rigor/cli/command.rb +8 -14
  77. data/lib/rigor/cli/coverage_command.rb +62 -48
  78. data/lib/rigor/cli/coverage_mutation.rb +14 -19
  79. data/lib/rigor/cli/coverage_report.rb +0 -2
  80. data/lib/rigor/cli/coverage_scan.rb +7 -11
  81. data/lib/rigor/cli/diagnostic_formats.rb +46 -62
  82. data/lib/rigor/cli/diff_command.rb +10 -20
  83. data/lib/rigor/cli/docs_command.rb +26 -40
  84. data/lib/rigor/cli/doctor_command.rb +8 -9
  85. data/lib/rigor/cli/explain_command.rb +4 -7
  86. data/lib/rigor/cli/fused_protection_renderer.rb +4 -5
  87. data/lib/rigor/cli/fused_protection_report.rb +6 -8
  88. data/lib/rigor/cli/lsp_command.rb +11 -19
  89. data/lib/rigor/cli/mcp_command.rb +4 -6
  90. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -4
  91. data/lib/rigor/cli/mutation_protection_report.rb +6 -9
  92. data/lib/rigor/cli/options.rb +11 -19
  93. data/lib/rigor/cli/plugin_command.rb +18 -30
  94. data/lib/rigor/cli/plugins_command.rb +29 -51
  95. data/lib/rigor/cli/plugins_renderer.rb +12 -20
  96. data/lib/rigor/cli/prism_colorizer.rb +13 -19
  97. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  98. data/lib/rigor/cli/protection_renderer.rb +5 -7
  99. data/lib/rigor/cli/protection_report.rb +34 -18
  100. data/lib/rigor/cli/renderable.rb +4 -6
  101. data/lib/rigor/cli/show_bleedingedge_command.rb +5 -8
  102. data/lib/rigor/cli/sig_gen_command.rb +14 -26
  103. data/lib/rigor/cli/skill_command.rb +30 -47
  104. data/lib/rigor/cli/skill_describe.rb +40 -64
  105. data/lib/rigor/cli/trace_command.rb +9 -16
  106. data/lib/rigor/cli/trace_renderer.rb +35 -51
  107. data/lib/rigor/cli/triage_command.rb +6 -10
  108. data/lib/rigor/cli/triage_renderer.rb +1 -2
  109. data/lib/rigor/cli/type_of_command.rb +14 -23
  110. data/lib/rigor/cli/type_of_renderer.rb +3 -5
  111. data/lib/rigor/cli/type_scan_command.rb +6 -10
  112. data/lib/rigor/cli/type_scan_renderer.rb +3 -5
  113. data/lib/rigor/cli/type_scan_report.rb +2 -3
  114. data/lib/rigor/cli/upgrade_command.rb +2 -3
  115. data/lib/rigor/cli.rb +16 -28
  116. data/lib/rigor/config_audit.rb +35 -44
  117. data/lib/rigor/configuration/dependencies.rb +41 -70
  118. data/lib/rigor/configuration/severity_profile.rb +28 -42
  119. data/lib/rigor/configuration.rb +150 -241
  120. data/lib/rigor/environment/bundle_sig_discovery.rb +74 -118
  121. data/lib/rigor/environment/class_registry.rb +19 -26
  122. data/lib/rigor/environment/constant_type_cache_holder.rb +14 -21
  123. data/lib/rigor/environment/hkt_registry_holder.rb +6 -12
  124. data/lib/rigor/environment/lockfile_resolver.rb +31 -47
  125. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  126. data/lib/rigor/environment/rbs_collection_discovery.rb +39 -63
  127. data/lib/rigor/environment/rbs_coverage_report.rb +24 -42
  128. data/lib/rigor/environment/rbs_loader.rb +420 -518
  129. data/lib/rigor/environment/reflection.rb +28 -53
  130. data/lib/rigor/environment/reporters.rb +13 -24
  131. data/lib/rigor/environment.rb +225 -304
  132. data/lib/rigor/flow_contribution/conflict.rb +16 -27
  133. data/lib/rigor/flow_contribution/element.rb +7 -12
  134. data/lib/rigor/flow_contribution/fact.rb +33 -57
  135. data/lib/rigor/flow_contribution/merge_result.rb +6 -9
  136. data/lib/rigor/flow_contribution/merger.rb +32 -47
  137. data/lib/rigor/flow_contribution.rb +37 -55
  138. data/lib/rigor/inference/acceptance.rb +133 -219
  139. data/lib/rigor/inference/block_parameter_binder.rb +52 -82
  140. data/lib/rigor/inference/body_fixpoint.rb +22 -30
  141. data/lib/rigor/inference/budget_trace.rb +43 -63
  142. data/lib/rigor/inference/builtins/array_catalog.rb +4 -6
  143. data/lib/rigor/inference/builtins/comparable_catalog.rb +4 -6
  144. data/lib/rigor/inference/builtins/complex_catalog.rb +13 -19
  145. data/lib/rigor/inference/builtins/date_catalog.rb +38 -67
  146. data/lib/rigor/inference/builtins/encoding_catalog.rb +22 -36
  147. data/lib/rigor/inference/builtins/enumerable_catalog.rb +4 -6
  148. data/lib/rigor/inference/builtins/exception_catalog.rb +38 -57
  149. data/lib/rigor/inference/builtins/hash_catalog.rb +9 -14
  150. data/lib/rigor/inference/builtins/method_catalog.rb +29 -48
  151. data/lib/rigor/inference/builtins/numeric_catalog.rb +13 -18
  152. data/lib/rigor/inference/builtins/pathname_catalog.rb +8 -13
  153. data/lib/rigor/inference/builtins/proc_catalog.rb +38 -62
  154. data/lib/rigor/inference/builtins/random_catalog.rb +21 -32
  155. data/lib/rigor/inference/builtins/range_catalog.rb +12 -21
  156. data/lib/rigor/inference/builtins/rational_catalog.rb +9 -15
  157. data/lib/rigor/inference/builtins/re_catalog.rb +29 -48
  158. data/lib/rigor/inference/builtins/set_catalog.rb +18 -27
  159. data/lib/rigor/inference/builtins/string_catalog.rb +12 -19
  160. data/lib/rigor/inference/builtins/struct_catalog.rb +18 -30
  161. data/lib/rigor/inference/builtins/time_catalog.rb +28 -44
  162. data/lib/rigor/inference/closure_escape_analyzer.rb +44 -67
  163. data/lib/rigor/inference/coverage_scanner.rb +13 -18
  164. data/lib/rigor/inference/def_return_typer.rb +8 -14
  165. data/lib/rigor/inference/dynamic_origin.rb +23 -20
  166. data/lib/rigor/inference/expression_typer.rb +794 -1154
  167. data/lib/rigor/inference/fallback.rb +8 -12
  168. data/lib/rigor/inference/fallback_tracer.rb +4 -10
  169. data/lib/rigor/inference/flow_tracer.rb +25 -36
  170. data/lib/rigor/inference/fork_map.rb +87 -0
  171. data/lib/rigor/inference/hkt_body.rb +45 -68
  172. data/lib/rigor/inference/hkt_body_parser.rb +33 -52
  173. data/lib/rigor/inference/hkt_reducer.rb +38 -59
  174. data/lib/rigor/inference/hkt_registry.rb +50 -76
  175. data/lib/rigor/inference/indexed_narrowing.rb +55 -81
  176. data/lib/rigor/inference/macro_block_self_type.rb +21 -34
  177. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +11 -18
  178. data/lib/rigor/inference/method_dispatcher/block_folding.rb +49 -83
  179. data/lib/rigor/inference/method_dispatcher/call_context.rb +16 -23
  180. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +13 -22
  181. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +307 -454
  182. data/lib/rigor/inference/method_dispatcher/data_folding.rb +24 -34
  183. data/lib/rigor/inference/method_dispatcher/file_folding.rb +22 -37
  184. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +52 -81
  185. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +51 -78
  186. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +60 -97
  187. data/lib/rigor/inference/method_dispatcher/math_folding.rb +23 -34
  188. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +18 -26
  189. data/lib/rigor/inference/method_dispatcher/method_folding.rb +37 -71
  190. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +114 -182
  191. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +122 -193
  192. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +15 -24
  193. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +64 -93
  194. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +31 -45
  195. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -9
  196. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +356 -511
  197. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +22 -32
  198. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +15 -21
  199. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +66 -82
  200. data/lib/rigor/inference/method_dispatcher/time_folding.rb +10 -15
  201. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -13
  202. data/lib/rigor/inference/method_dispatcher.rb +378 -524
  203. data/lib/rigor/inference/method_parameter_binder.rb +81 -114
  204. data/lib/rigor/inference/multi_target_binder.rb +51 -68
  205. data/lib/rigor/inference/mutation_widening.rb +115 -158
  206. data/lib/rigor/inference/narrowing.rb +627 -727
  207. data/lib/rigor/inference/origin_lookup.rb +38 -0
  208. data/lib/rigor/inference/parameter_inference_collector.rb +154 -109
  209. data/lib/rigor/inference/precision_scanner.rb +26 -35
  210. data/lib/rigor/inference/project_patched_methods.rb +20 -32
  211. data/lib/rigor/inference/project_patched_scanner.rb +23 -37
  212. data/lib/rigor/inference/protection_scanner.rb +16 -18
  213. data/lib/rigor/inference/rbs_type_translator.rb +49 -66
  214. data/lib/rigor/inference/scope_indexer.rb +473 -821
  215. data/lib/rigor/inference/statement_evaluator.rb +754 -1114
  216. data/lib/rigor/inference/struct_fold_safety.rb +34 -48
  217. data/lib/rigor/inference/synthetic_method.rb +9 -16
  218. data/lib/rigor/inference/synthetic_method_index.rb +20 -35
  219. data/lib/rigor/inference/synthetic_method_scanner.rb +63 -103
  220. data/lib/rigor/language_server/buffer_resolution.rb +6 -9
  221. data/lib/rigor/language_server/buffer_table.rb +11 -18
  222. data/lib/rigor/language_server/completion_provider.rb +69 -116
  223. data/lib/rigor/language_server/debouncer.rb +16 -25
  224. data/lib/rigor/language_server/diagnostic_publisher.rb +26 -40
  225. data/lib/rigor/language_server/document_symbol_provider.rb +14 -19
  226. data/lib/rigor/language_server/folding_range_provider.rb +11 -19
  227. data/lib/rigor/language_server/hover_provider.rb +8 -11
  228. data/lib/rigor/language_server/hover_renderer.rb +56 -93
  229. data/lib/rigor/language_server/loop.rb +14 -19
  230. data/lib/rigor/language_server/project_context.rb +46 -74
  231. data/lib/rigor/language_server/selection_range_provider.rb +9 -12
  232. data/lib/rigor/language_server/server.rb +55 -83
  233. data/lib/rigor/language_server/signature_help_provider.rb +29 -46
  234. data/lib/rigor/language_server/synchronized_writer.rb +4 -7
  235. data/lib/rigor/language_server/uri.rb +8 -13
  236. data/lib/rigor/language_server.rb +4 -6
  237. data/lib/rigor/mcp/loop.rb +2 -3
  238. data/lib/rigor/mcp/server.rb +4 -7
  239. data/lib/rigor/mcp.rb +3 -6
  240. data/lib/rigor/plugin/access_denied_error.rb +5 -8
  241. data/lib/rigor/plugin/additional_initializer.rb +21 -31
  242. data/lib/rigor/plugin/base.rb +335 -518
  243. data/lib/rigor/plugin/blueprint.rb +14 -23
  244. data/lib/rigor/plugin/box.rb +18 -29
  245. data/lib/rigor/plugin/fact_store.rb +16 -26
  246. data/lib/rigor/plugin/inflector.rb +37 -53
  247. data/lib/rigor/plugin/io_boundary.rb +33 -56
  248. data/lib/rigor/plugin/isolation.rb +42 -55
  249. data/lib/rigor/plugin/load_error.rb +10 -15
  250. data/lib/rigor/plugin/loader.rb +30 -49
  251. data/lib/rigor/plugin/macro/block_as_method.rb +20 -32
  252. data/lib/rigor/plugin/macro/heredoc_template.rb +35 -58
  253. data/lib/rigor/plugin/macro/nested_class_template.rb +22 -36
  254. data/lib/rigor/plugin/macro/trait_registry.rb +34 -51
  255. data/lib/rigor/plugin/macro.rb +10 -15
  256. data/lib/rigor/plugin/manifest.rb +85 -144
  257. data/lib/rigor/plugin/node_context.rb +14 -22
  258. data/lib/rigor/plugin/node_rule_walk.rb +49 -74
  259. data/lib/rigor/plugin/protocol_contract.rb +25 -39
  260. data/lib/rigor/plugin/registry.rb +132 -205
  261. data/lib/rigor/plugin/services.rb +21 -33
  262. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -19
  263. data/lib/rigor/plugin/trust_policy.rb +24 -38
  264. data/lib/rigor/plugin/type_node_resolver.rb +15 -24
  265. data/lib/rigor/protection/diagnostic_oracle.rb +11 -13
  266. data/lib/rigor/protection/mutation_scanner.rb +27 -35
  267. data/lib/rigor/protection/mutator.rb +50 -70
  268. data/lib/rigor/protection/test_suite_oracle.rb +20 -27
  269. data/lib/rigor/rbs_extended/conformance_checker.rb +48 -68
  270. data/lib/rigor/rbs_extended/hkt_directives.rb +28 -54
  271. data/lib/rigor/rbs_extended/reporter.rb +24 -40
  272. data/lib/rigor/rbs_extended.rb +107 -197
  273. data/lib/rigor/reflection.rb +68 -86
  274. data/lib/rigor/scope/discovery_index.rb +14 -19
  275. data/lib/rigor/scope.rb +255 -310
  276. data/lib/rigor/sig_gen/classification.rb +6 -10
  277. data/lib/rigor/sig_gen/generator.rb +197 -323
  278. data/lib/rigor/sig_gen/layout_index.rb +12 -20
  279. data/lib/rigor/sig_gen/method_candidate.rb +12 -17
  280. data/lib/rigor/sig_gen/observation_collector.rb +38 -70
  281. data/lib/rigor/sig_gen/observed_call.rb +13 -23
  282. data/lib/rigor/sig_gen/path_mapper.rb +17 -29
  283. data/lib/rigor/sig_gen/renderer.rb +7 -13
  284. data/lib/rigor/sig_gen/type_elaborator.rb +15 -28
  285. data/lib/rigor/sig_gen/write_result.rb +8 -16
  286. data/lib/rigor/sig_gen/writer.rb +95 -174
  287. data/lib/rigor/sig_gen.rb +3 -6
  288. data/lib/rigor/signature_path_audit.rb +24 -30
  289. data/lib/rigor/source/constant_path.rb +10 -14
  290. data/lib/rigor/source/literals.rb +31 -45
  291. data/lib/rigor/source/node_locator.rb +9 -11
  292. data/lib/rigor/source/node_walker.rb +9 -13
  293. data/lib/rigor/source.rb +3 -4
  294. data/lib/rigor/testing.rb +16 -20
  295. data/lib/rigor/triage/catalogue.rb +38 -62
  296. data/lib/rigor/triage.rb +31 -52
  297. data/lib/rigor/trinary.rb +9 -13
  298. data/lib/rigor/type/acceptance_router.rb +4 -6
  299. data/lib/rigor/type/accepts_result.rb +10 -14
  300. data/lib/rigor/type/app.rb +19 -27
  301. data/lib/rigor/type/bot.rb +4 -6
  302. data/lib/rigor/type/bound_method.rb +10 -15
  303. data/lib/rigor/type/combinator.rb +165 -257
  304. data/lib/rigor/type/constant.rb +23 -34
  305. data/lib/rigor/type/data_class.rb +10 -15
  306. data/lib/rigor/type/data_instance.rb +14 -20
  307. data/lib/rigor/type/difference.rb +21 -32
  308. data/lib/rigor/type/dynamic.rb +3 -5
  309. data/lib/rigor/type/hash_shape.rb +32 -18
  310. data/lib/rigor/type/integer_range.rb +11 -16
  311. data/lib/rigor/type/intersection.rb +27 -42
  312. data/lib/rigor/type/nominal.rb +10 -15
  313. data/lib/rigor/type/plain_lattice.rb +9 -13
  314. data/lib/rigor/type/refined.rb +67 -114
  315. data/lib/rigor/type/singleton.rb +4 -6
  316. data/lib/rigor/type/struct_class.rb +11 -16
  317. data/lib/rigor/type/struct_instance.rb +15 -21
  318. data/lib/rigor/type/tuple.rb +14 -19
  319. data/lib/rigor/type/union.rb +30 -42
  320. data/lib/rigor/type_node/generic.rb +14 -26
  321. data/lib/rigor/type_node/identifier.rb +12 -19
  322. data/lib/rigor/type_node.rb +3 -12
  323. data/lib/rigor/value_semantics.rb +16 -21
  324. data/lib/rigor/version.rb +1 -1
  325. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +18 -30
  326. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +16 -29
  327. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +12 -23
  328. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +22 -39
  329. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +26 -46
  330. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +51 -87
  331. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +10 -17
  332. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +26 -39
  333. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +141 -184
  334. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +30 -51
  335. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +57 -92
  336. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +92 -108
  337. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +13 -22
  338. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +15 -25
  339. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +8 -13
  340. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +16 -24
  341. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +40 -48
  342. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +140 -129
  343. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +56 -59
  344. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +27 -41
  345. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +16 -24
  346. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  347. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +168 -241
  348. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +3 -5
  349. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +8 -14
  350. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +9 -15
  351. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +4 -7
  352. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +23 -41
  353. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +3 -5
  354. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +6 -10
  355. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +8 -11
  356. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  357. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +30 -45
  358. data/plugins/rigor-devise/lib/rigor-devise.rb +3 -5
  359. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +43 -69
  360. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +26 -35
  361. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +3 -5
  362. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +30 -47
  363. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +3 -5
  364. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +67 -113
  365. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +29 -46
  366. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +3 -5
  367. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -20
  368. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +14 -23
  369. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +2 -3
  370. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +22 -37
  371. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +28 -46
  372. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +13 -17
  373. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -47
  374. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +52 -80
  375. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +21 -31
  376. data/plugins/rigor-graphql/lib/rigor-graphql.rb +3 -5
  377. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +11 -17
  378. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +20 -34
  379. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +45 -75
  380. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +22 -36
  381. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +22 -41
  382. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +17 -32
  383. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +6 -12
  384. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +4 -8
  385. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +17 -32
  386. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -18
  387. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +58 -92
  388. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +20 -34
  389. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +20 -31
  390. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +49 -82
  391. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +55 -92
  392. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +51 -90
  393. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +17 -30
  394. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  395. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +38 -70
  396. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +61 -75
  397. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +319 -505
  398. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +91 -96
  399. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +32 -54
  400. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +8 -13
  401. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +9 -16
  402. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +24 -39
  403. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +13 -22
  404. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +34 -56
  405. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +22 -42
  406. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +43 -75
  407. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +27 -39
  408. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +14 -19
  409. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +20 -34
  410. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +28 -47
  411. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +21 -37
  412. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +13 -22
  413. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +18 -31
  414. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +6 -10
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +14 -29
  416. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +23 -41
  417. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +3 -5
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +23 -38
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +57 -96
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +22 -35
  421. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +41 -69
  422. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +6 -10
  423. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +24 -41
  424. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +29 -50
  425. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +55 -88
  426. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +159 -262
  427. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +21 -29
  428. data/plugins/rigor-statesman/lib/rigor-statesman.rb +3 -5
  429. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +23 -37
  430. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +4 -6
  431. data/sig/rigor/environment.rbs +1 -0
  432. data/sig/rigor/scope.rbs +6 -0
  433. metadata +9 -1
@@ -10,6 +10,7 @@ require_relative "block_parameter_binder"
10
10
  require_relative "body_fixpoint"
11
11
  require_relative "budget_trace"
12
12
  require_relative "dynamic_origin"
13
+ require_relative "origin_lookup"
13
14
  require_relative "fallback"
14
15
  require_relative "flow_tracer"
15
16
  require_relative "indexed_narrowing"
@@ -20,52 +21,41 @@ require_relative "struct_fold_safety"
20
21
 
21
22
  module Rigor
22
23
  module Inference
23
- # Translates AST nodes into Rigor::Type values, consulting the surrounding
24
- # Rigor::Scope for local-variable bindings and the environment registry
25
- # for nominal-type resolution. Pure: never mutates the receiver scope.
24
+ # Translates AST nodes into Rigor::Type values, consulting the surrounding Rigor::Scope for local-variable
25
+ # bindings and the environment registry for nominal-type resolution. Pure: never mutates the receiver
26
+ # scope.
26
27
  #
27
- # Accepts both real Prism nodes and synthetic Rigor::AST::Node
28
- # instances; the synthetic family lets callers and plugins ask
29
- # "what would the analyzer infer if a value of type T appeared here?"
30
- # without building a real Prism expression.
28
+ # Accepts both real Prism nodes and synthetic Rigor::AST::Node instances; the synthetic family lets
29
+ # callers and plugins ask "what would the analyzer infer if a value of type T appeared here?" without
30
+ # building a real Prism expression.
31
31
  #
32
- # Slice 1 recognises literal expressions, local-variable reads/writes,
33
- # shallow Array literals, and Rigor::AST::TypeNode. Slice 2 adds
34
- # Prism::CallNode (routed through Rigor::Inference::MethodDispatcher),
35
- # Prism::ArgumentsNode (a non-value position whose children are typed
36
- # individually by the CallNode handler), constant references resolved
37
- # through Rigor::Environment::ClassRegistry, hash and interpolated
38
- # string/symbol literals, definition expressions (def/class/module),
39
- # and explicit handlers for parameter, block, splat, instance/class/
40
- # global-variable, and self positions. Many of those handlers return
41
- # Dynamic[Top] silently because they are non-value or out-of-scope
42
- # positions for Slice 2; later slices refine them in place.
32
+ # Slice 1 recognises literal expressions, local-variable reads/writes, shallow Array literals, and
33
+ # Rigor::AST::TypeNode. Slice 2 adds Prism::CallNode (routed through Rigor::Inference::MethodDispatcher),
34
+ # Prism::ArgumentsNode (a non-value position whose children are typed individually by the CallNode
35
+ # handler), constant references resolved through Rigor::Environment::ClassRegistry, hash and interpolated
36
+ # string/symbol literals, definition expressions (def/class/module), and explicit handlers for parameter,
37
+ # block, splat, instance/class/global-variable, and self positions. Many of those handlers return
38
+ # Dynamic[Top] silently because they are non-value or out-of-scope positions for Slice 2; later slices
39
+ # refine them in place.
43
40
  #
44
- # Slice 4 phase 2b types bare-constant references (`Foo`, `Foo::Bar`)
45
- # as `Singleton[Foo]` rather than `Nominal[Foo]`, so that method
46
- # dispatch on the constant correctly looks up *class* methods. The
47
- # corresponding instance type is reachable through `Foo.new` and the
48
- # value-lattice projections.
41
+ # Slice 4 phase 2b types bare-constant references (`Foo`, `Foo::Bar`) as `Singleton[Foo]` rather than
42
+ # `Nominal[Foo]`, so that method dispatch on the constant correctly looks up *class* methods. The
43
+ # corresponding instance type is reachable through `Foo.new` and the value-lattice projections.
49
44
  #
50
- # Every other node falls back to Dynamic[Top] per the fail-soft
51
- # policy in docs/internal-spec/inference-engine.md. The optional
52
- # tracer is a Rigor::Inference::FallbackTracer (or any object
53
- # answering #record_fallback) that receives a Fallback event for
54
- # each fallback; the tracer MUST NOT change the return value of
55
- # type_of.
45
+ # Every other node falls back to Dynamic[Top] per the fail-soft policy in
46
+ # docs/internal-spec/inference-engine.md. The optional tracer is a Rigor::Inference::FallbackTracer (or
47
+ # any object answering #record_fallback) that receives a Fallback event for each fallback; the tracer
48
+ # MUST NOT change the return value of type_of.
56
49
  # rubocop:disable Metrics/ClassLength
57
50
  class ExpressionTyper
58
- # Hash-based dispatch keeps `type_of` linear and lets future slices add
59
- # node kinds without growing a single case statement past RuboCop's
60
- # cyclomatic budget. Anonymous Prism subclasses are not expected.
51
+ # Hash-based dispatch keeps `type_of` linear and lets future slices add node kinds without growing a
52
+ # single case statement past RuboCop's cyclomatic budget. Anonymous Prism subclasses are not expected.
61
53
  PRISM_DISPATCH = {
62
54
  # Literals
63
55
  Prism::IntegerNode => :type_of_literal_value,
64
56
  Prism::FloatNode => :type_of_literal_value,
65
- # `1i` / `2.5ri` lift via `node.value` which is already a
66
- # `Complex` Ruby value; same for `1r` / `1.5r` whose
67
- # value is a `Rational`. `Type::Constant` accepts both
68
- # via `SCALAR_CLASSES`.
57
+ # `1i` / `2.5ri` lift via `node.value` which is already a `Complex` Ruby value; same for `1r` / `1.5r`
58
+ # whose value is a `Rational`. `Type::Constant` accepts both via `SCALAR_CLASSES`.
69
59
  Prism::ImaginaryNode => :type_of_literal_value,
70
60
  Prism::RationalNode => :type_of_literal_value,
71
61
  Prism::SymbolNode => :symbol_type_for,
@@ -125,12 +115,10 @@ module Rigor
125
115
  Prism::IndexOrWriteNode => :type_of_assignment_write,
126
116
  Prism::IndexAndWriteNode => :type_of_assignment_write,
127
117
  Prism::MultiWriteNode => :type_of_assignment_write,
128
- # LHS-only target nodes (destructuring assignment, pattern matching,
129
- # `for x in xs`, block parameter `|a, (b, c)|`). They have no value
130
- # to extract the type-of pass acknowledges the node class so the
131
- # coverage scanner stops flagging it; binding the inner names back
132
- # into the scope is the StatementEvaluator / MultiTargetBinder /
133
- # BlockParameterBinder side's concern.
118
+ # LHS-only target nodes (destructuring assignment, pattern matching, `for x in xs`, block parameter
119
+ # `|a, (b, c)|`). They have no value to extract — the type-of pass acknowledges the node class so the
120
+ # coverage scanner stops flagging it; binding the inner names back into the scope is the
121
+ # StatementEvaluator / MultiTargetBinder / BlockParameterBinder side's concern.
134
122
  Prism::LocalVariableTargetNode => :type_of_non_value,
135
123
  Prism::MultiTargetNode => :type_of_non_value,
136
124
  Prism::InstanceVariableTargetNode => :type_of_non_value,
@@ -203,9 +191,8 @@ module Rigor
203
191
  Prism::ForwardingArgumentsNode => :type_of_non_value,
204
192
  Prism::WhileNode => :type_of_loop,
205
193
  Prism::UntilNode => :type_of_loop,
206
- # `for` matches `eval_for`'s statement-path policy: the loop
207
- # expression types `Constant[nil]` (no `break VALUE` observed),
208
- # same as `while` / `until` — annotating a `for`'s `end` line as
194
+ # `for` matches `eval_for`'s statement-path policy: the loop expression types `Constant[nil]` (no
195
+ # `break VALUE` observed), same as `while` / `until` — annotating a `for`'s `end` line as
209
196
  # `Dynamic[top]` was a display artifact of the old mapping.
210
197
  Prism::ForNode => :type_of_loop,
211
198
  Prism::DefinedNode => :type_of_defined,
@@ -242,13 +229,10 @@ module Rigor
242
229
  end
243
230
 
244
231
  def untraced_type_of(node)
245
- # Slice A-declarations. ScopeIndexer pre-fills
246
- # `scope.declared_types` for declaration-position nodes
247
- # (`module Foo` / `class Bar` headers) with the qualified
248
- # `Singleton` type so the header itself does not fall
249
- # through to `Dynamic[Top]`. The override is consulted
250
- # before any other dispatch and bypasses fail-soft
251
- # tracing on a recognised match.
232
+ # Slice A-declarations. ScopeIndexer pre-fills `scope.declared_types` for declaration-position nodes
233
+ # (`module Foo` / `class Bar` headers) with the qualified `Singleton` type so the header itself does
234
+ # not fall through to `Dynamic[Top]`. The override is consulted before any other dispatch and
235
+ # bypasses fail-soft tracing on a recognised match.
252
236
  declared = scope.declared_types[node]
253
237
  return declared if declared
254
238
 
@@ -284,26 +268,29 @@ module Rigor
284
268
  Type::Combinator.constant_of(nil)
285
269
  end
286
270
 
287
- # All `*WriteNode` flavours expose a `.value` rvalue child. Their type
288
- # is the type of that rvalue. Binding the result back into the scope
289
- # is the responsibility of the statement-level evaluator (Slice 3),
290
- # never of `type_of` itself.
271
+ # All `*WriteNode` flavours expose a `.value` rvalue child. Their type is the type of that rvalue.
272
+ # Binding the result back into the scope is the responsibility of the statement-level evaluator
273
+ # (Slice 3), never of `type_of` itself.
291
274
  def type_of_assignment_write(node)
292
275
  type_of(node.value)
293
276
  end
294
277
 
295
- # Slice 7 phase 1 — instance/class/global variable reads.
296
- # Each lookup returns the type currently bound in the
297
- # surrounding scope's per-kind binding map (populated by
298
- # `StatementEvaluator` write handlers within the same
299
- # method body), falling through to `Dynamic[Top]` when no
300
- # binding is recorded. Cross-method ivar/cvar inference is
301
- # a follow-up slice; the read handlers MUST NOT raise on a
302
- # missing binding and MUST NOT record a fallback event in
303
- # either branch — the absence of a binding is a recognised
304
- # semantic outcome, not a fail-soft compromise.
278
+ # Slice 7 phase 1 — instance/class/global variable reads. Each lookup returns the type currently bound
279
+ # in the surrounding scope's per-kind binding map (populated by `StatementEvaluator` write handlers
280
+ # within the same method body), falling through to `Dynamic[Top]` when no binding is recorded.
281
+ # Cross-method ivar/cvar inference is a follow-up slice; the read handlers MUST NOT raise on a missing
282
+ # binding and MUST NOT record a fallback event in either branch — the absence of a binding is a
283
+ # recognised semantic outcome, not a fail-soft compromise.
305
284
  def type_of_instance_variable_read(node)
306
- scope.ivar(node.name) || dynamic_top
285
+ bound = scope.ivar(node.name)
286
+ return bound if bound
287
+
288
+ # ADR-82 — an unbound instance-variable read is dynamic because the engine does not track this
289
+ # field's type (it is assigned in another method, or never seen). Route it to ivar-field typing
290
+ # (ADR-58) rather than reporting no cause, and let WD6 carry it through a `@x.foo.bar` chain. This
291
+ # records provenance only (still `dynamic_top`), never a fallback / tracer event.
292
+ scope.record_dynamic_origin(node, DynamicOrigin::INFERRED_RETURN_UNTYPED)
293
+ dynamic_top
307
294
  end
308
295
 
309
296
  def type_of_class_variable_read(node)
@@ -322,22 +309,18 @@ module Rigor
322
309
  statements_type_for(node.statements)
323
310
  end
324
311
 
325
- # Recognised position that does not produce a value: parameter lists
326
- # and individual parameter declarations, splats inside argument
327
- # lists, key-value pairs in hashes, and the implicit-rest token
328
- # inside destructuring. Returning Dynamic[Top] silently keeps these
329
- # off the unrecognised list without faking a value type.
312
+ # Recognised position that does not produce a value: parameter lists and individual parameter
313
+ # declarations, splats inside argument lists, key-value pairs in hashes, and the implicit-rest token
314
+ # inside destructuring. Returning Dynamic[Top] silently keeps these off the unrecognised list without
315
+ # faking a value type.
330
316
  def type_of_non_value(_node)
331
317
  dynamic_top
332
318
  end
333
319
 
334
- # `Prism::SelfNode` resolves to the scope's
335
- # `self_type` when one has been injected (by
336
- # `StatementEvaluator` at class-body and method-body
337
- # boundaries) or `Dynamic[Top]` at the top level. Class-body
338
- # `self` is `Singleton[<class>]`; instance-method `self` is
339
- # `Nominal[<class>]`; singleton-method `self` is
340
- # `Singleton[<class>]`.
320
+ # `Prism::SelfNode` resolves to the scope's `self_type` when one has been injected (by
321
+ # `StatementEvaluator` at class-body and method-body boundaries) or `Dynamic[Top]` at the top level.
322
+ # Class-body `self` is `Singleton[<class>]`; instance-method `self` is `Nominal[<class>]`;
323
+ # singleton-method `self` is `Singleton[<class>]`.
341
324
  def type_of_self_node(_node)
342
325
  scope.self_type || dynamic_top
343
326
  end
@@ -346,11 +329,9 @@ module Rigor
346
329
  dynamic_top
347
330
  end
348
331
 
349
- # `defined?(expr)` returns `String | nil` per Ruby semantics —
350
- # a description of the expression's category (`"local-variable"`,
351
- # `"method"`, ...) when defined, or `nil` when not. The argument
352
- # is not evaluated (it is statically inspected by the runtime),
353
- # so the typer does not recurse into it.
332
+ # `defined?(expr)` returns `String | nil` per Ruby semantics — a description of the expression's
333
+ # category (`"local-variable"`, `"method"`, ...) when defined, or `nil` when not. The argument is not
334
+ # evaluated (it is statically inspected by the runtime), so the typer does not recurse into it.
354
335
  def type_of_defined(_node)
355
336
  Type::Combinator.union(
356
337
  Type::Combinator.nominal_of("String"),
@@ -358,10 +339,9 @@ module Rigor
358
339
  )
359
340
  end
360
341
 
361
- # `$1`, `$&`, `$'`, `$+`, `$\`` — the regex back-reference and
362
- # numbered-capture globals each carry `String | nil`. They share
363
- # the typer because the typing rule is identical regardless of
364
- # which back-reference shape Prism emitted.
342
+ # `$1`, `$&`, `$'`, `$+`, `$\`` — the regex back-reference and numbered-capture globals each carry
343
+ # `String | nil`. They share the typer because the typing rule is identical regardless of which
344
+ # back-reference shape Prism emitted.
365
345
  def type_of_string_or_nil(_node)
366
346
  Type::Combinator.union(
367
347
  Type::Combinator.nominal_of("String"),
@@ -369,23 +349,20 @@ module Rigor
369
349
  )
370
350
  end
371
351
 
372
- # `$1` / `$2` / ... — numbered match-data globals. When the
373
- # narrowing tier has bound a tighter type for this number
374
- # (typically `String` after a `=~`-success guard like `unless
375
- # /(\d+)/ =~ s; raise; end`), prefer the scope-bound type.
376
- # Falls back to the default `String | nil`.
352
+ # `$1` / `$2` / ... — numbered match-data globals. When the narrowing tier has bound a tighter type for
353
+ # this number (typically `String` after a `=~`-success guard like `unless /(\d+)/ =~ s; raise; end`),
354
+ # prefer the scope-bound type. Falls back to the default `String | nil`.
377
355
  def type_of_numbered_reference(node)
378
356
  scope.global(:"$#{node.number}") || type_of_string_or_nil(node)
379
357
  end
380
358
 
381
- # `$&` / `$'` / `$\`` / `$+` — symbolic back-references. Same
382
- # narrowing model as numbered references.
359
+ # `$&` / `$'` / `$\`` / `$+` — symbolic back-references. Same narrowing model as numbered references.
383
360
  def type_of_back_reference(node)
384
361
  scope.global(node.name) || type_of_string_or_nil(node)
385
362
  end
386
363
 
387
- # `expr in pattern` — pattern-match predicate. Returns `true`
388
- # when the pattern matches, `false` otherwise.
364
+ # `expr in pattern` — pattern-match predicate. Returns `true` when the pattern matches, `false`
365
+ # otherwise.
389
366
  def type_of_match_predicate(_node)
390
367
  Type::Combinator.union(
391
368
  Type::Combinator.constant_of(true),
@@ -393,42 +370,54 @@ module Rigor
393
370
  )
394
371
  end
395
372
 
396
- # `expr => pattern` — one-line pattern-match assertion. Raises
397
- # `NoMatchingPatternError` on mismatch; on success the expression
398
- # itself evaluates to `nil`.
373
+ # `expr => pattern` — one-line pattern-match assertion. Raises `NoMatchingPatternError` on mismatch; on
374
+ # success the expression itself evaluates to `nil`.
399
375
  def type_of_match_required(_node)
400
376
  Type::Combinator.constant_of(nil)
401
377
  end
402
378
 
403
- # The expression `Foo` evaluates to the *class object* `Foo`, not
404
- # an instance. From Slice 4 phase 2b on we therefore type a
405
- # bare-constant reference as `Singleton[Foo]`; method dispatch on
406
- # that receiver looks up class methods (`Foo.new`, `Foo.bar`, ...).
379
+ # The expression `Foo` evaluates to the *class object* `Foo`, not an instance. From Slice 4 phase 2b on
380
+ # we therefore type a bare-constant reference as `Singleton[Foo]`; method dispatch on that receiver
381
+ # looks up class methods (`Foo.new`, `Foo.bar`, ...).
407
382
  #
408
- # Slice A constant-walk: when the literal name does not resolve,
409
- # we try a lexical walk based on the surrounding class context
410
- # exposed through `scope.self_type` so a reference like
411
- # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo`
412
- # resolves to `Rigor::Inference::FallbackTracer`.
383
+ # Slice A constant-walk: when the literal name does not resolve, we try a lexical walk based on the
384
+ # surrounding class context exposed through `scope.self_type` so a reference like
385
+ # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
386
+ # `Rigor::Inference::FallbackTracer`.
413
387
  def type_of_constant_read(node)
414
- resolve_constant_name(node.name.to_s) || fallback_for(node, family: :prism)
388
+ resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
415
389
  end
416
390
 
417
391
  def type_of_constant_path(node)
418
392
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
419
393
  return fallback_for(node, family: :prism) if full_name.nil?
420
394
 
421
- resolve_constant_name(full_name) || fallback_for(node, family: :prism)
395
+ resolve_constant_name(full_name) || unresolved_constant_fallback(node, full_name)
396
+ end
397
+
398
+ # ADR-82 WD9 — an unresolved constant whose root name a locked, RBS-less gem declares carries the
399
+ # `external_gem_without_rbs` cause instead of the generic `unsupported_syntax`. The constant read is
400
+ # where the class name is last visible (a no-RBS gem's receiver never types Nominal, so the dispatch
401
+ # tiers that record this cause under ADR-10 / `pre_eval:` opt-ins can't see it); WD6 chain inheritance
402
+ # then carries the cause through `Faraday.new.get(...)`. Side-channel only — the type stays the same
403
+ # `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
404
+ # cause: the fail-open direction is a missing label, never a wrong one.
405
+ def unresolved_constant_fallback(node, full_name)
406
+ root = full_name.delete_prefix("::").split("::").first
407
+ owner = root && scope.environment.missing_rbs_gem_owner(root)
408
+ return fallback_for(node, family: :prism) unless owner
409
+
410
+ inner = dynamic_top
411
+ record_fallback(node, family: :prism, inner_type: inner, origin: DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
412
+ scope.record_dynamic_origin(node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
413
+ inner
422
414
  end
423
415
 
424
- # Try the literal name first, then walk Ruby's lexical lookup by
425
- # progressively prefixing the surrounding class path (peeled
426
- # one `::segment` at a time). For each candidate the lookup
427
- # consults `Environment#singleton_for_name` (a class object)
428
- # and then `Environment#constant_for_name` (a non-class
429
- # constant value such as `BUCKETS: Array[Symbol]`).
430
- # Returns the matched `Rigor::Type` or nil; the caller decides
431
- # whether to fall back.
416
+ # Try the literal name first, then walk Ruby's lexical lookup by progressively prefixing the surrounding
417
+ # class path (peeled one `::segment` at a time). For each candidate the lookup consults
418
+ # `Environment#singleton_for_name` (a class object) and then `Environment#constant_for_name` (a
419
+ # non-class constant value such as `BUCKETS: Array[Symbol]`). Returns the matched `Rigor::Type` or nil;
420
+ # the caller decides whether to fall back.
432
421
  def resolve_constant_name(name)
433
422
  env = scope.environment
434
423
  discovered = scope.discovered_classes
@@ -440,9 +429,8 @@ module Rigor
440
429
  in_source_class = discovered[candidate]
441
430
  return in_source_class if in_source_class
442
431
 
443
- # In-source value-bearing constants take precedence
444
- # over RBS constant decls because user code is the
445
- # authoritative source for its own constants.
432
+ # In-source value-bearing constants take precedence over RBS constant decls because user code is
433
+ # the authoritative source for its own constants.
446
434
  in_source_value = in_source[candidate]
447
435
  return in_source_value if in_source_value
448
436
 
@@ -452,20 +440,17 @@ module Rigor
452
440
  nil
453
441
  end
454
442
 
455
- # The candidate qualified names to try, in Ruby's lexical
456
- # order: most-qualified first (the surrounding class path
457
- # joined to `name`), then progressively less-qualified, then
458
- # the bare `name`. Top-level scopes (no `self_type`) yield
459
- # only `[name]`, preserving the pre-walk behaviour.
443
+ # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the surrounding
444
+ # class path joined to `name`), then progressively less-qualified, then the bare `name`. Top-level
445
+ # scopes (no `self_type`) yield only `[name]`, preserving the pre-walk behaviour.
460
446
  def lexical_constant_candidates(name)
461
447
  prefix = enclosing_class_path
462
448
  candidates = []
463
449
  while prefix && !prefix.empty?
464
450
  candidates << "#{prefix}::#{name}"
465
- # Strip the last `::` segment without `rpartition`'s throwaway
466
- # 3-element array + extra substrings (this loop is the sole
467
- # caller of the `String#rpartition` allocation seen in the
468
- # profile): `rindex` + slice gives the same prefix, or nil.
451
+ # Strip the last `::` segment without `rpartition`'s throwaway 3-element array + extra substrings
452
+ # (this loop is the sole caller of the `String#rpartition` allocation seen in the profile): `rindex`
453
+ # + slice gives the same prefix, or nil.
469
454
  idx = prefix.rindex("::")
470
455
  prefix = idx ? prefix[0, idx] : nil
471
456
  end
@@ -473,10 +458,8 @@ module Rigor
473
458
  candidates
474
459
  end
475
460
 
476
- # Pulls the enclosing qualified class name out of
477
- # `scope.self_type` when one is set. `Nominal[T]` and
478
- # `Singleton[T]` both expose `class_name`. Returns nil when
479
- # no class context is available (top-level).
461
+ # Pulls the enclosing qualified class name out of `scope.self_type` when one is set. `Nominal[T]` and
462
+ # `Singleton[T]` both expose `class_name`. Returns nil when no class context is available (top-level).
480
463
  def enclosing_class_path
481
464
  st = scope.self_type
482
465
  case st
@@ -484,23 +467,16 @@ module Rigor
484
467
  end
485
468
  end
486
469
 
487
- # Slice 5 phase 1 upgrades hash literals to `HashShape{...}`
488
- # when every entry is a static `AssocNode` whose key is a
489
- # `SymbolNode` or `StringNode` with a known value (covering the
490
- # `{ a: 1, "b" => 2 }` pattern and falling back to the generic
491
- # `Hash[K, V]` form otherwise). Splatted entries
492
- # (`{ **other }`) and dynamic keys widen to the underlying
493
- # `Hash[K, V]` form by unioning the types each entry exposes;
494
- # when no concrete pair survives we fall back to the raw `Hash`
495
- # so callers stay backward compatible.
470
+ # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
471
+ # whose key is a `SymbolNode` or `StringNode` with a known value (covering the `{ a: 1, "b" => 2 }`
472
+ # pattern and falling back to the generic `Hash[K, V]` form otherwise). Splatted entries (`{ **other }`)
473
+ # and dynamic keys widen to the underlying `Hash[K, V]` form by unioning the types each entry exposes;
474
+ # when no concrete pair survives we fall back to the raw `Hash` so callers stay backward compatible.
496
475
  def type_of_hash(node)
497
476
  elements = node.respond_to?(:elements) ? node.elements : []
498
- # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier
499
- # rather than `Nominal[Hash]`, mirroring the v0.0.6 empty-
500
- # array literal change. Both forms erase to plain `Hash`,
501
- # but `HashShape{}` pins the literal's known size (zero)
502
- # so HashShape projections (`empty?`, `first`, `count`,
503
- # …) fold against it.
477
+ # v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
478
+ # v0.0.6 empty-array literal change. Both forms erase to plain `Hash`, but `HashShape{}` pins the
479
+ # literal's known size (zero) so HashShape projections (`empty?`, `first`, `count`, …) fold against it.
504
480
  return Type::Combinator.hash_shape_of({}) if elements.empty?
505
481
 
506
482
  shape = static_hash_shape_for(elements)
@@ -515,9 +491,8 @@ module Rigor
515
491
  )
516
492
  end
517
493
 
518
- # Builds `HashShape{...}` when every entry is an `AssocNode`
519
- # whose key is a static Symbol or String literal. Returns nil
520
- # otherwise so the caller falls back to the generic shape.
494
+ # Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a static Symbol or String
495
+ # literal. Returns nil otherwise so the caller falls back to the generic shape.
521
496
  def static_hash_shape_for(elements)
522
497
  pairs = {}
523
498
  elements.each do |entry|
@@ -534,10 +509,9 @@ module Rigor
534
509
  Type::Combinator.hash_shape_of(pairs)
535
510
  end
536
511
 
537
- # Returns the static (Symbol|String) literal carried by a hash
538
- # key node, or nil when the key is dynamic. We only treat
539
- # SymbolNode#value and StringNode#unescaped as static when they
540
- # are non-nil (interpolation produces a nil unescaped).
512
+ # Returns the static (Symbol|String) literal carried by a hash key node, or nil when the key is
513
+ # dynamic. We only treat SymbolNode#value and StringNode#unescaped as static when they are non-nil
514
+ # (interpolation produces a nil unescaped).
541
515
  def static_hash_key(node)
542
516
  case node
543
517
  when Prism::SymbolNode
@@ -560,13 +534,10 @@ module Rigor
560
534
  [keys, values]
561
535
  end
562
536
 
563
- # An interpolated string `"#{a}b#{c}"` is `literal-string`
564
- # when every part contributes literal-bearing material:
565
- # plain text segments are literal by construction, embedded
566
- # expressions count when their type is itself literal-string-
567
- # compatible (a `Constant<String>`, the `literal-string`
568
- # carrier, an `Intersection` containing it, or a `Union`
569
- # whose members all qualify). Otherwise the result widens to
537
+ # An interpolated string `"#{a}b#{c}"` is `literal-string` when every part contributes literal-bearing
538
+ # material: plain text segments are literal by construction, embedded expressions count when their type
539
+ # is itself literal-string-compatible (a `Constant<String>`, the `literal-string` carrier, an
540
+ # `Intersection` containing it, or a `Union` whose members all qualify). Otherwise the result widens to
570
541
  # plain `Nominal[String]` as before.
571
542
  def type_of_interpolated_string(node)
572
543
  return Type::Combinator.literal_string if interpolation_parts_literal?(node.parts)
@@ -601,11 +572,9 @@ module Rigor
601
572
  Type::Combinator.constant_of(node.name)
602
573
  end
603
574
 
604
- # `class Foo; body; end`, `module Foo; body; end`, and `class << x;
605
- # body; end` evaluate to the value of the body's last expression,
606
- # or `nil` when the body is empty. We do not track class/module
607
- # scope yet, so the body is typed in the surrounding scope and
608
- # that result is returned.
575
+ # `class Foo; body; end`, `module Foo; body; end`, and `class << x; body; end` evaluate to the value of
576
+ # the body's last expression, or `nil` when the body is empty. We do not track class/module scope yet,
577
+ # so the body is typed in the surrounding scope and that result is returned.
609
578
  def type_of_class_or_module(node)
610
579
  body = node.body
611
580
  return Type::Combinator.constant_of(nil) if body.nil?
@@ -613,35 +582,29 @@ module Rigor
613
582
  type_of(body)
614
583
  end
615
584
 
616
- # `alias x y`, `alias $x $y`, and `undef foo` all evaluate to nil at
617
- # runtime; the constant carrier captures that exactly.
585
+ # `alias x y`, `alias $x $y`, and `undef foo` all evaluate to nil at runtime; the constant carrier
586
+ # captures that exactly.
618
587
  def type_of_nil_value(_node)
619
588
  Type::Combinator.constant_of(nil)
620
589
  end
621
590
 
622
- # `if c; t; (elsif c2; ...; )* else; e; end`. Prism nests `elsif`
623
- # branches as `IfNode#subsequent`. Slice 3 phase 1 types both
624
- # branches in the receiver scope and returns their union; scope
625
- # rebinding is the StatementEvaluator's job (Slice 3 phase 2).
626
- # Without an else clause the branch's implicit value is nil, which
627
- # is included in the union.
591
+ # `if c; t; (elsif c2; ...; )* else; e; end`. Prism nests `elsif` branches as `IfNode#subsequent`. Slice
592
+ # 3 phase 1 types both branches in the receiver scope and returns their union; scope rebinding is the
593
+ # StatementEvaluator's job (Slice 3 phase 2). Without an else clause the branch's implicit value is nil,
594
+ # which is included in the union.
628
595
  #
629
- # v0.0.6 — when the predicate folds to a `Type::Constant` whose
630
- # value is Ruby-truthy (resp. Ruby-falsey), the unreachable
631
- # branch is elided so the if-expression's type is the live
632
- # branch alone. Statement-level branch elision lives in
633
- # `StatementEvaluator#eval_if`; this handler covers the
634
- # expression-position ternary form (`a ? b : c`) and any
635
- # `if`/`unless` reached through `type_of`.
596
+ # v0.0.6 — when the predicate folds to a `Type::Constant` whose value is Ruby-truthy (resp.
597
+ # Ruby-falsey), the unreachable branch is elided so the if-expression's type is the live branch alone.
598
+ # Statement-level branch elision lives in `StatementEvaluator#eval_if`; this handler covers the
599
+ # expression-position ternary form (`a ? b : c`) and any `if`/`unless` reached through `type_of`.
636
600
  def type_of_if(node)
637
601
  then_type = statements_or_nil(node.statements)
638
602
  else_type = if_else_type(node.subsequent)
639
603
  elide_or_union(node.predicate, then_type, else_type)
640
604
  end
641
605
 
642
- # `unless c; t; else; e; end`. Prism uses `else_clause` here (no
643
- # `elsif` chain). Branch-elision logic mirrors `type_of_if`,
644
- # inverted: a truthy predicate selects the else branch.
606
+ # `unless c; t; else; e; end`. Prism uses `else_clause` here (no `elsif` chain). Branch-elision logic
607
+ # mirrors `type_of_if`, inverted: a truthy predicate selects the else branch.
645
608
  def type_of_unless(node)
646
609
  then_type = statements_or_nil(node.statements)
647
610
  else_type = if_else_type(node.else_clause)
@@ -654,11 +617,9 @@ module Rigor
654
617
  type_of(subsequent)
655
618
  end
656
619
 
657
- # Routes the predicate's typed value through branch elision.
658
- # `live_when_truthy` and `live_when_falsey` are the branch
659
- # types selected by the predicate's polarity; the names
660
- # match `IfNode` semantics directly and invert at the
661
- # `type_of_unless` call site.
620
+ # Routes the predicate's typed value through branch elision. `live_when_truthy` and `live_when_falsey`
621
+ # are the branch types selected by the predicate's polarity; the names match `IfNode` semantics
622
+ # directly and invert at the `type_of_unless` call site.
662
623
  def elide_or_union(predicate, live_when_truthy, live_when_falsey)
663
624
  case constant_predicate_polarity(predicate)
664
625
  when :truthy then live_when_truthy
@@ -667,14 +628,10 @@ module Rigor
667
628
  end
668
629
  end
669
630
 
670
- # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary
671
- # predicate expression under three-valued logic.
672
- # {Narrowing.predicate_certainty} owns the judgment (the same
673
- # one `StatementEvaluator#live_branch_for_if` reads on the
674
- # scope side): `Nominal[Integer]` (always truthy in Ruby),
675
- # `Constant[nil]`, and `Constant[false]` fold one branch;
676
- # `Union[true, false]`, `Dynamic[T]`, and `Top` keep both
677
- # branches live.
631
+ # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary predicate expression under three-valued logic.
632
+ # {Narrowing.predicate_certainty} owns the judgment (the same one `StatementEvaluator#live_branch_for_if`
633
+ # reads on the scope side): `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`, and
634
+ # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
678
635
  def constant_predicate_polarity(predicate)
679
636
  return nil if predicate.nil?
680
637
 
@@ -685,27 +642,22 @@ module Rigor
685
642
  statements_or_nil(node.statements)
686
643
  end
687
644
 
688
- # `a && b` and `a || b` short-circuit at the value level:
689
- # `a && b` returns `a` when `a` is falsey, else `b`.
690
- # `a || b` returns `a` when `a` is truthy, else `b`.
645
+ # `a && b` and `a || b` short-circuit at the value level: `a && b` returns `a` when `a` is falsey, else
646
+ # `b`. `a || b` returns `a` when `a` is truthy, else `b`.
691
647
  #
692
- # v0.0.6 — when the left operand folds to a `Type::Constant`,
693
- # we know which side actually flows through, so the result
694
- # is one operand's type instead of a union. Otherwise the
695
- # union-of-both-operands fallback is preserved.
648
+ # v0.0.6 — when the left operand folds to a `Type::Constant`, we know which side actually flows
649
+ # through, so the result is one operand's type instead of a union. Otherwise the union-of-both-operands
650
+ # fallback is preserved.
696
651
  def type_of_and_or(node)
697
652
  left_type = type_of(node.left)
698
653
  polarity = constant_value_polarity(left_type)
699
654
  return short_circuit_for(node, left_type, polarity) if polarity
700
655
 
701
- # The left operand only flows through on the edge that short-
702
- # circuits: `a || b` yields `a` solely when `a` is truthy, so its
703
- # falsey constituents (`nil` / `false`) can never be the value of
704
- # the OrNode (they hand off to `b`); `a && b` yields `a` solely
705
- # when `a` is falsey. Narrow the surviving left edge before the
706
- # union so `s || full` (with `s : String?`) types `String |
707
- # <full>` rather than re-admitting the stripped `nil`. Mirrors
708
- # `StatementEvaluator#eval_and_or`'s `skipped_type`.
656
+ # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
657
+ # when `a` is truthy, so its falsey constituents (`nil` / `false`) can never be the value of the
658
+ # OrNode (they hand off to `b`); `a && b` yields `a` solely when `a` is falsey. Narrow the surviving
659
+ # left edge before the union so `s || full` (with `s : String?`) types `String | <full>` rather than
660
+ # re-admitting the stripped `nil`. Mirrors `StatementEvaluator#eval_and_or`'s `skipped_type`.
709
661
  surviving_left =
710
662
  if node.is_a?(Prism::AndNode)
711
663
  Narrowing.narrow_falsey(left_type)
@@ -724,37 +676,30 @@ module Rigor
724
676
  end
725
677
  end
726
678
 
727
- # Returns `:truthy` / `:falsey` for a `Type::Constant`,
728
- # nil otherwise. Mirrors `constant_predicate_polarity` but
729
- # operates on a typed value (already-type-of'd) rather
730
- # than a Prism node, so the same predicate analysis can
731
- # be reused in both contexts.
679
+ # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
680
+ # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
681
+ # node, so the same predicate analysis can be reused in both contexts.
732
682
  def constant_value_polarity(type)
733
683
  return nil unless type.is_a?(Type::Constant)
734
684
 
735
685
  type.value ? :truthy : :falsey
736
686
  end
737
687
 
738
- # Three-valued evaluation of `case predicate when pattern`
739
- # dispatch. For each `when` clause we ask: under static types,
740
- # does `pattern === predicate` definitely match (`:yes`),
741
- # definitely not match (`:no`), or possibly match (`:maybe`)?
742
- # Walking in source order:
688
+ # Three-valued evaluation of `case predicate when pattern` dispatch. For each `when` clause we ask:
689
+ # under static types, does `pattern === predicate` definitely match (`:yes`), definitely not match
690
+ # (`:no`), or possibly match (`:maybe`)? Walking in source order:
743
691
  #
744
- # - `:yes` — this branch fires, subsequent branches are
745
- # unreachable. Result = union(prior `:maybe` branches, this
746
- # `:yes` branch).
692
+ # - `:yes` — this branch fires, subsequent branches are unreachable. Result = union(prior `:maybe`
693
+ # branches, this `:yes` branch).
747
694
  # - `:no` — branch dropped.
748
695
  # - `:maybe` — branch is a candidate, continue.
749
696
  #
750
- # If no `:yes` was reached, the else clause (or `Constant[nil]`
751
- # when absent) is added to the candidate set.
697
+ # If no `:yes` was reached, the else clause (or `Constant[nil]` when absent) is added to the candidate
698
+ # set.
752
699
  #
753
- # The `case ... in` pattern-matching form (`CaseMatchNode`) and
754
- # the predicate-less form (`case; when c1; ...`) bypass the
755
- # `===` analysis: pattern matching has richer semantics, and a
756
- # predicate-less `case` reduces to a `if c1; ...; elsif c2`
757
- # chain that statement-level narrowing already handles.
700
+ # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
701
+ # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
702
+ # `case` reduces to a `if c1; ...; elsif c2` chain that statement-level narrowing already handles.
758
703
  def type_of_case(node)
759
704
  return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
760
705
 
@@ -789,10 +734,8 @@ module Rigor
789
734
  type_of(node.else_clause)
790
735
  end
791
736
 
792
- # Combines per-pattern certainty across a `when` clause's
793
- # conditions (`when a, b, c` `a === s || b === s || c === s`).
794
- # `:yes` if any pattern is `:yes`; `:no` if all are `:no`;
795
- # `:maybe` otherwise.
737
+ # Combines per-pattern certainty across a `when` clause's conditions (`when a, b, c` ≡ `a === s || b
738
+ # === s || c === s`). `:yes` if any pattern is `:yes`; `:no` if all are `:no`; `:maybe` otherwise.
796
739
  def case_when_branch_certainty(subject_type, when_node)
797
740
  return :maybe unless when_node.respond_to?(:conditions)
798
741
 
@@ -804,23 +747,17 @@ module Rigor
804
747
  :maybe
805
748
  end
806
749
 
807
- # Static three-valued certainty for `pattern === subject`.
808
- # Specialises two pattern shapes:
750
+ # Static three-valued certainty for `pattern === subject`. Specialises two pattern shapes:
809
751
  #
810
- # - **Class / Module reference** (`Integer`, `Foo::Bar`):
811
- # reduce to `subject.is_a?(class)` via
812
- # `Narrowing.narrow_class` / `narrow_not_class`. A Bot
813
- # truthy fragment means no inhabitant matches (`:no`); a
814
- # Bot falsey fragment means every inhabitant matches
815
- # (`:yes`).
816
- # - **Value-equality literal** (numeric / String / Symbol /
817
- # true / false / nil) against a `Constant[c]` subject:
818
- # the static comparison `pattern_value === c` is exact.
819
- # Other subject carriers stay `:maybe` because the
820
- # runtime value isn't pinned.
752
+ # - **Class / Module reference** (`Integer`, `Foo::Bar`): reduce to `subject.is_a?(class)` via
753
+ # `Narrowing.narrow_class` / `narrow_not_class`. A Bot truthy fragment means no inhabitant matches
754
+ # (`:no`); a Bot falsey fragment means every inhabitant matches (`:yes`).
755
+ # - **Value-equality literal** (numeric / String / Symbol / true / false / nil) against a
756
+ # `Constant[c]` subject: the static comparison `pattern_value === c` is exact. Other subject
757
+ # carriers stay `:maybe` because the runtime value isn't pinned.
821
758
  #
822
- # Other pattern shapes (Range, Regexp, custom `===`) stay
823
- # `:maybe` — the existing union fallback handles them.
759
+ # Other pattern shapes (Range, Regexp, custom `===`) stay `:maybe` — the existing union fallback
760
+ # handles them.
824
761
  def case_when_pattern_certainty(subject_type, pattern_node)
825
762
  class_name = Source::ConstantPath.qualified_name_or_nil(pattern_node)
826
763
  return Narrowing.class_pattern_certainty(subject_type, class_name, environment: scope.environment) if class_name
@@ -831,11 +768,9 @@ module Rigor
831
768
  :maybe
832
769
  end
833
770
 
834
- # Returns `{ value: v }` when `pattern_node` types to a
835
- # `Constant[v]` of a value-equality-safe class (so `===`
836
- # reduces to `==`), else nil. Wrapped in a hash so a literal
837
- # `nil` / `false` value doesn't collide with the "no literal"
838
- # signal.
771
+ # Returns `{ value: v }` when `pattern_node` types to a `Constant[v]` of a value-equality-safe class
772
+ # (so `===` reduces to `==`), else nil. Wrapped in a hash so a literal `nil` / `false` value doesn't
773
+ # collide with the "no literal" signal.
839
774
  def literal_pattern_value(pattern_node)
840
775
  type = type_of(pattern_node)
841
776
  return nil unless type.is_a?(Type::Constant)
@@ -844,19 +779,16 @@ module Rigor
844
779
  { value: type.value }
845
780
  end
846
781
 
847
- # `when` clauses for `case` and `in` clauses for `case ... in` have
848
- # the same body shape; we reuse one handler for both Prism node
849
- # classes.
782
+ # `when` clauses for `case` and `in` clauses for `case ... in` have the same body shape; we reuse one
783
+ # handler for both Prism node classes.
850
784
  def type_of_when_or_in(node)
851
785
  statements_or_nil(node.statements)
852
786
  end
853
787
 
854
- # `begin; body; rescue R => e; r1; rescue; r2; else; e; ensure; f; end`.
855
- # The result is the union of every value-producing branch: the body
856
- # (or the else-clause when present, since it replaces the body's
857
- # value when no exception fires), plus each rescue body in the
858
- # rescue chain. The ensure clause runs but does not contribute to
859
- # the begin's value.
788
+ # `begin; body; rescue R => e; r1; rescue; r2; else; e; ensure; f; end`. The result is the union of
789
+ # every value-producing branch: the body (or the else-clause when present, since it replaces the body's
790
+ # value when no exception fires), plus each rescue body in the rescue chain. The ensure clause runs but
791
+ # does not contribute to the begin's value.
860
792
  def type_of_begin(node)
861
793
  rescue_clause = node.rescue_clause
862
794
  else_clause = node.else_clause
@@ -888,10 +820,8 @@ module Rigor
888
820
  statements_or_nil(node.statements)
889
821
  end
890
822
 
891
- # `expr rescue fallback` is RescueModifierNode in Prism. The result
892
- # is `expr`'s type when no exception is raised and `fallback`'s
893
- # type otherwise; both paths are reachable, so the result is their
894
- # union.
823
+ # `expr rescue fallback` is RescueModifierNode in Prism. The result is `expr`'s type when no exception
824
+ # is raised and `fallback`'s type otherwise; both paths are reachable, so the result is their union.
895
825
  def type_of_rescue_modifier(node)
896
826
  Type::Combinator.union(type_of(node.expression), type_of(node.rescue_expression))
897
827
  end
@@ -900,20 +830,16 @@ module Rigor
900
830
  statements_or_nil(node.statements)
901
831
  end
902
832
 
903
- # `return`, `break`, `next`, `retry`, and `redo` all transfer
904
- # control instead of producing a value. Their type is Bot, the
905
- # empty type that absorbs cleanly under union (e.g.
906
- # `Constant[1] | Bot == Constant[1]`), so the surrounding
907
- # control-flow handlers collapse correctly when one branch jumps.
833
+ # `return`, `break`, `next`, `retry`, and `redo` all transfer control instead of producing a value.
834
+ # Their type is Bot, the empty type that absorbs cleanly under union (e.g. `Constant[1] | Bot ==
835
+ # Constant[1]`), so the surrounding control-flow handlers collapse correctly when one branch jumps.
908
836
  def type_of_jump(_node)
909
837
  Type::Combinator.bot
910
838
  end
911
839
 
912
- # `while` and `until` loops produce nil unless interrupted by
913
- # `break VALUE`; the expression value of `break VALUE` is not yet
914
- # modeled (scope break-path propagation landed in `eval_loop`).
915
- # Returning Constant[nil] is safe and matches Ruby semantics for
916
- # the common case.
840
+ # `while` and `until` loops produce nil unless interrupted by `break VALUE`; the expression value of
841
+ # `break VALUE` is not yet modeled (scope break-path propagation landed in `eval_loop`). Returning
842
+ # Constant[nil] is safe and matches Ruby semantics for the common case.
917
843
  def type_of_loop(_node)
918
844
  Type::Combinator.constant_of(nil)
919
845
  end
@@ -930,12 +856,10 @@ module Rigor
930
856
  nominal_range_for_endpoints(node.left, node.right)
931
857
  end
932
858
 
933
- # Derives `Nominal[Range, [T]]` from the endpoint expression
934
- # types when at least one endpoint is statically typeable. The
935
- # element parameter is the union of the endpoint types (lifted
936
- # from `Constant<v>` to `Nominal<v.class>` so the carrier matches
937
- # what `Range#each` would yield). Falls back to bare
938
- # `Nominal[Range]` when no endpoint contributes a typable shape.
859
+ # Derives `Nominal[Range, [T]]` from the endpoint expression types when at least one endpoint is
860
+ # statically typeable. The element parameter is the union of the endpoint types (lifted from
861
+ # `Constant<v>` to `Nominal<v.class>` so the carrier matches what `Range#each` would yield). Falls back
862
+ # to bare `Nominal[Range]` when no endpoint contributes a typable shape.
939
863
  def nominal_range_for_endpoints(left_node, right_node)
940
864
  endpoints = [left_node, right_node].compact.map { |n| range_endpoint_element_type(n) }
941
865
  endpoints.reject! { |t| t.equal?(Type::Combinator.untyped) }
@@ -959,12 +883,10 @@ module Rigor
959
883
  end
960
884
  end
961
885
 
962
- # v0.0.7 — non-interpolated regex literals lift to
963
- # `Constant<Regexp>` so `Constant<String>#scan(/regex/)`
964
- # / `#match(/regex/)` etc. can fold through the catalog
965
- # tier. Interpolated regexes (`/foo#{x}/`) reach the
966
- # second `Prism::InterpolatedRegularExpressionNode` arm
967
- # which keeps the conservative `Nominal[Regexp]` answer.
886
+ # v0.0.7 — non-interpolated regex literals lift to `Constant<Regexp>` so `Constant<String>#scan(/regex/)`
887
+ # / `#match(/regex/)` etc. can fold through the catalog tier. Interpolated regexes (`/foo#{x}/`) reach
888
+ # the second `Prism::InterpolatedRegularExpressionNode` arm which keeps the conservative
889
+ # `Nominal[Regexp]` answer.
968
890
  def type_of_regexp(node)
969
891
  return Type::Combinator.nominal_of(Regexp) unless node.is_a?(Prism::RegularExpressionNode)
970
892
 
@@ -974,15 +896,12 @@ module Rigor
974
896
  Type::Combinator.nominal_of(Regexp)
975
897
  end
976
898
 
977
- # A range endpoint folds to a static value when it is a literal
978
- # (`IntegerNode` / `StringNode`) or when its *evaluated* type is a
979
- # `Constant<v>` carrying a range-able value (Integer / Float /
980
- # String matching the literal-path value kinds). The evaluated
981
- # arm lets `(1..n)` fold to `Constant<Range>` when per-call body
982
- # inference has pinned `n` to a constant (fact2 chain). A `nil`
983
- # node is a beginless/endless boundary: keep today's static-nil
984
- # behaviour (which yields `Constant<Range>` only when the *other*
985
- # end is also static, preserving today's beginless/endless path).
899
+ # A range endpoint folds to a static value when it is a literal (`IntegerNode` / `StringNode`) or when
900
+ # its *evaluated* type is a `Constant<v>` carrying a range-able value (Integer / Float / String —
901
+ # matching the literal-path value kinds). The evaluated arm lets `(1..n)` fold to `Constant<Range>`
902
+ # when per-call body inference has pinned `n` to a constant (fact2 chain). A `nil` node is a
903
+ # beginless/endless boundary: keep today's static-nil behaviour (which yields `Constant<Range>` only
904
+ # when the *other* end is also static, preserving today's beginless/endless path).
986
905
  def static_range_endpoint(node)
987
906
  return [true, nil] if node.nil?
988
907
  return [true, node.value] if node.is_a?(Prism::IntegerNode)
@@ -997,10 +916,9 @@ module Rigor
997
916
  [false, nil]
998
917
  end
999
918
 
1000
- # Helper for the many control-flow handlers that read a body
1001
- # `Prism::StatementsNode` or treat its absence as nil. Note that
1002
- # Prism uses nil (rather than an empty `StatementsNode`) for
1003
- # missing bodies in many node kinds.
919
+ # Helper for the many control-flow handlers that read a body `Prism::StatementsNode` or treat its
920
+ # absence as nil. Note that Prism uses nil (rather than an empty `StatementsNode`) for missing bodies
921
+ # in many node kinds.
1004
922
  def statements_or_nil(statements_node)
1005
923
  return Type::Combinator.constant_of(nil) if statements_node.nil?
1006
924
 
@@ -1050,41 +968,34 @@ module Rigor
1050
968
  Type::Combinator.constant_of(unescaped)
1051
969
  end
1052
970
 
1053
- # Backtick (`cmd`) and `%x{cmd}` invoke Kernel#` and always return a
1054
- # String. Even when the content is statically known, we widen to
1055
- # Nominal[String] because the runtime value depends on the
1056
- # subprocess output, not the source text.
971
+ # Backtick (`cmd`) and `%x{cmd}` invoke Kernel#` and always return a String. Even when the content is
972
+ # statically known, we widen to Nominal[String] because the runtime value depends on the subprocess
973
+ # output, not the source text.
1057
974
  def type_of_xstring(_node)
1058
975
  Type::Combinator.nominal_of(String)
1059
976
  end
1060
977
 
1061
- # __FILE__ is the source file path. Always non-empty when
1062
- # parsing a real file (the path resolver gives the buffer
1063
- # name, which is at minimum `"(stdin)"` / `"-e"` / a real
1064
- # path — never the empty String). Widened to
1065
- # `non-empty-string` instead of `Nominal[String]` so
1066
- # downstream String-emptiness checks know the value cannot
1067
- # be `""`.
978
+ # __FILE__ is the source file path. Always non-empty when parsing a real file (the path resolver gives
979
+ # the buffer name, which is at minimum `"(stdin)"` / `"-e"` / a real path never the empty String).
980
+ # Widened to `non-empty-string` instead of `Nominal[String]` so downstream String-emptiness checks know
981
+ # the value cannot be `""`.
1068
982
  def type_of_source_file(_node)
1069
983
  Type::Combinator.non_empty_string
1070
984
  end
1071
985
 
1072
- # __LINE__ is the line of the source literal. Ruby line
1073
- # numbers are 1-indexed, so `__LINE__` is always at least
1074
- # 1 — `positive-int` (Integer in `[1, +Inf)`) is the
1075
- # canonical refinement.
986
+ # __LINE__ is the line of the source literal. Ruby line numbers are 1-indexed, so `__LINE__` is always
987
+ # at least 1 — `positive-int` (Integer in `[1, +Inf)`) is the canonical refinement.
1076
988
  def type_of_source_line(_node)
1077
989
  Type::Combinator.positive_int
1078
990
  end
1079
991
 
1080
- # `# shareable_constant_value:` magic comment wraps the next
1081
- # constant write. Type is the wrapped write's value.
992
+ # `# shareable_constant_value:` magic comment wraps the next constant write. Type is the wrapped
993
+ # write's value.
1082
994
  def type_of_shareable_constant(node)
1083
995
  type_of(node.write)
1084
996
  end
1085
997
 
1086
- # `{ x: }` shorthand hash. The implicit value is the call to
1087
- # `x` (or a local read of `x`). Delegate.
998
+ # `{ x: }` shorthand hash. The implicit value is the call to `x` (or a local read of `x`). Delegate.
1088
999
  def type_of_implicit(node)
1089
1000
  type_of(node.value)
1090
1001
  end
@@ -1093,25 +1004,20 @@ module Rigor
1093
1004
  scope.local(node.name) || dynamic_top
1094
1005
  end
1095
1006
 
1096
- # `it` (Ruby 3.4) — `ItLocalVariableReadNode` carries no `name`
1097
- # field; the implicit name is always `:it`, matching the binding
1098
- # `BlockParameterBinder` installs for `Prism::ItParametersNode`.
1007
+ # `it` (Ruby 3.4) — `ItLocalVariableReadNode` carries no `name` field; the implicit name is always
1008
+ # `:it`, matching the binding `BlockParameterBinder` installs for `Prism::ItParametersNode`.
1099
1009
  def it_read(_node)
1100
1010
  scope.local(:it) || dynamic_top
1101
1011
  end
1102
1012
 
1103
- # Slice 5 phase 1 upgrades array literals to `Tuple[T1..Tn]`
1104
- # when every element is a non-splat value. Splatted entries
1105
- # (`[*xs, 1]`) preserve the Slice 4 phase 2d behavior: we union
1106
- # the contributed element types and emit
1107
- # `Nominal[Array, [union]]`.
1013
+ # Slice 5 phase 1 upgrades array literals to `Tuple[T1..Tn]` when every element is a non-splat value.
1014
+ # Splatted entries (`[*xs, 1]`) preserve the Slice 4 phase 2d behavior: we union the contributed
1015
+ # element types and emit `Nominal[Array, [union]]`.
1108
1016
  #
1109
- # v0.0.6 — the empty literal `[]` resolves to the empty
1110
- # `Tuple[]` carrier rather than the raw `Nominal[Array]`.
1111
- # Both carriers erase to RBS `Array`, but `Tuple[]` pins
1112
- # the literal's known arity (zero), which lets the
1113
- # per-element block fold concatenate across all-empty
1114
- # positions like `[1, 2].flat_map { |_| [] }`.
1017
+ # v0.0.6 — the empty literal `[]` resolves to the empty `Tuple[]` carrier rather than the raw
1018
+ # `Nominal[Array]`. Both carriers erase to RBS `Array`, but `Tuple[]` pins the literal's known arity
1019
+ # (zero), which lets the per-element block fold concatenate across all-empty positions like `[1,
1020
+ # 2].flat_map { |_| [] }`.
1115
1021
  def array_type_for(node)
1116
1022
  elements = node.elements
1117
1023
  return Type::Combinator.tuple_of if elements.empty?
@@ -1141,26 +1047,20 @@ module Rigor
1141
1047
  type_of(body.last)
1142
1048
  end
1143
1049
 
1144
- # Indexed-collection narrowing — `receiver[key]` after a
1145
- # prior `receiver[key] ||= default` reads the post-`||=`
1146
- # type when the receiver and key are stable enough to
1147
- # address. Sits ahead of `MethodDispatcher.dispatch` so
1148
- # the standard `Hash#[]` / `Array#[]` answer (which would
1149
- # fold to `Constant[nil]` for an empty `HashShape{}` or
1150
- # `Tuple[]`) does not override the narrowing. See
1050
+ # Indexed-collection narrowing — `receiver[key]` after a prior `receiver[key] ||= default` reads the
1051
+ # post-`||=` type when the receiver and key are stable enough to address. Sits ahead of
1052
+ # `MethodDispatcher.dispatch` so the standard `Hash#[]` / `Array#[]` answer (which would fold to
1053
+ # `Constant[nil]` for an empty `HashShape{}` or `Tuple[]`) does not override the narrowing. See
1151
1054
  # {Inference::IndexedNarrowing}.
1152
1055
  def indexed_narrowing_for(node)
1153
1056
  IndexedNarrowing.lookup_for_call(node, scope) || method_chain_narrowing_for(node)
1154
1057
  end
1155
1058
 
1156
- # Stable single-hop chain narrowing — `receiver.method`
1157
- # after an `is_a?` / `kind_of?` / `instance_of?` predicate
1158
- # established the narrowing on the dominated edge. The
1159
- # call MUST be no-arg + no-block + rooted at a local-var /
1160
- # ivar read; everything else falls through to the
1161
- # standard dispatcher. ROADMAP § Future cycles —
1162
- # "Method-call receiver narrowing across stable
1163
- # receivers" — Law-of-Demeter-justified single-hop scope.
1059
+ # Stable single-hop chain narrowing — `receiver.method` after an `is_a?` / `kind_of?` / `instance_of?`
1060
+ # predicate established the narrowing on the dominated edge. The call MUST be no-arg + no-block +
1061
+ # rooted at a local-var / ivar read; everything else falls through to the standard dispatcher. ROADMAP
1062
+ # § Future cycles — "Method-call receiver narrowing across stable receivers" Law-of-Demeter-justified
1063
+ # single-hop scope.
1164
1064
  def method_chain_narrowing_for(node)
1165
1065
  return nil unless node.is_a?(Prism::CallNode)
1166
1066
  return nil unless node.block.nil?
@@ -1174,14 +1074,10 @@ module Rigor
1174
1074
  end
1175
1075
  end
1176
1076
 
1177
- # v0.0.3 A — implicit-self calls prefer a same-named
1178
- # top-level `def` over RBS dispatch. Without this,
1179
- # a helper like `def select(...)` defined inside an
1180
- # `RSpec.describe ... do ... end` block mis-routes
1181
- # through `Enumerable#select` / `Object#select` and
1182
- # the caller observes `Array[Elem]` instead of the
1183
- # helper's actual return type. The check fires only
1184
- # for `node.receiver.nil?` (true implicit self), so
1077
+ # v0.0.3 A — implicit-self calls prefer a same-named top-level `def` over RBS dispatch. Without this, a
1078
+ # helper like `def select(...)` defined inside an `RSpec.describe ... do ... end` block mis-routes
1079
+ # through `Enumerable#select` / `Object#select` and the caller observes `Array[Elem]` instead of the
1080
+ # helper's actual return type. The check fires only for `node.receiver.nil?` (true implicit self), so
1185
1081
  # explicit-receiver dispatch is unaffected.
1186
1082
  def try_local_def_dispatch(node, receiver, arg_types)
1187
1083
  local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
@@ -1190,24 +1086,19 @@ module Rigor
1190
1086
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1191
1087
  return local_inference if local_inference
1192
1088
 
1193
- # The local def matches by name but the inference was
1194
- # disqualified the parameter shape is too complex for the
1195
- # first-iteration binder (kwargs / optionals / rest), so the
1196
- # body could not be re-typed. `Dynamic[Top]` is the
1197
- # safest answer: RBS dispatch would be wrong (the method
1198
- # is user-defined and shadows whatever ancestor method the
1199
- # dispatch would find), and `Dynamic[Top]` propagates
1200
- # correctly through downstream call chains without
1201
- # surfacing misleading false-positive diagnostics.
1089
+ # The local def matches by name but the inference was disqualified — the parameter shape is too
1090
+ # complex for the first-iteration binder (kwargs / optionals / rest), so the body could not be
1091
+ # re-typed. `Dynamic[Top]` is the safest answer: RBS dispatch would be wrong (the method is
1092
+ # user-defined and shadows whatever ancestor method the dispatch would find), and `Dynamic[Top]`
1093
+ # propagates correctly through downstream call chains without surfacing misleading false-positive
1094
+ # diagnostics.
1202
1095
  dynamic_top
1203
1096
  end
1204
1097
 
1205
- # Slice 2 routes call expressions through `MethodDispatcher`. The
1206
- # receiver and every argument are typed first, then the dispatcher is
1207
- # asked for a result type. A nil result triggers the fail-soft fallback
1208
- # for the CallNode itself (the inner type_of calls already record
1209
- # their own fallbacks for unrecognised receivers/args, so the tracer
1210
- # captures both the immediate dispatch miss and the deeper cause).
1098
+ # Slice 2 routes call expressions through `MethodDispatcher`. The receiver and every argument are typed
1099
+ # first, then the dispatcher is asked for a result type. A nil result triggers the fail-soft fallback
1100
+ # for the CallNode itself (the inner type_of calls already record their own fallbacks for unrecognised
1101
+ # receivers/args, so the tracer captures both the immediate dispatch miss and the deeper cause).
1211
1102
  def call_type_for(node)
1212
1103
  narrowed = indexed_narrowing_for(node)
1213
1104
  return narrowed if narrowed
@@ -1219,15 +1110,11 @@ module Rigor
1219
1110
  local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1220
1111
  return local_def_result if local_def_result
1221
1112
 
1222
- # v0.0.6 phase 2 — per-element block fold for Tuple
1223
- # receivers. When `[a, b, c].map { |x| f(x) }` and the
1224
- # receiver is a `Tuple` carrier with finite elements,
1225
- # type the block body once per position with the
1226
- # corresponding element bound to the block parameter
1227
- # and assemble the results into a `Tuple[U_1..U_n]`.
1228
- # This sits ahead of `MethodDispatcher.dispatch` so
1229
- # the RBS tier does not re-widen the answer back to
1230
- # `Array[union]`.
1113
+ # v0.0.6 phase 2 — per-element block fold for Tuple receivers. When `[a, b, c].map { |x| f(x) }` and
1114
+ # the receiver is a `Tuple` carrier with finite elements, type the block body once per position with
1115
+ # the corresponding element bound to the block parameter and assemble the results into a
1116
+ # `Tuple[U_1..U_n]`. This sits ahead of `MethodDispatcher.dispatch` so the RBS tier does not re-widen
1117
+ # the answer back to `Array[union]`.
1231
1118
  per_element = try_per_element_block_fold(node, receiver)
1232
1119
  return per_element if per_element
1233
1120
 
@@ -1248,48 +1135,50 @@ module Rigor
1248
1135
  )
1249
1136
  return result if result
1250
1137
 
1251
- # v0.0.2 #5 — inter-procedural inference for
1252
- # user-defined methods. When dispatch misses but the
1253
- # receiver is a user class with a `def` body, re-type
1254
- # the body with the call's argument types bound and
1255
- # return the body's last-expression type.
1138
+ # v0.0.2 #5 — inter-procedural inference for user-defined methods. When dispatch misses but the
1139
+ # receiver is a user class with a `def` body, re-type the body with the call's argument types bound
1140
+ # and return the body's last-expression type.
1256
1141
  user_inference = try_user_method_inference(receiver, node, arg_types)
1257
1142
  return user_inference if user_inference
1258
1143
 
1259
- # Module-singleton call resolution (ADR-57 follow-up) — when the
1260
- # receiver is `Singleton[Foo]` (a module/class constant or a
1261
- # singleton-method `self`) and `Foo` declares a user-side
1262
- # `def self.x` / `module_function` body, re-type that body
1263
- # with the call args bound. Sits after the RBS dispatch tier, so
1264
- # foreign / RBS-known singletons (`Math.sqrt`) keep their catalog
1265
- # answer; only project-defined singleton methods reach here.
1144
+ # Module-singleton call resolution (ADR-57 follow-up) — when the receiver is `Singleton[Foo]` (a
1145
+ # module/class constant or a singleton-method `self`) and `Foo` declares a user-side `def self.x` /
1146
+ # `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
1147
+ # tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
1148
+ # project-defined singleton methods reach here.
1266
1149
  singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1267
1150
  return singleton_inference if singleton_inference
1268
1151
 
1269
- # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1270
- # no positive rule resolves the call, the result inherits the
1271
- # dynamic origin. Per the value-lattice algebra, this is a
1272
- # recognised semantic outcome, not a fail-soft compromise, so it
1273
- # MUST NOT record a tracer event.
1274
- return dynamic_top if receiver.is_a?(Type::Dynamic)
1275
-
1276
- # ADR-24 slice 4a this is the engine choke-point where an
1277
- # implicit-self call has exhausted every resolution tier (RBS
1278
- # dispatch + user-class ancestor walk) and falls through to
1279
- # `Dynamic[top]`. When the slice-4 recorder is active, capture the
1280
- # miss so a later slice's closed-class gate can flag it. Off by
1281
- # default: `active?` is a plain integer read.
1152
+ # Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
1153
+ # the result inherits the dynamic origin. Per the value-lattice algebra, this is a recognised
1154
+ # semantic outcome, not a fail-soft compromise, so it MUST NOT record a tracer event.
1155
+ return inherit_receiver_origin(node) if receiver.is_a?(Type::Dynamic)
1156
+
1157
+ # ADR-24 slice 4a — this is the engine choke-point where an implicit-self call has exhausted every
1158
+ # resolution tier (RBS dispatch + user-class ancestor walk) and falls through to `Dynamic[top]`. When
1159
+ # the slice-4 recorder is active, capture the miss so a later slice's closed-class gate can flag it.
1160
+ # Off by default: `active?` is a plain integer read.
1282
1161
  record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
1283
1162
 
1284
1163
  fallback_for(node, family: :prism)
1285
1164
  end
1286
1165
 
1287
- # ADR-24 slice 4a records an unresolved *implicit-self* call (no
1288
- # explicit receiver) whose `self` types to a concrete user class.
1289
- # Explicit-receiver misses are out of scope (the existing
1290
- # `call.undefined-method` rule already owns receiver-typed dispatch);
1291
- # a non-`Nominal` self (top-level / DSL-block `self`, or a `Dynamic`
1292
- # self) is skipped so the gradual guarantee is never touched here.
1166
+ # ADR-82 WD6carry the receiver's provenance onto the call it produces (returning the unchanged
1167
+ # `dynamic_top` result), so a specific cause survives a method chain (`x.foo.bar`): without this,
1168
+ # `.foo` on a Dynamic `x` records nothing and `.bar`'s receiver looks causeless. Side-channel only
1169
+ # (the result type is untouched); a nil cause is skipped. This is the lever for the
1170
+ # intermediate-expression / chain receivers that dominate a real app's `coverage --protection`
1171
+ # catch-all.
1172
+ def inherit_receiver_origin(node)
1173
+ inherited = OriginLookup.origin_for(scope, node.receiver)
1174
+ scope.record_dynamic_origin(node, inherited) if inherited
1175
+ dynamic_top
1176
+ end
1177
+
1178
+ # ADR-24 slice 4a — records an unresolved *implicit-self* call (no explicit receiver) whose `self`
1179
+ # types to a concrete user class. Explicit-receiver misses are out of scope (the existing
1180
+ # `call.undefined-method` rule already owns receiver-typed dispatch); a non-`Nominal` self (top-level /
1181
+ # DSL-block `self`, or a `Dynamic` self) is skipped so the gradual guarantee is never touched here.
1293
1182
  def record_unresolved_self_call(node, receiver)
1294
1183
  return unless node.receiver.nil?
1295
1184
  return unless receiver.is_a?(Type::Nominal)
@@ -1306,24 +1195,18 @@ module Rigor
1306
1195
  )
1307
1196
  end
1308
1197
 
1309
- # The recorder must capture *existence* misses, not type misses.
1310
- # Reaching the choke-point means RBS dispatch produced no result, but
1311
- # a project method can still EXIST without an inferable return type
1312
- # a `module_function` sibling whose body the engine can't fully type,
1313
- # an `attr_reader` / `define_method` / `Data.define` member. Recording
1314
- # those would reproduce the 135 false positives of slice-4 attempt 1.
1315
- # So skip any name the engine's own existence signals already know:
1316
- # a `def` resolvable through the ancestor walk, or an own-class entry
1317
- # in the discovered-methods table (`def` / `attr_*` / `define_method`
1318
- # / alias). This reuses the engine's real resolution the
1319
- # "collect, don't recompute" lesson so only a name that exists
1320
- # nowhere a project signal can see reaches the recorder.
1321
- # `module_function` records its defs as `:singleton` (an implicit-self
1322
- # call inside such a method dispatches to the module's singleton
1323
- # method), while ordinary instance methods record `:instance`. The
1324
- # recorder cannot tell the two contexts apart from the call node, so
1325
- # existence under EITHER kind suppresses recording — the FP-safe
1326
- # choice, since either means the method genuinely exists.
1198
+ # The recorder must capture *existence* misses, not type misses. Reaching the choke-point means RBS
1199
+ # dispatch produced no result, but a project method can still EXIST without an inferable return type —
1200
+ # a `module_function` sibling whose body the engine can't fully type, an `attr_reader` /
1201
+ # `define_method` / `Data.define` member. Recording those would reproduce the 135 false positives of
1202
+ # slice-4 attempt 1. So skip any name the engine's own existence signals already know: a `def`
1203
+ # resolvable through the ancestor walk, or an own-class entry in the discovered-methods table (`def` /
1204
+ # `attr_*` / `define_method` / alias). This reuses the engine's real resolution the "collect, don't
1205
+ # recompute" lesson — so only a name that exists nowhere a project signal can see reaches the recorder.
1206
+ # `module_function` records its defs as `:singleton` (an implicit-self call inside such a method
1207
+ # dispatches to the module's singleton method), while ordinary instance methods record `:instance`. The
1208
+ # recorder cannot tell the two contexts apart from the call node, so existence under EITHER kind
1209
+ # suppresses recording the FP-safe choice, since either means the method genuinely exists.
1327
1210
  def self_call_method_known?(class_name, method_name)
1328
1211
  return true if resolve_user_def_through_ancestors(class_name, method_name)
1329
1212
 
@@ -1331,94 +1214,68 @@ module Rigor
1331
1214
  scope.discovered_method?(class_name, method_name, :singleton)
1332
1215
  end
1333
1216
 
1334
- # v0.0.2 #5 — re-types the body of a user-defined
1335
- # instance method with the call site's argument types
1336
- # bound to the method's parameters. Used as a
1337
- # last-resort tier after `MethodDispatcher.dispatch`
1338
- # has exhausted its catalogue (RBS, shape, constant
1339
- # folding, user-class fallback). Returns nil when:
1217
+ # v0.0.2 #5 — re-types the body of a user-defined instance method with the call site's argument types
1218
+ # bound to the method's parameters. Used as a last-resort tier after `MethodDispatcher.dispatch` has
1219
+ # exhausted its catalogue (RBS, shape, constant folding, user-class fallback). Returns nil when:
1340
1220
  #
1341
1221
  # - the receiver is not `Nominal[T]` for some T;
1342
- # - no def_node is recorded for that class/method
1343
- # (the receiver is foreign or has only an RBS sig);
1344
- # - the def has no body, or has a parameter shape we
1345
- # cannot bind from the call's positional args;
1346
- # - the inference is already in progress for this
1347
- # (class, method, signature) tuple recursion
1348
- # safety net.
1349
- # v0.0.3 A re-types a top-level (or DSL-block-nested)
1350
- # `def` discovered by `ScopeIndexer` under the
1351
- # `TOP_LEVEL_DEF_KEY` sentinel. Mirrors the
1352
- # `infer_user_method_return` shape but uses the
1353
- # current `scope.self_type` (or implicit `Object`)
1354
- # as the receiver carrier so the body's own self is
1355
- # consistent with the call site's. Returns nil when
1356
- # the parameter shape disqualifies the def, when the
1357
- # body is empty, or when a recursion cycle is
1358
- # detected.
1222
+ # - no def_node is recorded for that class/method (the receiver is foreign or has only an RBS sig);
1223
+ # - the def has no body, or has a parameter shape we cannot bind from the call's positional args;
1224
+ # - the inference is already in progress for this (class, method, signature) tuple recursion safety
1225
+ # net.
1226
+ # v0.0.3 A — re-types a top-level (or DSL-block-nested) `def` discovered by `ScopeIndexer` under the
1227
+ # `TOP_LEVEL_DEF_KEY` sentinel. Mirrors the `infer_user_method_return` shape but uses the current
1228
+ # `scope.self_type` (or implicit `Object`) as the receiver carrier so the body's own self is
1229
+ # consistent with the call site's. Returns nil when the parameter shape disqualifies the def, when the
1230
+ # body is empty, or when a recursion cycle is detected.
1359
1231
  def infer_top_level_user_method(def_node, receiver, arg_types)
1360
1232
  infer_user_method_return(def_node, receiver, arg_types)
1361
1233
  rescue StandardError
1362
1234
  nil
1363
1235
  end
1364
1236
 
1365
- # ADR-24 slice 1 — implicit-self method-call resolution.
1366
- # `discovered_def_nodes` is now carried into method /
1367
- # class body scopes (see `StatementEvaluator#build_fresh_body_scope`),
1368
- # so a call written with no explicit receiver inside a
1369
- # method body resolves against the enclosing class's own
1370
- # definitions and the file's top-level defs. Before
1371
- # slice 1 every such call typed `Dynamic[top]`.
1237
+ # ADR-24 slice 1 — implicit-self method-call resolution. `discovered_def_nodes` is now carried into
1238
+ # method / class body scopes (see `StatementEvaluator#build_fresh_body_scope`), so a call written with
1239
+ # no explicit receiver inside a method body resolves against the enclosing class's own definitions and
1240
+ # the file's top-level defs. Before slice 1 every such call typed `Dynamic[top]`.
1372
1241
  #
1373
- # The resolved return type is adopted UNCONDITIONALLY — a resolved
1374
- # user-method call site reads the callee's inferred return, exactly as a
1375
- # toplevel call has since v0.0.3.
1242
+ # The resolved return type is adopted UNCONDITIONALLY — a resolved user-method call site reads the
1243
+ # callee's inferred return, exactly as a toplevel call has since v0.0.3.
1376
1244
  #
1377
- # ADR-24 WD3 originally gated this: inside a class / method body only a
1378
- # `Bot` return was adopted, everything else stayed `Dynamic[top]`, because
1379
- # an early unconditional-adoption experiment regressed `rigor check lib`
1380
- # by 16 diagnostics. ADR-55 / ADR-56 then chipped the gate open for the
1381
- # recursive-fixpoint summary and the value-pinned unroll envelope. ADR-57
1382
- # closed the arc: it re-ran the gate-open experiment per engine generation
1383
- # and adjudicated every firing as genuine-or-artifact, fixing the
1384
- # artifacts at their root the tail-only body evaluator dropping explicit
1385
- # `return` (slice 1), multi-value returns not contributing a Tuple
1386
- # (slice 1), escaping block-captured content mutation surviving as a
1387
- # precise seed both inline and across a method boundary (slices 2/3), two
1388
- # over-strict self-authored RBS signatures (slice 1), and an over-optional
1389
- # tuple-slot destructure (slice 3). With the residual all genuine-or-win,
1390
- # the gate opened permanently on 2026-06-12 (ADR-57 WD2): the gate-open
1391
- # `rigor check lib` + plugin self-check delta is zero, and the Mastodon /
1392
- # haml / kramdown corpora show only adjudicated wins (a more precise error
1393
- # message; FP removals).
1245
+ # ADR-24 WD3 originally gated this: inside a class / method body only a `Bot` return was adopted,
1246
+ # everything else stayed `Dynamic[top]`, because an early unconditional-adoption experiment regressed
1247
+ # `rigor check lib` by 16 diagnostics. ADR-55 / ADR-56 then chipped the gate open for the
1248
+ # recursive-fixpoint summary and the value-pinned unroll envelope. ADR-57 closed the arc: it re-ran the
1249
+ # gate-open experiment per engine generation and adjudicated every firing as genuine-or-artifact,
1250
+ # fixing the artifacts at their root — the tail-only body evaluator dropping explicit `return` (slice
1251
+ # 1), multi-value returns not contributing a Tuple (slice 1), escaping block-captured content mutation
1252
+ # surviving as a precise seed both inline and across a method boundary (slices 2/3), two over-strict
1253
+ # self-authored RBS signatures (slice 1), and an over-optional tuple-slot destructure (slice 3). With
1254
+ # the residual all genuine-or-win, the gate opened permanently on 2026-06-12 (ADR-57 WD2): the
1255
+ # gate-open `rigor check lib` + plugin self-check delta is zero, and the Mastodon / haml / kramdown
1256
+ # corpora show only adjudicated wins (a more precise error message; FP removals).
1394
1257
  #
1395
- # The historical `adoptable_self_call_result?` predicate (its
1396
- # `self_type.nil?` / `Bot` / fixpoint-summary / unroll special cases) is
1397
- # now subsumed by unconditional adoption and removed; `try_local_def_
1398
- # dispatch` / `try_user_method_inference` simply return the inferred
1399
- # return. `clamp_unroll_result` still backstops an untrustworthy unrolled
1400
- # value independently of adoption.
1401
-
1402
- # An extended (value-keyed) guard frame is `[plain_signature,
1403
- # value_key]` where `plain_signature` is itself the `[receiver,
1404
- # method]` pair; a plain frame is that pair directly.
1258
+ # The historical `adoptable_self_call_result?` predicate (its `self_type.nil?` / `Bot` /
1259
+ # fixpoint-summary / unroll special cases) is now subsumed by unconditional adoption and removed;
1260
+ # `try_local_def_dispatch` / `try_user_method_inference` simply return the inferred return.
1261
+ # `clamp_unroll_result` still backstops an untrustworthy unrolled value independently of adoption.
1262
+
1263
+ # An extended (value-keyed) guard frame is `[plain_signature, value_key]` where `plain_signature` is
1264
+ # itself the `[receiver, method]` pair; a plain frame is that pair directly.
1405
1265
  def extended_frame?(frame)
1406
1266
  frame.is_a?(Array) && frame.size == 2 && frame.first.is_a?(Array)
1407
1267
  end
1408
1268
 
1409
- # The plain `(receiver, method)` signature carried by a guard frame:
1410
- # the frame itself for a plain frame, or its first element for an
1411
- # extended (value-keyed) frame.
1269
+ # The plain `(receiver, method)` signature carried by a guard frame: the frame itself for a plain
1270
+ # frame, or its first element for an extended (value-keyed) frame.
1412
1271
  def plain_part(frame)
1413
1272
  extended_frame?(frame) ? frame.first : frame
1414
1273
  end
1415
1274
 
1416
- # True when `type` is a concrete value — a `Type::Constant` or a
1417
- # `Type::Tuple` whose elements are (recursively) all value-pinned.
1418
- # ADR-55 slice 1: a value-pinned self-call result is adopted even
1419
- # inside a class/method body (where WD3 otherwise keeps non-`Bot`
1420
- # returns as `Dynamic[top]`). A concrete value at a call site is
1421
- # strictly more precise and can never enable an undefined-method or
1275
+ # True when `type` is a concrete value — a `Type::Constant` or a `Type::Tuple` whose elements are
1276
+ # (recursively) all value-pinned. ADR-55 slice 1: a value-pinned self-call result is adopted even
1277
+ # inside a class/method body (where WD3 otherwise keeps non-`Bot` returns as `Dynamic[top]`). A
1278
+ # concrete value at a call site is strictly more precise and can never enable an undefined-method or
1422
1279
  # argument-type false positive — it is FP-neutral by construction.
1423
1280
  def fully_value_pinned?(type)
1424
1281
  case type
@@ -1442,20 +1299,16 @@ module Rigor
1442
1299
  nil
1443
1300
  end
1444
1301
 
1445
- # Module-singleton call resolution (ADR-57 follow-up) — resolves
1446
- # `Foo.<name>` on a `Singleton[Foo]` receiver against `Foo`'s
1447
- # user-side singleton defs (`def self.x`, `def Foo.x`, a
1448
- # `class << self` body, or a `module_function` method) and re-types
1449
- # the body with the call's argument types bound. The body scope's
1450
- # `self_type` is the SAME `Singleton[Foo]` carrier, so an
1451
- # implicit-self call inside (`def self.via; helper(x); end`)
1452
- # re-enters this tier and resolves against the same singleton table
1302
+ # Module-singleton call resolution (ADR-57 follow-up) — resolves `Foo.<name>` on a `Singleton[Foo]`
1303
+ # receiver against `Foo`'s user-side singleton defs (`def self.x`, `def Foo.x`, a `class << self`
1304
+ # body, or a `module_function` method) and re-types the body with the call's argument types bound.
1305
+ # The body scope's `self_type` is the SAME `Singleton[Foo]` carrier, so an implicit-self call inside
1306
+ # (`def self.via; helper(x); end`) re-enters this tier and resolves against the same singleton table
1453
1307
  # — the symmetric counterpart of the instance-side ancestor walk.
1454
1308
  #
1455
- # Resolution is OWN-class only: the singleton-ancestry chain
1456
- # (`extend`ed modules, inherited class-method dispatch) is not
1457
- # walked at this slice. A miss degrades to today's `Dynamic[top]`,
1458
- # never a false resolution (ADR-57 follow-up § module-singleton).
1309
+ # Resolution is OWN-class only: the singleton-ancestry chain (`extend`ed modules, inherited
1310
+ # class-method dispatch) is not walked at this slice. A miss degrades to today's `Dynamic[top]`, never
1311
+ # a false resolution (ADR-57 follow-up § module-singleton).
1459
1312
  def try_singleton_method_inference(receiver, call_node, arg_types)
1460
1313
  return nil unless receiver.is_a?(Type::Singleton)
1461
1314
 
@@ -1470,42 +1323,30 @@ module Rigor
1470
1323
  nil
1471
1324
  end
1472
1325
 
1473
- # ADR-24 slice 2 — resolves `method_name` against
1474
- # `class_name`'s own `def`s, then walks the user-class
1475
- # ancestor chain: included / prepended modules (transitive)
1476
- # and the superclass chain. RBS-known ancestors are NOT
1477
- # walked here the `MethodDispatcher` RBS tier runs before
1478
- # `try_user_method_inference` and already covers them; an
1479
- # ancestor name that resolves to no project-discovered
1480
- # class/module ends that branch. Cross-file: the chain is
1481
- # followed through `Scope#discovered_superclasses` /
1482
- # `#discovered_includes` / `#discovered_def_nodes`, which
1483
- # the runner seeds from the project-wide pre-pass. The walk
1484
- # is breadth-first, cycle-guarded, and node-count-capped.
1326
+ # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
1327
+ # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
1328
+ # ancestors are NOT walked here the `MethodDispatcher` RBS tier runs before
1329
+ # `try_user_method_inference` and already covers them; an ancestor name that resolves to no
1330
+ # project-discovered class/module ends that branch. Cross-file: the chain is followed through
1331
+ # `Scope#discovered_superclasses` / `#discovered_includes` / `#discovered_def_nodes`, which the runner
1332
+ # seeds from the project-wide pre-pass. The walk is breadth-first, cycle-guarded, and node-count-capped.
1485
1333
  ANCESTOR_WALK_LIMIT = 100
1486
1334
  private_constant :ANCESTOR_WALK_LIMIT
1487
1335
 
1488
1336
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
1489
1337
  private_constant :CLASS_GRAPH_CACHE_KEY
1490
1338
 
1491
- # Run-scoped memo for the static class-graph resolvers below. They
1492
- # are pure functions of the *frozen* project index trio
1493
- # (`discovered_def_nodes` / `discovered_superclasses` /
1494
- # `discovered_includes`)`user_def_for` / `superclass_of` /
1495
- # `includes_of` read nothing else, and never touch the current
1496
- # scope's locals or narrowings so a result computed for one
1497
- # `(class, method)` is valid for every `Scope` that shares those
1498
- # tables. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the
1499
- # memo lives on `Thread.current` rather than on `self`. It is keyed
1500
- # by the *identity* of the three frozen tables (nested
1501
- # `compare_by_identity` stores): a new analysis generation, or any
1502
- # `Scope` that swaps an index via `with_discovered_*`, transparently
1503
- # lands in a fresh bucket while everything sharing the tables shares
1504
- # the memo. Steady-state cost is three identity-keyed hash reads and
1505
- # zero allocation — the `||=` chains only allocate on the first miss
1506
- # of a generation. (Pool mode forks per worker, so the
1507
- # `Thread.current` store is process-local and never crosses a
1508
- # project boundary.)
1339
+ # Run-scoped memo for the static class-graph resolvers below. They are pure functions of the *frozen*
1340
+ # project index trio (`discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`)
1341
+ # `user_def_for` / `superclass_of` / `includes_of` read nothing else, and never touch the current
1342
+ # scope's locals or narrowings so a result computed for one `(class, method)` is valid for every
1343
+ # `Scope` that shares those tables. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the memo lives
1344
+ # on `Thread.current` rather than on `self`. It is keyed by the *identity* of the three frozen tables
1345
+ # (nested `compare_by_identity` stores): a new analysis generation, or any `Scope` that swaps an index
1346
+ # via `with_discovered_*`, transparently lands in a fresh bucket while everything sharing the tables
1347
+ # shares the memo. Steady-state cost is three identity-keyed hash reads and zero allocation — the `||=`
1348
+ # chains only allocate on the first miss of a generation. (Pool mode forks per worker, so the
1349
+ # `Thread.current` store is process-local and never crosses a project boundary.)
1509
1350
  def class_graph_buckets
1510
1351
  store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
1511
1352
  by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
@@ -1517,14 +1358,11 @@ module Rigor
1517
1358
  resolve_user_def_with_owner(class_name, method_name).first
1518
1359
  end
1519
1360
 
1520
- # ADR-57 N5 follow-up — resolves the method's def node AND the
1521
- # ancestor that owns it (the class/module whose own `def` table
1522
- # holds the body, which may differ from `class_name` when the
1523
- # method is inherited from a superclass or included module). The
1524
- # owner is what the overridable-method adoption gate keys on. Both
1525
- # are cached together (the walk is identical to the def-only path it
1526
- # replaced) and returned as a `[def_node, owner]` pair; `owner` is
1527
- # nil exactly when `def_node` is nil.
1361
+ # ADR-57 N5 follow-up — resolves the method's def node AND the ancestor that owns it (the class/module
1362
+ # whose own `def` table holds the body, which may differ from `class_name` when the method is
1363
+ # inherited from a superclass or included module). The owner is what the overridable-method adoption
1364
+ # gate keys on. Both are cached together (the walk is identical to the def-only path it replaced) and
1365
+ # returned as a `[def_node, owner]` pair; `owner` is nil exactly when `def_node` is nil.
1528
1366
  def resolve_user_def_with_owner(class_name, method_name)
1529
1367
  cache = class_graph_buckets[:user_def]
1530
1368
  table = (cache[class_name.to_s] ||= {})
@@ -1557,12 +1395,10 @@ module Rigor
1557
1395
  [nil, nil]
1558
1396
  end
1559
1397
 
1560
- # Pushes `current`'s direct ancestors onto the BFS queue:
1561
- # included / prepended modules first (Ruby places mixins
1562
- # nearer than the superclass), then the superclass. Each
1563
- # as-written name is resolved against `current`'s lexical
1564
- # nesting; names that resolve to no project class/module
1565
- # are dropped (RBS-known / third-party ancestors).
1398
+ # Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby
1399
+ # places mixins nearer than the superclass), then the superclass. Each as-written name is resolved
1400
+ # against `current`'s lexical nesting; names that resolve to no project class/module are dropped
1401
+ # (RBS-known / third-party ancestors).
1566
1402
  def enqueue_ancestors(current, queue)
1567
1403
  scope.includes_of(current).each do |raw|
1568
1404
  resolved = resolve_ancestor_class_name(current, raw)
@@ -1575,13 +1411,10 @@ module Rigor
1575
1411
  queue.push(resolved_super) if resolved_super
1576
1412
  end
1577
1413
 
1578
- # Resolves a superclass name AS WRITTEN (`"Base"`, or a
1579
- # qualified `"A::B"`) to a project-discovered class,
1580
- # following Ruby's `Module.nesting` constant lookup: try
1581
- # the raw name under each enclosing namespace of the
1582
- # subclass, innermost first, then bare. Returns nil when
1583
- # no candidate names a discovered user class (e.g. the
1584
- # superclass is an RBS-known or third-party class).
1414
+ # Resolves a superclass name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered
1415
+ # class, following Ruby's `Module.nesting` constant lookup: try the raw name under each enclosing
1416
+ # namespace of the subclass, innermost first, then bare. Returns nil when no candidate names a
1417
+ # discovered user class (e.g. the superclass is an RBS-known or third-party class).
1585
1418
  def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
1586
1419
  by_subclass = (class_graph_buckets[:name][subclass_qualified] ||= {})
1587
1420
  return by_subclass[raw_superclass] if by_subclass.key?(raw_superclass)
@@ -1605,29 +1438,22 @@ module Rigor
1605
1438
  scope.discovered_includes.key?(name)
1606
1439
  end
1607
1440
 
1608
- # ADR-57 N5 — overridable-method adoption gate. A self-call resolved
1609
- # to a project `def` whose owner has a discovered subclass / includer
1610
- # that REDEFINES the same method (same instance-vs-singleton kind) is
1611
- # a template-method site: the base body's literal return is the
1612
- # *default*, not the value every receiver sees, so adopting it as a
1613
- # flow constant is unsound (rgl `module Graph; def directed?; false`
1614
- # folds `unless directed?` always-true, ignoring `DirectedAdjacencyGraph`
1615
- # overriding it to `true` the entire rgl warning set, per the
1616
- # 2026-06-13 app/network survey N5 row). On such a hit the precise
1617
- # return degrades to `Dynamic[top]`, deliberately re-opening a Dynamic
1618
- # source ONLY for genuinely-overridden methods. A method with no
1619
- # discovered override folds exactly as before — over-conservatism must
1620
- # not re-open Dynamic for final methods.
1441
+ # ADR-57 N5 — overridable-method adoption gate. A self-call resolved to a project `def` whose owner has
1442
+ # a discovered subclass / includer that REDEFINES the same method (same instance-vs-singleton kind) is
1443
+ # a template-method site: the base body's literal return is the *default*, not the value every
1444
+ # receiver sees, so adopting it as a flow constant is unsound (rgl `module Graph; def directed?; false`
1445
+ # folds `unless directed?` always-true, ignoring `DirectedAdjacencyGraph` overriding it to `true` the
1446
+ # entire rgl warning set, per the 2026-06-13 app/network survey N5 row). On such a hit the precise
1447
+ # return degrades to `Dynamic[top]`, deliberately re-opening a Dynamic source ONLY for
1448
+ # genuinely-overridden methods. A method with no discovered override folds exactly as before —
1449
+ # over-conservatism must not re-open Dynamic for final methods.
1621
1450
  #
1622
- # The gate only inspects a *flow-constant-foldable* result (a
1623
- # `Constant`, or a `Tuple` of such): only a value-pinned return can
1624
- # mislead a downstream `if`/`unless`/`case` into an
1625
- # `always-truthy-condition` fold, which is exactly the unsoundness the
1626
- # gate exists to remove. A `Nominal` / `Dynamic` / union return cannot
1627
- # produce a flow constant, so adopting it from an overridden method is
1628
- # harmless and is left untouched — this keeps the override-relation
1629
- # walk off the hot path for the overwhelming majority of self-calls
1630
- # (whose return is not a bare constant).
1451
+ # The gate only inspects a *flow-constant-foldable* result (a `Constant`, or a `Tuple` of such): only a
1452
+ # value-pinned return can mislead a downstream `if`/`unless`/`case` into an `always-truthy-condition`
1453
+ # fold, which is exactly the unsoundness the gate exists to remove. A `Nominal` / `Dynamic` / union
1454
+ # return cannot produce a flow constant, so adopting it from an overridden method is harmless and is
1455
+ # left untouched this keeps the override-relation walk off the hot path for the overwhelming
1456
+ # majority of self-calls (whose return is not a bare constant).
1631
1457
  def degrade_if_overridable(result, owner, method_name, kind)
1632
1458
  return result if owner.nil?
1633
1459
  return result unless fully_value_pinned?(result)
@@ -1639,14 +1465,11 @@ module Rigor
1639
1465
  OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
1640
1466
  private_constant :OVERRIDE_GATE_CACHE_KEY
1641
1467
 
1642
- # Run-scoped memo for {#overridden_in_project?}, keyed (like
1643
- # `class_graph_buckets`) by the identity of the frozen discovery
1644
- # trio so a new analysis generation lands in a fresh bucket, then
1645
- # nested `kind owner method_name`. The predicate is a pure
1646
- # function of those tables. Nesting avoids allocating a composite
1647
- # cache key on the hot path (the gate runs on every adopted self-call
1648
- # return), so a steady-state hit is three identity hash reads + two
1649
- # string/symbol hash reads with zero allocation.
1468
+ # Run-scoped memo for {#overridden_in_project?}, keyed (like `class_graph_buckets`) by the identity of
1469
+ # the frozen discovery trio so a new analysis generation lands in a fresh bucket, then nested `kind →
1470
+ # owner method_name`. The predicate is a pure function of those tables. Nesting avoids allocating a
1471
+ # composite cache key on the hot path (the gate runs on every adopted self-call return), so a
1472
+ # steady-state hit is three identity hash reads + two string/symbol hash reads with zero allocation.
1650
1473
  def override_gate_buckets
1651
1474
  store = (Thread.current[OVERRIDE_GATE_CACHE_KEY] ||= {}.compare_by_identity)
1652
1475
  by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
@@ -1654,12 +1477,10 @@ module Rigor
1654
1477
  by_super[scope.discovered_includes] ||= { instance: {}, singleton: {} }
1655
1478
  end
1656
1479
 
1657
- # True when some discovered project class/module — distinct from
1658
- # `owner` — redefines `(method_name, kind)` AND is related to `owner`
1659
- # (a transitive discovered subclass of an owner class, or a
1660
- # class/module that includes/prepends extends, for singleton kind
1661
- # an owner module). A same-name reopen of `owner` itself is NOT an
1662
- # override (monkey-patch reopen shares the owner identity). Memoized
1480
+ # True when some discovered project class/module — distinct from `owner` — redefines `(method_name,
1481
+ # kind)` AND is related to `owner` (a transitive discovered subclass of an owner class, or a
1482
+ # class/module that includes/prepends extends, for singleton kind — an owner module). A same-name
1483
+ # reopen of `owner` itself is NOT an override (monkey-patch reopen shares the owner identity). Memoized
1663
1484
  # per `(owner, method_name, kind)`.
1664
1485
  def overridden_in_project?(owner, method_name, kind)
1665
1486
  by_owner = (override_gate_buckets[kind][owner] ||= {})
@@ -1676,16 +1497,13 @@ module Rigor
1676
1497
  end
1677
1498
  end
1678
1499
 
1679
- # Every discovered project class/module whose OWN def table redefines
1680
- # `(method_name, kind)`. Instance kind reads `discovered_def_nodes`,
1681
- # singleton kind reads `discovered_singleton_def_nodes` both are
1682
- # genuine project `def` bodies (not RBS / accessor synthesis), so a
1683
- # name's presence is a real redefinition. Served from a per-generation
1684
- # inverted index (`method_name [owner names]`) built once per def
1685
- # table, so the lookup is a single hash read rather than a full-table
1686
- # scan on every `(method_name, kind)` first-miss — the gate runs on
1687
- # every adopted self-call return, so the full-table `filter_map` it
1688
- # replaced was the dominant added allocation on a large `lib`.
1500
+ # Every discovered project class/module whose OWN def table redefines `(method_name, kind)`. Instance
1501
+ # kind reads `discovered_def_nodes`, singleton kind reads `discovered_singleton_def_nodes` — both are
1502
+ # genuine project `def` bodies (not RBS / accessor synthesis), so a name's presence is a real
1503
+ # redefinition. Served from a per-generation inverted index (`method_name [owner names]`) built once
1504
+ # per def table, so the lookup is a single hash read rather than a full-table scan on every
1505
+ # `(method_name, kind)` first-miss the gate runs on every adopted self-call return, so the full-table
1506
+ # `filter_map` it replaced was the dominant added allocation on a large `lib`.
1689
1507
  def redefiners_of(method_name, kind)
1690
1508
  method_definers_index(kind)[method_name] || EMPTY_REDEFINERS
1691
1509
  end
@@ -1696,11 +1514,10 @@ module Rigor
1696
1514
  METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
1697
1515
  private_constant :METHOD_DEFINERS_INDEX_KEY
1698
1516
 
1699
- # Per-generation `method_name (Symbol) → [owner names]` inverted index
1700
- # over the instance / singleton def tables, memoised by the identity
1701
- # of the def table it inverts (a new analysis generation lands in a
1702
- # fresh bucket). The toplevel sentinel is excluded — a toplevel `def`
1703
- # has no class ancestry and so can never be an override.
1517
+ # Per-generation `method_name (Symbol) → [owner names]` inverted index over the instance / singleton
1518
+ # def tables, memoised by the identity of the def table it inverts (a new analysis generation lands in
1519
+ # a fresh bucket). The toplevel sentinel is excluded — a toplevel `def` has no class ancestry and so
1520
+ # can never be an override.
1704
1521
  def method_definers_index(kind)
1705
1522
  table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
1706
1523
  store = (Thread.current[METHOD_DEFINERS_INDEX_KEY] ||= {}.compare_by_identity)
@@ -1717,12 +1534,10 @@ module Rigor
1717
1534
  index
1718
1535
  end
1719
1536
 
1720
- # True when `candidate`'s transitive ancestor chain (superclasses +
1721
- # included/prepended modules) reaches `owner` — i.e. `candidate` is a
1722
- # subclass of an owner class or an includer of an owner module. Reuses
1723
- # the same BFS resolver the method-resolution ancestor walk uses, so
1724
- # name resolution (lexical nesting, RBS-known-ancestor pruning) is
1725
- # identical.
1537
+ # True when `candidate`'s transitive ancestor chain (superclasses + included/prepended modules) reaches
1538
+ # `owner` — i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Reuses
1539
+ # the same BFS resolver the method-resolution ancestor walk uses, so name resolution (lexical nesting,
1540
+ # RBS-known-ancestor pruning) is identical.
1726
1541
  def related_to_owner?(candidate, owner)
1727
1542
  queue = []
1728
1543
  enqueue_ancestors(candidate, queue)
@@ -1749,109 +1564,83 @@ module Rigor
1749
1564
  INFERENCE_UNROLL_FUEL_KEY = :__rigor_user_method_unroll_fuel__
1750
1565
  private_constant :INFERENCE_UNROLL_FUEL_KEY
1751
1566
 
1752
- # ADR-55 slice 2 — thread-local fixpoint return-summary table,
1753
- # keyed by the plain `(receiver, method)` signature (NOT the
1754
- # value-extended signature: extended frames from slice 1 share the
1755
- # same summary). Each entry is `{ assumption:, consulted: }` where
1756
- # `assumption` is the current Kleene iterate (seeded `bot`) and
1757
- # `consulted` flips true when an in-cycle re-entry returns it.
1567
+ # ADR-55 slice 2 — thread-local fixpoint return-summary table, keyed by the plain `(receiver, method)`
1568
+ # signature (NOT the value-extended signature: extended frames from slice 1 share the same summary).
1569
+ # Each entry is `{ assumption:, consulted: }` where `assumption` is the current Kleene iterate (seeded
1570
+ # `bot`) and `consulted` flips true when an in-cycle re-entry returns it.
1758
1571
  INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
1759
1572
  private_constant :INFERENCE_SUMMARY_KEY
1760
1573
 
1761
- # Monotonic per-thread counter, bumped once each time `consult_summary`
1762
- # actually reads an in-flight fixpoint assumption (ADR-55 slice 2). A
1763
- # method return computed across an interval in which this counter does
1764
- # NOT move depended on no transient Kleene iterate, so it is FINAL and
1765
- # safe to memoise even when the `summaries` table is non-empty because
1766
- # some unrelated outermost frame merely *seeded* (but never consulted)
1767
- # its own entry. See `infer_user_method_return`'s post-hoc memo gate.
1574
+ # Monotonic per-thread counter, bumped once each time `consult_summary` actually reads an in-flight
1575
+ # fixpoint assumption (ADR-55 slice 2). A method return computed across an interval in which this
1576
+ # counter does NOT move depended on no transient Kleene iterate, so it is FINAL and safe to memoise —
1577
+ # even when the `summaries` table is non-empty because some unrelated outermost frame merely *seeded*
1578
+ # (but never consulted) its own entry. See `infer_user_method_return`'s post-hoc memo gate.
1768
1579
  SUMMARY_CONSULT_COUNTER_KEY = :__rigor_user_method_summary_consults__
1769
1580
  private_constant :SUMMARY_CONSULT_COUNTER_KEY
1770
1581
 
1771
- # Per-thread append-only log of the seed depths of every in-flight
1772
- # summary `consult_summary` read (ADR-55 slice 2 mutual-recursion
1773
- # soundness fix, 2026-06-12). Each fixpoint owner records the guard
1774
- # stack size at seed time on its entry (`depth:`); a consult appends
1775
- # the consulted entry's depth here. A fixpoint whose body evaluation
1776
- # logged a depth SHALLOWER than its own seed depth read an ancestor
1777
- # signature's transient Kleene iterate -- cross-signature mutual
1778
- # recursion (`even?`/`odd?`) -- so its computed return is entangled
1779
- # with a not-yet-converged foreign assumption and must degrade to
1780
- # `untyped` rather than fold one branch's seed into a "final"
1781
- # constant. Own-signature consults log depth == own depth, and a
1782
- # nested fixpoint that completes within the evaluation logs depths
1783
- # > own depth; neither is foreign. Cleared with the summary table
1784
- # when the guard stack drains to empty.
1582
+ # Per-thread append-only log of the seed depths of every in-flight summary `consult_summary` read
1583
+ # (ADR-55 slice 2 mutual-recursion soundness fix, 2026-06-12). Each fixpoint owner records the guard
1584
+ # stack size at seed time on its entry (`depth:`); a consult appends the consulted entry's depth here.
1585
+ # A fixpoint whose body evaluation logged a depth SHALLOWER than its own seed depth read an ancestor
1586
+ # signature's transient Kleene iterate -- cross-signature mutual recursion (`even?`/`odd?`) -- so its
1587
+ # computed return is entangled with a not-yet-converged foreign assumption and must degrade to
1588
+ # `untyped` rather than fold one branch's seed into a "final" constant. Own-signature consults log
1589
+ # depth == own depth, and a nested fixpoint that completes within the evaluation logs depths > own
1590
+ # depth; neither is foreign. Cleared with the summary table when the guard stack drains to empty.
1785
1591
  SUMMARY_CONSULT_DEPTHS_KEY = :__rigor_user_method_summary_consult_depths__
1786
1592
  private_constant :SUMMARY_CONSULT_DEPTHS_KEY
1787
1593
 
1788
- # ADR-57 follow-up — run-scoped memo for resolved user-method
1789
- # return types. The ADR-57 gate-open made every resolved in-body
1790
- # self-call adopt the callee's inferred return, which re-types the
1791
- # callee body once per call site. With a project-wide discovery
1792
- # index, file N re-types callees defined in files 1..N-1, so
1793
- # whole-`lib` cost grows superlinearly in files-per-process (the
1794
- # 2026-06-12 Rails survey's whole-`lib` scaling wall).
1594
+ # ADR-57 follow-up — run-scoped memo for resolved user-method return types. The ADR-57 gate-open made
1595
+ # every resolved in-body self-call adopt the callee's inferred return, which re-types the callee body
1596
+ # once per call site. With a project-wide discovery index, file N re-types callees defined in files
1597
+ # 1..N-1, so whole-`lib` cost grows superlinearly in files-per-process (the 2026-06-12 Rails survey's
1598
+ # whole-`lib` scaling wall).
1795
1599
  #
1796
- # `infer_user_method_return` is a pure function of
1797
- # `(def_node, receiver, arg_types)` PLUS the frozen project
1798
- # discovery index: `build_user_method_body_scope` binds the args
1799
- # to the params in a FRESH `Scope` seeded from an empty fact /
1800
- # narrowing store and inherits `scope.discovery` whole by
1801
- # reference the caller's narrowing state never enters. (This is
1802
- # what makes a signature-keyed return memo sound where the
1803
- # ADR-52 WD5 per-call-NODE contribution cache was not: that cache
1804
- # keyed scope-sensitive results on the node; this memo keys a
1805
- # scope-INSENSITIVE result on its real inputs.)
1600
+ # `infer_user_method_return` is a pure function of `(def_node, receiver, arg_types)` PLUS the frozen
1601
+ # project discovery index: `build_user_method_body_scope` binds the args to the params in a FRESH
1602
+ # `Scope` seeded from an empty fact / narrowing store and inherits `scope.discovery` whole by
1603
+ # reference the caller's narrowing state never enters. (This is what makes a signature-keyed return
1604
+ # memo sound where the ADR-52 WD5 per-call-NODE contribution cache was not: that cache keyed
1605
+ # scope-sensitive results on the node; this memo keys a scope-INSENSITIVE result on its real inputs.)
1806
1606
  #
1807
- # Two dimensions are call-site-varying and so live IN the key:
1808
- # the receiver carrier (`describe(:short)`) and the argument-type
1809
- # signature (`describe(:short)` of each arg) value-pinned args
1810
- # change folds (`factorial(5)` vs `factorial(6)`), so a coarser
1811
- # key would serve a stale fold. The third unsafe dimension
1812
- # the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1813
- # assumption / WD1 clamp) producing a TRANSIENT result rather
1814
- # than a final return is excluded structurally: the memo is
1815
- # consulted and populated ONLY when the incoming guard stack is
1816
- # empty (a genuine top-of-stack entry, whose result is final and
1817
- # cannot be an in-progress assumption or a clamped value). Frames
1818
- # entered with a non-empty stack bypass the memo entirely and
1819
- # compute as before.
1607
+ # Two dimensions are call-site-varying and so live IN the key: the receiver carrier
1608
+ # (`describe(:short)`) and the argument-type signature (`describe(:short)` of each arg) — value-pinned
1609
+ # args change folds (`factorial(5)` vs `factorial(6)`), so a coarser key would serve a stale fold. The
1610
+ # third unsafe dimension the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1611
+ # assumption / WD1 clamp) producing a TRANSIENT result rather than a final return is excluded
1612
+ # structurally: the memo is consulted and populated ONLY when the incoming guard stack is empty (a
1613
+ # genuine top-of-stack entry, whose result is final and cannot be an in-progress assumption or a
1614
+ # clamped value). Frames entered with a non-empty stack bypass the memo entirely and compute as before.
1820
1615
  #
1821
- # Keyed by the identity of the frozen discovery `def_nodes`
1822
- # table (a new analysis generation lands in a fresh bucket,
1823
- # mirroring `class_graph_buckets`) then by the identity of the
1824
- # `def_node` and the `[receiver, *args]` descriptor tuple.
1825
- # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1826
- # lives on `Thread.current`; fork-pool workers are separate
1827
- # processes, so it never crosses a project boundary.
1616
+ # Keyed by the identity of the frozen discovery `def_nodes` table (a new analysis generation lands in
1617
+ # a fresh bucket, mirroring `class_graph_buckets`) then by the identity of the `def_node` and the
1618
+ # `[receiver, *args]` descriptor tuple. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1619
+ # lives on `Thread.current`; fork-pool workers are separate processes, so it never crosses a project
1620
+ # boundary.
1828
1621
  RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
1829
1622
  private_constant :RETURN_MEMO_KEY
1830
1623
 
1831
- # Per-inference recursion context threaded through the guard /
1832
- # fixpoint helpers (ADR-55 slice 2). Bundles the call descriptor
1833
- # (`receiver`, `arg_types`, `plain_signature`), the thread-local
1834
- # summary table, and the WD1 clamp flag so the helpers stay within the
1835
- # parameter-list budget. `def_node` is carried separately (it is the
1836
- # body owner, not call context).
1624
+ # Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
1625
+ # Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
1626
+ # table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
1627
+ # carried separately (it is the body owner, not call context).
1837
1628
  RecursionContext = Data.define(
1838
1629
  :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded
1839
1630
  )
1840
1631
  private_constant :RecursionContext
1841
1632
 
1842
- # Total body evaluations the fixpoint iteration is permitted per
1843
- # outermost entry for a signature (ADR-55 WD2). Hard, non-configurable
1844
- # — the iteration cap is part of the termination story (ADR-41 WD4).
1633
+ # Total body evaluations the fixpoint iteration is permitted per outermost entry for a signature
1634
+ # (ADR-55 WD2). Hard, non-configurable — the iteration cap is part of the termination story (ADR-41
1635
+ # WD4).
1845
1636
  RECURSION_FIXPOINT_CAP = 3
1846
1637
  private_constant :RECURSION_FIXPOINT_CAP
1847
1638
 
1848
- # Hard, non-configurable caps for the ADR-55 slice 1 constant-arg
1849
- # unroll. `RECURSION_UNROLL_FUEL` bounds the number of extended
1850
- # (value-keyed) frames per outermost inference entry;
1851
- # `RECURSION_VALUE_SIZE_CAP` disqualifies a frame whose pinned
1852
- # argument values are structurally large. Both are termination
1853
- # guards (ADR-41 WD4) — not measurement-gated precision budgets —
1854
- # so they ship default-on with no opt-in.
1639
+ # Hard, non-configurable caps for the ADR-55 slice 1 constant-arg unroll. `RECURSION_UNROLL_FUEL`
1640
+ # bounds the number of extended (value-keyed) frames per outermost inference entry;
1641
+ # `RECURSION_VALUE_SIZE_CAP` disqualifies a frame whose pinned argument values are structurally large.
1642
+ # Both are termination guards (ADR-41 WD4) — not measurement-gated precision budgets — so they ship
1643
+ # default-on with no opt-in.
1855
1644
  RECURSION_UNROLL_FUEL = 32
1856
1645
  private_constant :RECURSION_UNROLL_FUEL
1857
1646
 
@@ -1864,44 +1653,31 @@ module Rigor
1864
1653
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
1865
1654
  return nil if body_scope.nil?
1866
1655
 
1867
- # Recursion-guard signature. Keyed on `(receiver,
1868
- # method)` only NOT the argument types. ADR-24 WD5:
1869
- # a method whose summary is still being computed
1870
- # resolves to `Dynamic[top]` for that cycle. Keying on
1871
- # arg types would let mutual recursion through a
1872
- # `module_function` module (`Acceptance#accepts`
1873
- # `accepts_one` `accepts_dynamic` `accepts`)
1874
- # recurse unboundedly whenever the carried argument
1875
- # types differ at each level — observed as a
1876
- # `SystemStackError` once implicit-self calls began
1877
- # resolving during the main walk. `describe(:short)`
1878
- # keeps non-Nominal receivers (the implicit `Object`
1879
- # carrier for top-level / DSL-block defs) printable.
1656
+ # Recursion-guard signature. Keyed on `(receiver, method)` only — NOT the argument types. ADR-24 WD5:
1657
+ # a method whose summary is still being computed resolves to `Dynamic[top]` for that cycle. Keying on
1658
+ # arg types would let mutual recursion through a `module_function` module (`Acceptance#accepts` →
1659
+ # `accepts_one` `accepts_dynamic` `accepts`) recurse unboundedly whenever the carried argument
1660
+ # types differ at each level observed as a `SystemStackError` once implicit-self calls began
1661
+ # resolving during the main walk. `describe(:short)` keeps non-Nominal receivers (the implicit
1662
+ # `Object` carrier for top-level / DSL-block defs) printable.
1880
1663
  plain_signature = [receiver.describe(:short), def_node.name]
1881
1664
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1882
1665
  summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
1883
1666
 
1884
- # ADR-57 follow-up — return memo. The inferred return is a pure
1885
- # function of `(def_node, receiver, arg_types)` and the frozen
1886
- # discovery index whenever the computation does NOT depend on a
1887
- # transient ADR-55 Kleene assumption (an in-flight fixpoint summary).
1888
- # Two structural preconditions decide whether THIS frame's result is
1889
- # even a memo candidate, both stable across the body walk: the
1890
- # signature must not already be on the recursion guard stack (else we
1891
- # are inside its own cycle) and no constant-arg unroll may be in
1892
- # flight (its value-keyed frames are transient). When both hold we
1893
- # consult the memo, and on a miss we compute, then store the result
1894
- # only if no fixpoint summary was *consulted* during the computation
1895
- # (the post-hoc consult-counter check) which is sound regardless of
1896
- # whether the `summaries` table holds inert *seeded-but-unconsulted*
1897
- # entries left by unrelated outermost frames. This is the fix for the
1898
- # whole-`lib` scaling wall: a deep DAG of non-recursive private
1899
- # readers (ActiveStorage `video_analyzer.rb`) seeded a summary on its
1900
- # first outermost method and thereafter the old `summaries.empty?`
1901
- # gate disabled the memo for every nested call, re-walking the shared
1902
- # sub-readers combinatorially (~932k body evaluations for ~20 tiny
1903
- # methods). The computation itself lives in
1904
- # `compute_user_method_return`.
1667
+ # ADR-57 follow-up — return memo. The inferred return is a pure function of `(def_node, receiver,
1668
+ # arg_types)` and the frozen discovery index whenever the computation does NOT depend on a transient
1669
+ # ADR-55 Kleene assumption (an in-flight fixpoint summary). Two structural preconditions decide
1670
+ # whether THIS frame's result is even a memo candidate, both stable across the body walk: the
1671
+ # signature must not already be on the recursion guard stack (else we are inside its own cycle) and
1672
+ # no constant-arg unroll may be in flight (its value-keyed frames are transient). When both hold we
1673
+ # consult the memo, and on a miss we compute, then store the result only if no fixpoint summary was
1674
+ # *consulted* during the computation (the post-hoc consult-counter check) which is sound
1675
+ # regardless of whether the `summaries` table holds inert *seeded-but-unconsulted* entries left by
1676
+ # unrelated outermost frames. This is the fix for the whole-`lib` scaling wall: a deep DAG of
1677
+ # non-recursive private readers (ActiveStorage `video_analyzer.rb`) seeded a summary on its first
1678
+ # outermost method and thereafter the old `summaries.empty?` gate disabled the memo for every nested
1679
+ # call, re-walking the shared sub-readers combinatorially (~932k body evaluations for ~20 tiny
1680
+ # methods). The computation itself lives in `compute_user_method_return`.
1905
1681
  unless memo_candidate?(stack, plain_signature)
1906
1682
  return compute_user_method_return(def_node, body_scope, stack, summaries,
1907
1683
  receiver, arg_types, plain_signature)
@@ -1917,28 +1693,23 @@ module Rigor
1917
1693
  receiver, arg_types, plain_signature)
1918
1694
  consults_after = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1919
1695
 
1920
- # Store only a FINAL result. If a fixpoint summary was consulted
1921
- # during the computation, `result` embeds a transient Kleene iterate
1922
- # whose value depends on the iteration in flight, so it must not be
1923
- # shared across call sites.
1696
+ # Store only a FINAL result. If a fixpoint summary was consulted during the computation, `result`
1697
+ # embeds a transient Kleene iterate whose value depends on the iteration in flight, so it must not
1698
+ # be shared across call sites.
1924
1699
  memo[memo_key] = result if consults_after == consults_before
1925
1700
  result
1926
1701
  end
1927
1702
 
1928
- # The ADR-55 recursion-guard + value-unroll + fixpoint body of
1929
- # user-method return inference, factored out so
1930
- # `infer_user_method_return` is a thin memo wrapper (the memo is
1931
- # the ADR-57 follow-up; this is unchanged from pre-memo behaviour).
1703
+ # The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
1704
+ # out so `infer_user_method_return` is a thin memo wrapper (the memo is the ADR-57 follow-up; this is
1705
+ # unchanged from pre-memo behaviour).
1932
1706
  def compute_user_method_return(def_node, body_scope, stack, summaries,
1933
1707
  receiver, arg_types, plain_signature)
1934
- # ADR-55 slice 1: when every bound argument is value-pinned,
1935
- # extend the guard key with a stable descriptor of the argument
1936
- # *values* so distinct constant frames may recurse (e.g.
1937
- # `factorial(5)` folds to `Constant[120]`). Distinct constant
1938
- # frames are bounded by `RECURSION_UNROLL_FUEL` per outermost
1939
- # entry; exhaustion or value blow-up falls back to the plain
1940
- # `(receiver, method)` guard — today's behaviour. Non-constant
1941
- # args never reach this path.
1708
+ # ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
1709
+ # descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
1710
+ # folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
1711
+ # outermost entry; exhaustion or value blow-up falls back to the plain `(receiver, method)` guard
1712
+ # today's behaviour. Non-constant args never reach this path.
1942
1713
  signature = plain_signature
1943
1714
  value_key = constant_argument_value_key(arg_types)
1944
1715
  extended = value_key && unroll_fuel_remaining(stack).positive?
@@ -1946,25 +1717,20 @@ module Rigor
1946
1717
 
1947
1718
  if stack.include?(signature)
1948
1719
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
1949
- # ADR-55 slice 2: in-cycle re-entries return the current assumed
1950
- # summary (Kleene iterate, seeded `bot`) instead of bare
1951
- # `untyped`. The fixpoint loop below seeds the entry on the
1952
- # outermost frame; if a re-entry beats it here the entry already
1953
- # exists. The WD4 composition: slice 1's clamp/fuel fallbacks
1954
- # also route here when a summary is active.
1720
+ # ADR-55 slice 2: in-cycle re-entries return the current assumed summary (Kleene iterate, seeded
1721
+ # `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
1722
+ # if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
1723
+ # fallbacks also route here when a summary is active.
1955
1724
  return consult_summary(summaries, plain_signature)
1956
1725
  end
1957
1726
 
1958
- # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may
1959
- # only ever surface a fully value-pinned result; any other outcome
1960
- # must be byte-identical to the plain guard's `untyped`. A frame
1961
- # that took the extended (value-keyed) path but whose plain
1962
- # `(receiver, method)` signature is already on the stack in
1963
- # plain form or as the plain part of an extended frame would
1964
- # have been guarded before slice 1. If such a frame's body folds
1965
- # to a non-pinned type, the unroll surfaced a precise value the
1966
- # plain guard would have masked (and the body evaluator's blind
1967
- # spots can make that value wrong), so clamp it back to `untyped`.
1727
+ # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may only ever surface a fully
1728
+ # value-pinned result; any other outcome must be byte-identical to the plain guard's `untyped`. A
1729
+ # frame that took the extended (value-keyed) path but whose plain `(receiver, method)` signature is
1730
+ # already on the stack — in plain form or as the plain part of an extended frame would have been
1731
+ # guarded before slice 1. If such a frame's body folds to a non-pinned type, the unroll surfaced a
1732
+ # precise value the plain guard would have masked (and the body evaluator's blind spots can make
1733
+ # that value wrong), so clamp it back to `untyped`.
1968
1734
  would_have_been_guarded =
1969
1735
  extended &&
1970
1736
  stack.any? { |frame| plain_part(frame) == plain_signature }
@@ -1976,46 +1742,39 @@ module Rigor
1976
1742
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1977
1743
  end
1978
1744
 
1979
- # True when this frame's result is a candidate for the return memo:
1980
- # the structural preconditions, both stable across the body walk, that
1981
- # are necessary (but not sufficient) for a FINAL result. Sufficiency is
1982
- # decided post-hoc in `infer_user_method_return` by the consult-counter
1983
- # check (no transient fixpoint summary was read during the compute)
1984
- # so unlike the prior `memoisable_return?` this deliberately does NOT
1985
- # require an empty `summaries` table: inert seeded-but-unconsulted
1986
- # entries left by unrelated outermost frames do not contaminate a
1987
- # result, and gating on them disabled the memo for an entire non-
1988
- # recursive DAG (the scaling wall). The two preconditions: no constant-
1989
- # arg unroll in flight (its value-keyed frames are transient) and this
1990
- # plain signature not itself on the recursion guard stack (else we are
1991
- # inside its own cycle, returning a Kleene iterate).
1745
+ # True when this frame's result is a candidate for the return memo: the structural preconditions, both
1746
+ # stable across the body walk, that are necessary (but not sufficient) for a FINAL result. Sufficiency
1747
+ # is decided post-hoc in `infer_user_method_return` by the consult-counter check (no transient
1748
+ # fixpoint summary was read during the compute) — so unlike the prior `memoisable_return?` this
1749
+ # deliberately does NOT require an empty `summaries` table: inert seeded-but-unconsulted entries left
1750
+ # by unrelated outermost frames do not contaminate a result, and gating on them disabled the memo for
1751
+ # an entire non-recursive DAG (the scaling wall). The two preconditions: no constant-arg unroll in
1752
+ # flight (its value-keyed frames are transient) and this plain signature not itself on the recursion
1753
+ # guard stack (else we are inside its own cycle, returning a Kleene iterate).
1992
1754
  def memo_candidate?(stack, plain_signature)
1993
- # Read the unroll fuel WITHOUT the decrement side effect of
1994
- # `unroll_fuel_remaining`: a constant-arg unroll has begun iff the
1995
- # thread-local is set and the stack is non-empty (the `ensure` in
1755
+ # Read the unroll fuel WITHOUT the decrement side effect of `unroll_fuel_remaining`: a constant-arg
1756
+ # unroll has begun iff the thread-local is set and the stack is non-empty (the `ensure` in
1996
1757
  # `evaluate_guarded_user_method_body` clears it at stack-empty).
1997
1758
  unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
1998
1759
  unroll_idle &&
1999
1760
  stack.none? { |frame| plain_part(frame) == plain_signature }
2000
1761
  end
2001
1762
 
2002
- # Run-scoped return-memo bucket for the current discovery
2003
- # generation. Keyed by the identity of the frozen `def_nodes`
2004
- # table so a new analysis generation (or any scope that swaps the
2005
- # index) transparently lands in a fresh bucket. See RETURN_MEMO_KEY.
1763
+ # Run-scoped return-memo bucket for the current discovery generation. Keyed by the identity of the
1764
+ # frozen `def_nodes` table so a new analysis generation (or any scope that swaps the index)
1765
+ # transparently lands in a fresh bucket. See RETURN_MEMO_KEY.
2006
1766
  def return_memo_bucket
2007
1767
  store = (Thread.current[RETURN_MEMO_KEY] ||= {}.compare_by_identity)
2008
1768
  store[scope.discovered_def_nodes] ||= {}
2009
1769
  end
2010
1770
 
2011
- # Pushes the recursion-guard frame, evaluates the body (the outermost
2012
- # frame for a plain signature runs the ADR-55 slice 2 fixpoint; nested
2013
- # extended frames evaluate once and let the owner iterate), and on the
2014
- # way out pops the frame and resets the per-outermost-entry fuel and
2015
- # summary tables when the guard stack drains to empty.
1771
+ # Pushes the recursion-guard frame, evaluates the body (the outermost frame for a plain signature runs
1772
+ # the ADR-55 slice 2 fixpoint; nested extended frames evaluate once and let the owner iterate), and on
1773
+ # the way out pops the frame and resets the per-outermost-entry fuel and summary tables when the guard
1774
+ # stack drains to empty.
2016
1775
  def evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2017
- # The outermost frame for this plain signature owns the summary
2018
- # entry and runs the fixpoint loop. ADR-55 WD2.
1776
+ # The outermost frame for this plain signature owns the summary entry and runs the fixpoint loop.
1777
+ # ADR-55 WD2.
2019
1778
  outermost = stack.none? { |frame| plain_part(frame) == context.plain_signature }
2020
1779
  stack.push(signature)
2021
1780
  begin
@@ -2035,19 +1794,16 @@ module Rigor
2035
1794
  end
2036
1795
  end
2037
1796
 
2038
- # Evaluates a method body and joins the value types of every explicit
2039
- # `return value` reached during the walk with the body's tail type.
1797
+ # Evaluates a method body and joins the value types of every explicit `return value` reached during
1798
+ # the walk with the body's tail type.
2040
1799
  #
2041
- # The tail-only evaluator (`statements_type_for` → `type_of(body.last)`)
2042
- # models only the fall-through value; an early `return false` or a
2043
- # block-internal `return x` produces `Bot` at its own position and is
2044
- # otherwise invisible to method-return inference. Without this join a
2045
- # predicate helper shaped `return false unless cond; ...; true` infers
2046
- # `Constant[true]` (the early `return false` dropped), which folds
2047
- # `if helper` to always-truthy. `StatementEvaluator.with_return_sink`
2048
- # collects the returns (nested `def`/lambda are barriers; block-internal
2049
- # returns correctly bubble to the enclosing method) so the inferred
2050
- # return is `tail | return_1 | … | return_n`, matching Ruby semantics.
1800
+ # The tail-only evaluator (`statements_type_for` → `type_of(body.last)`) models only the fall-through
1801
+ # value; an early `return false` or a block-internal `return x` produces `Bot` at its own position and
1802
+ # is otherwise invisible to method-return inference. Without this join a predicate helper shaped
1803
+ # `return false unless cond; ...; true` infers `Constant[true]` (the early `return false` dropped),
1804
+ # which folds `if helper` to always-truthy. `StatementEvaluator.with_return_sink` collects the returns
1805
+ # (nested `def`/lambda are barriers; block-internal returns correctly bubble to the enclosing method)
1806
+ # so the inferred return is `tail | return_1 | … | return_n`, matching Ruby semantics.
2051
1807
  def evaluate_body_with_returns(body_scope, body)
2052
1808
  (type, post_scope), returns = StatementEvaluator.with_return_sink do
2053
1809
  body_scope.evaluate(body)
@@ -2056,16 +1812,13 @@ module Rigor
2056
1812
  [joined, post_scope]
2057
1813
  end
2058
1814
 
2059
- # ADR-55 slice 2 — Kleene fixpoint over a recursive method's return
2060
- # summary. Seeds the assumption to `bot`, evaluates the body, and (only
2061
- # if the summary was actually consulted during evaluation i.e. the
2062
- # method really recursed) iterates: if the computed return is subsumed
2063
- # by the assumption the fixpoint is reached; otherwise the assumption
2064
- # joins in the computed return and the body re-evaluates. Capped at
2065
- # `RECURSION_FIXPOINT_CAP` total evaluations; the final permitted
2066
- # iteration widens value-pinned constituents to their nominal base to
2067
- # force convergence, and any residual instability collapses to
2068
- # `untyped` (today's behaviour).
1815
+ # ADR-55 slice 2 — Kleene fixpoint over a recursive method's return summary. Seeds the assumption to
1816
+ # `bot`, evaluates the body, and (only if the summary was actually consulted during evaluation — i.e.
1817
+ # the method really recursed) iterates: if the computed return is subsumed by the assumption the
1818
+ # fixpoint is reached; otherwise the assumption joins in the computed return and the body re-evaluates.
1819
+ # Capped at `RECURSION_FIXPOINT_CAP` total evaluations; the final permitted iteration widens
1820
+ # value-pinned constituents to their nominal base to force convergence, and any residual instability
1821
+ # collapses to `untyped` (today's behaviour).
2069
1822
  def fixpoint_user_method_return(def_node, body_scope, context, widened: false)
2070
1823
  plain_signature = context.plain_signature
2071
1824
  summaries = context.summaries
@@ -2079,34 +1832,27 @@ module Rigor
2079
1832
  type, = evaluate_body_with_returns(body_scope, def_node.body)
2080
1833
  computed = clamp_unroll_result(type, context.would_have_been_guarded)
2081
1834
 
2082
- # Cross-signature mutual recursion (ADR-55 soundness fix,
2083
- # 2026-06-12): the evaluation consulted an ANCESTOR signature's
2084
- # in-flight summary (seed depth shallower than this frame's), so
2085
- # `computed` embeds a transient foreign Kleene iterate -- e.g.
2086
- # `odd?` folding `even?`'s seeded `bot` into `Constant[false]`.
2087
- # The per-signature iteration below cannot converge such an
2088
- # entangled pair (each side's iterate is conditioned on the
2089
- # other's unfinished assumption), so degrade this frame to the
1835
+ # Cross-signature mutual recursion (ADR-55 soundness fix, 2026-06-12): the evaluation consulted an
1836
+ # ANCESTOR signature's in-flight summary (seed depth shallower than this frame's), so `computed`
1837
+ # embeds a transient foreign Kleene iterate -- e.g. `odd?` folding `even?`'s seeded `bot` into
1838
+ # `Constant[false]`. The per-signature iteration below cannot converge such an entangled pair (each
1839
+ # side's iterate is conditioned on the other's unfinished assumption), so degrade this frame to the
2090
1840
  # sound `untyped` floor instead of surfacing a one-sided value.
2091
1841
  if consult_depths[consult_mark..].any? { |d| d < depth }
2092
1842
  return degrade_entangled_fixpoint(summaries, plain_signature)
2093
1843
  end
2094
1844
 
2095
- # The summary was never consulted — the method did not recurse on
2096
- # this evaluation, so there is no fixpoint to chase. Return the
2097
- # computed type directly (pre-fixpoint behaviour for non-recursive
1845
+ # The summary was never consulted — the method did not recurse on this evaluation, so there is no
1846
+ # fixpoint to chase. Return the computed type directly (pre-fixpoint behaviour for non-recursive
2098
1847
  # bodies that merely share `infer_user_method_return`).
2099
1848
  return computed unless summaries.dig(plain_signature, :consulted)
2100
1849
 
2101
- # ADR-55 slice 2 bot-collapse fix (2026-06-11). When the recursive
2102
- # method's only contribution this evaluation was the seeded `bot`
2103
- # assumption (so `computed` is `bot` even though the body recursed),
2104
- # the `joined == assumption` check below would trivially converge at
2105
- # the seed and return `bot` UNSOUND for a method with a reachable
2106
- # non-recursive exit (`passthrough` returns `:done`, `pick` returns
2107
- # `nil`). `bot` means "never returns", which feeds ADR-47
2108
- # reachability / always-falsey diagnostics, so it must be reserved
2109
- # for genuinely diverging methods (`spin`).
1850
+ # ADR-55 slice 2 bot-collapse fix (2026-06-11). When the recursive method's only contribution this
1851
+ # evaluation was the seeded `bot` assumption (so `computed` is `bot` even though the body
1852
+ # recursed), the `joined == assumption` check below would trivially converge at the seed and
1853
+ # return `bot` UNSOUND for a method with a reachable non-recursive exit (`passthrough` returns
1854
+ # `:done`, `pick` returns `nil`). `bot` means "never returns", which feeds ADR-47 reachability /
1855
+ # always-falsey diagnostics, so it must be reserved for genuinely diverging methods (`spin`).
2110
1856
  if computed.is_a?(Type::Bot)
2111
1857
  resolved = resolve_bot_collapse(def_node, context, widened: widened)
2112
1858
  return resolved unless resolved.nil?
@@ -2117,10 +1863,9 @@ module Rigor
2117
1863
  end
2118
1864
  end
2119
1865
 
2120
- # Seeds the thread-local summary entry for a fixpoint owner: the `bot`
2121
- # Kleene seed plus the guard-stack depth at seed time (the frame for
2122
- # this signature is already pushed), which `consult_summary` logs so
2123
- # nested fixpoints can detect a foreign in-flight (ancestor) consult.
1866
+ # Seeds the thread-local summary entry for a fixpoint owner: the `bot` Kleene seed plus the
1867
+ # guard-stack depth at seed time (the frame for this signature is already pushed), which
1868
+ # `consult_summary` logs so nested fixpoints can detect a foreign in-flight (ancestor) consult.
2124
1869
  # Returns the seed depth. ADR-55 slice 2.
2125
1870
  def seed_fixpoint_summary(summaries, plain_signature)
2126
1871
  depth = (Thread.current[INFERENCE_GUARD_KEY] || []).size
@@ -2130,9 +1875,8 @@ module Rigor
2130
1875
  depth
2131
1876
  end
2132
1877
 
2133
- # Degrades an entangled mutual-recursion fixpoint to the sound
2134
- # `untyped` floor (ADR-55 mutual-recursion soundness fix, 2026-06-12),
2135
- # parking `untyped` in the assumption so any consumer that still reads
1878
+ # Degrades an entangled mutual-recursion fixpoint to the sound `untyped` floor (ADR-55 mutual-recursion
1879
+ # soundness fix, 2026-06-12), parking `untyped` in the assumption so any consumer that still reads
2136
1880
  # this signature's summary sees the floor, not the stale `bot` seed.
2137
1881
  def degrade_entangled_fixpoint(summaries, plain_signature)
2138
1882
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
@@ -2141,12 +1885,10 @@ module Rigor
2141
1885
  Type::Combinator.untyped
2142
1886
  end
2143
1887
 
2144
- # One Kleene-iteration step of the fixpoint loop. Joins `computed` into
2145
- # the running assumption (widening value-pinned constituents on the
2146
- # final permitted iteration to force convergence) and either returns a
2147
- # final type convergence, or the capped `untyped` collapse — or
2148
- # `:continue` to request another body evaluation, having advanced the
2149
- # stored assumption. ADR-55 WD2.
1888
+ # One Kleene-iteration step of the fixpoint loop. Joins `computed` into the running assumption
1889
+ # (widening value-pinned constituents on the final permitted iteration to force convergence) and
1890
+ # either returns a final type convergence, or the capped `untyped` collapse — or `:continue` to
1891
+ # request another body evaluation, having advanced the stored assumption. ADR-55 WD2.
2150
1892
  def fixpoint_step(summaries, plain_signature, computed, iteration)
2151
1893
  assumption = summaries[plain_signature][:assumption]
2152
1894
  last_iteration = iteration == RECURSION_FIXPOINT_CAP - 1
@@ -2170,36 +1912,31 @@ module Rigor
2170
1912
  :continue
2171
1913
  end
2172
1914
 
2173
- # Rebuilds the user-method body scope with every bound positional
2174
- # parameter widened to its nominal base (`1 | 2 | 3` → `Integer`,
2175
- # `Constant[:x]` `Symbol`). Used by the bot-collapse retry in
2176
- # `fixpoint_user_method_return`: call-site argument narrowing can prune a
2177
- # recursive method's base case, and widening restores the declared-type
2178
- # view under which the base case is reachable. Returns `nil` when the
2179
- # parameter shape is not inferable (mirrors `build_user_method_body_scope`).
1915
+ # Rebuilds the user-method body scope with every bound positional parameter widened to its nominal
1916
+ # base (`1 | 2 | 3` → `Integer`, `Constant[:x]` → `Symbol`). Used by the bot-collapse retry in
1917
+ # `fixpoint_user_method_return`: call-site argument narrowing can prune a recursive method's base
1918
+ # case, and widening restores the declared-type view under which the base case is reachable. Returns
1919
+ # `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
2180
1920
  def widened_user_method_body_scope(def_node, receiver, arg_types)
2181
1921
  widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
2182
1922
  build_user_method_body_scope(def_node, receiver, widened_args)
2183
1923
  end
2184
1924
 
2185
- # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a
2186
- # fixpoint iteration computed `bot` for a recursive body. Two escape
2187
- # hatches keep `bot` reserved for genuinely diverging methods:
1925
+ # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a fixpoint iteration computed `bot`
1926
+ # for a recursive body. Two escape hatches keep `bot` reserved for genuinely diverging methods:
2188
1927
  #
2189
- # 1. Re-run the fixpoint ONCE over a parameter-widened body scope
2190
- # (`1 | 2 | 3` `Integer`): call-site argument narrowing can prune
2191
- # a base-case *tail* branch (`n <= 0 ? :done : recurse` with a
2192
- # positive-only `n`), and widening un-prunes it so the base
2193
- # constituent (`:done`) surfaces. `passthrough` recovers here.
1928
+ # 1. Re-run the fixpoint ONCE over a parameter-widened body scope (`1 | 2 | 3` → `Integer`):
1929
+ # call-site argument narrowing can prune a base-case *tail* branch (`n <= 0 ? :done : recurse`
1930
+ # with a positive-only `n`), and widening un-prunes it so the base constituent (`:done`) surfaces.
1931
+ # `passthrough` recovers here.
2194
1932
  #
2195
- # 2. If the (possibly widened) body STILL computes `bot` but contains
2196
- # a reachable explicit `return` — whose value the tail-only body
2197
- # evaluator never folds into the result (`pick`'s `return nil`)
2198
- # fall to the conservative `Dynamic[top]` floor (the pre-slice-2
2199
- # observable) rather than the unsound `bot`.
1933
+ # 2. If the (possibly widened) body STILL computes `bot` but contains a reachable explicit `return`
1934
+ # — whose value the tail-only body evaluator never folds into the result (`pick`'s `return nil`)
1935
+ # fall to the conservative `Dynamic[top]` floor (the pre-slice-2 observable) rather than the
1936
+ # unsound `bot`.
2200
1937
  #
2201
- # Returns the resolved type, or `nil` to let the caller's normal
2202
- # fixpoint convergence proceed (genuine divergence — `spin`).
1938
+ # Returns the resolved type, or `nil` to let the caller's normal fixpoint convergence proceed (genuine
1939
+ # divergence — `spin`).
2203
1940
  def resolve_bot_collapse(def_node, context, widened:)
2204
1941
  unless widened
2205
1942
  widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types)
@@ -2211,13 +1948,11 @@ module Rigor
2211
1948
  nil
2212
1949
  end
2213
1950
 
2214
- # True when `node` contains a reachable explicit `return` statement —
2215
- # one not nested inside a return barrier (`def` / lambda / block). The
2216
- # tail-only body evaluator in `infer_user_method_return` never folds an
2217
- # early-return value into the method result, so a recursive method whose
2218
- # base case is spelled as `return value` (rather than a tail branch)
2219
- # looks like it only diverges. This detector is the signal that such a
2220
- # method has a non-recursive exit, so its bot-collapse must floor to
1951
+ # True when `node` contains a reachable explicit `return` statement — one not nested inside a return
1952
+ # barrier (`def` / lambda / block). The tail-only body evaluator in `infer_user_method_return` never
1953
+ # folds an early-return value into the method result, so a recursive method whose base case is
1954
+ # spelled as `return value` (rather than a tail branch) looks like it only diverges. This detector is
1955
+ # the signal that such a method has a non-recursive exit, so its bot-collapse must floor to
2221
1956
  # `Dynamic[top]` rather than `bot` (ADR-55 slice 2, 2026-06-11).
2222
1957
  RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
2223
1958
  private_constant :RETURN_BARRIER_NODES
@@ -2230,11 +1965,10 @@ module Rigor
2230
1965
  node.compact_child_nodes.any? { |child| body_has_explicit_return?(child) }
2231
1966
  end
2232
1967
 
2233
- # Returns the current assumed summary for `plain_signature`, recording
2234
- # that it was consulted (so the fixpoint owner knows the body actually
2235
- # recursed). Falls back to `untyped` when no summary is active e.g. a
2236
- # nested extended frame guarded before its plain signature seeded an
2237
- # entry, which is the pre-slice-2 observable.
1968
+ # Returns the current assumed summary for `plain_signature`, recording that it was consulted (so the
1969
+ # fixpoint owner knows the body actually recursed). Falls back to `untyped` when no summary is active
1970
+ # — e.g. a nested extended frame guarded before its plain signature seeded an entry, which is the
1971
+ # pre-slice-2 observable.
2238
1972
  def consult_summary(summaries, plain_signature)
2239
1973
  entry = summaries[plain_signature]
2240
1974
  return Type::Combinator.untyped if entry.nil?
@@ -2244,46 +1978,38 @@ module Rigor
2244
1978
  entry[:assumption]
2245
1979
  end
2246
1980
 
2247
- # ADR-55 WD1 governing-rule clamp. When the just-evaluated frame
2248
- # took the extended (value-keyed) path but its plain signature was
2249
- # already guarded (`would_have_been_guarded`), the unroll may only
2250
- # surface a fully value-pinned result; any other outcome must be
2251
- # byte-identical to the plain guard's `untyped` (and counts a
2252
- # `RECURSION_GUARD` hit, matching the pre-slice-1 observable).
1981
+ # ADR-55 WD1 governing-rule clamp. When the just-evaluated frame took the extended (value-keyed) path
1982
+ # but its plain signature was already guarded (`would_have_been_guarded`), the unroll may only surface
1983
+ # a fully value-pinned result; any other outcome must be byte-identical to the plain guard's `untyped`
1984
+ # (and counts a `RECURSION_GUARD` hit, matching the pre-slice-1 observable).
2253
1985
  def clamp_unroll_result(type, would_have_been_guarded)
2254
1986
  return type unless would_have_been_guarded && !fully_value_pinned?(type)
2255
1987
 
2256
1988
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2257
1989
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
2258
- # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
2259
- # must be byte-identical to the plain guard's `untyped`. This path
2260
- # deliberately does NOT route to the in-progress fixpoint summary:
2261
- # the summary is a Kleene lower bound mid-iteration, while the clamp
2262
- # is a soundness backstop for an untrustworthy unrolled value, so it
2263
- # must stay the conservative `untyped` upper bound. (WD4's
2264
- # summary-composition applies to the in-cycle guard and fuel paths,
2265
- # which DO return the assumed summary — see `consult_summary`.)
1990
+ # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned must be byte-identical to the
1991
+ # plain guard's `untyped`. This path deliberately does NOT route to the in-progress fixpoint summary:
1992
+ # the summary is a Kleene lower bound mid-iteration, while the clamp is a soundness backstop for an
1993
+ # untrustworthy unrolled value, so it must stay the conservative `untyped` upper bound. (WD4's
1994
+ # summary-composition applies to the in-cycle guard and fuel paths, which DO return the assumed
1995
+ # summary see `consult_summary`.)
2266
1996
  Type::Combinator.untyped
2267
1997
  end
2268
1998
 
2269
- # Widens every value-pinned constituent of `type` to its nominal base
2270
- # (`Constant[1]` → `Integer`, `Tuple[Constant…]` → its element bases),
2271
- # leaving non-pinned constituents untouched. Used on the fixpoint's
2272
- # final permitted iteration (ADR-55 WD2) to force convergence — the
2273
- # tower of distinct constant iterates collapses to one nominal type.
1999
+ # Widens every value-pinned constituent of `type` to its nominal base (`Constant[1]` → `Integer`,
2000
+ # `Tuple[Constant…]` → its element bases), leaving non-pinned constituents untouched. Used on the
2001
+ # fixpoint's final permitted iteration (ADR-55 WD2) to force convergence the tower of distinct
2002
+ # constant iterates collapses to one nominal type.
2274
2003
  def widen_value_pinned(type)
2275
2004
  Type::Combinator.widen_value_pinned(type)
2276
2005
  end
2277
2006
 
2278
- # Consumes one unit from the thread-local unroll-fuel counter and
2279
- # returns the units that were available *before* this consumption
2280
- # (so a positive return means the extended value-key may be used).
2281
- # Fuel is per-outermost inference entry: at the top level (empty
2282
- # guard stack) it seeds to `RECURSION_UNROLL_FUEL`, and the
2283
- # `ensure` in `infer_user_method_return` clears it once the stack
2284
- # drains back to empty. On exhaustion (return 0) it records a
2285
- # `RECURSION_UNROLL_FUEL` hit so the caller keeps the plain
2286
- # `(receiver, method)` signature — today's behaviour.
2007
+ # Consumes one unit from the thread-local unroll-fuel counter and returns the units that were
2008
+ # available *before* this consumption (so a positive return means the extended value-key may be used).
2009
+ # Fuel is per-outermost inference entry: at the top level (empty guard stack) it seeds to
2010
+ # `RECURSION_UNROLL_FUEL`, and the `ensure` in `infer_user_method_return` clears it once the stack
2011
+ # drains back to empty. On exhaustion (return 0) it records a `RECURSION_UNROLL_FUEL` hit so the caller
2012
+ # keeps the plain `(receiver, method)` signature today's behaviour.
2287
2013
  def unroll_fuel_remaining(stack)
2288
2014
  remaining = Thread.current[INFERENCE_UNROLL_FUEL_KEY]
2289
2015
  remaining = RECURSION_UNROLL_FUEL if remaining.nil? || stack.empty?
@@ -2295,12 +2021,11 @@ module Rigor
2295
2021
  remaining
2296
2022
  end
2297
2023
 
2298
- # A stable, hashable descriptor of the argument values when EVERY
2299
- # element of `arg_types` is value-pinned: a `Type::Constant`, or a
2300
- # `Type::Tuple` whose elements are (recursively) all value-pinned.
2301
- # Returns nil when any argument is not value-pinned (the ordinary
2302
- # type-keyed path) or when any pinned value's structural size
2303
- # exceeds `RECURSION_VALUE_SIZE_CAP` (value blow-up → fall back).
2024
+ # A stable, hashable descriptor of the argument values when EVERY element of `arg_types` is
2025
+ # value-pinned: a `Type::Constant`, or a `Type::Tuple` whose elements are (recursively) all
2026
+ # value-pinned. Returns nil when any argument is not value-pinned (the ordinary type-keyed path) or
2027
+ # when any pinned value's structural size exceeds `RECURSION_VALUE_SIZE_CAP` (value blow-up fall
2028
+ # back).
2304
2029
  def constant_argument_value_key(arg_types)
2305
2030
  return nil if arg_types.empty?
2306
2031
 
@@ -2316,10 +2041,9 @@ module Rigor
2316
2041
  keys.map(&:first)
2317
2042
  end
2318
2043
 
2319
- # Returns `[descriptor, structural_size]` for a value-pinned type,
2320
- # or nil for anything else. Strings count by a cheap length proxy
2321
- # (length > 256 ≈ 64+ nodes) so a long built string disqualifies
2322
- # the frame without a deep walk; tuples recurse.
2044
+ # Returns `[descriptor, structural_size]` for a value-pinned type, or nil for anything else. Strings
2045
+ # count by a cheap length proxy (length > 256 ≈ 64+ nodes) so a long built string disqualifies the
2046
+ # frame without a deep walk; tuples recurse.
2323
2047
  def pinned_value_descriptor(arg)
2324
2048
  case arg
2325
2049
  when Type::Constant
@@ -2340,35 +2064,27 @@ module Rigor
2340
2064
  end
2341
2065
  end
2342
2066
 
2343
- # Builds the body scope for a user-defined instance
2344
- # method call: a fresh `Scope` with `self_type` set to
2345
- # the receiver's nominal type, the project-wide
2346
- # accumulators inherited (so the body sees the same
2347
- # `discovered_classes` / `class_ivars` / etc. the
2348
- # caller does), and required positional parameters
2349
- # bound from the call's `arg_types` by index. Returns
2350
- # nil when the parameter shape is too complex for the
2351
- # first-iteration binder (rest args, keyword args,
2352
- # block params, etc.).
2067
+ # Builds the body scope for a user-defined instance method call: a fresh `Scope` with `self_type` set
2068
+ # to the receiver's nominal type, the project-wide accumulators inherited (so the body sees the same
2069
+ # `discovered_classes` / `class_ivars` / etc. the caller does), and required positional parameters
2070
+ # bound from the call's `arg_types` by index. Returns nil when the parameter shape is too complex for
2071
+ # the first-iteration binder (rest args, keyword args, block params, etc.).
2353
2072
  def build_user_method_body_scope(def_node, receiver, arg_types)
2354
2073
  params = def_node.parameters
2355
2074
  required = params&.requireds || []
2356
2075
  return nil unless params.nil? || user_method_param_shape_simple?(params)
2357
2076
  return nil unless required.size == arg_types.size
2358
2077
 
2359
- # Bind required positionals by index. The body scope starts from an
2360
- # empty fact store and narrowing set, so `with_local`'s fact /
2361
- # narrowing invalidations would be no-ops here — build the locals
2078
+ # Bind required positionals by index. The body scope starts from an empty fact store and narrowing
2079
+ # set, so `with_local`'s fact / narrowing invalidations would be no-ops here — build the locals
2362
2080
  # table directly (matching `with_local`'s `name.to_sym` key).
2363
2081
  locals = {}
2364
2082
  required.each_with_index { |param, index| locals[param.name.to_sym] = arg_types[index] }
2365
2083
 
2366
- # Construct the body scope in a SINGLE allocation — the previous
2367
- # `Scope.empty.with_*.with_*…` chain allocated a fresh frozen Scope
2368
- # per field, run per user-method-call inference (ADR-44). The
2369
- # discovery index is inherited whole by reference (ADR-53 Track A);
2370
- # the hand-copied per-field list this replaces had silently dropped
2371
- # `data_member_layouts` and `discovered_method_visibilities`.
2084
+ # Construct the body scope in a SINGLE allocation — the previous `Scope.empty.with_*.with_*…` chain
2085
+ # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
2086
+ # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
2087
+ # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
2372
2088
  Scope.new(
2373
2089
  environment: scope.environment,
2374
2090
  locals: locals.freeze,
@@ -2379,10 +2095,9 @@ module Rigor
2379
2095
  )
2380
2096
  end
2381
2097
 
2382
- # ADR-48 Struct slice 3 — the fold-safe-local set for a method body
2383
- # (runs only on a return-memo miss, so the per-call cost is bounded —
2384
- # measured perf-neutral). Struct member layouts of constant receivers
2385
- # are resolved through the discovery side-table the body scope inherits.
2098
+ # ADR-48 Struct slice 3 — the fold-safe-local set for a method body (runs only on a return-memo miss,
2099
+ # so the per-call cost is bounded — measured perf-neutral). Struct member layouts of constant
2100
+ # receivers are resolved through the discovery side-table the body scope inherits.
2386
2101
  def struct_fold_safe_locals_for(body)
2387
2102
  StructFoldSafety.fold_safe_locals(
2388
2103
  body,
@@ -2390,10 +2105,8 @@ module Rigor
2390
2105
  )
2391
2106
  end
2392
2107
 
2393
- # First iteration accepts only required positional
2394
- # parameters: `def foo(a, b, c)`. Optionals, rest,
2395
- # keyword params, and block params disqualify the
2396
- # method from inference (the caller observes
2108
+ # First iteration accepts only required positional parameters: `def foo(a, b, c)`. Optionals, rest,
2109
+ # keyword params, and block params disqualify the method from inference (the caller observes
2397
2110
  # `Dynamic[Top]` instead).
2398
2111
  def user_method_param_shape_simple?(params)
2399
2112
  return false unless params.is_a?(Prism::ParametersNode)
@@ -2405,16 +2118,12 @@ module Rigor
2405
2118
  params.block.nil?
2406
2119
  end
2407
2120
 
2408
- # Slice A-engine. Implicit-self calls (no `node.receiver`)
2409
- # adopt the surrounding scope's `self_type` as their receiver
2410
- # so calls like `attr_reader_method_name` or
2411
- # `private_helper(...)` inside an instance method dispatch
2412
- # against the enclosing class. Slice 7 phase 10 when
2413
- # `self_type` is nil (top-level program), the receiver
2414
- # MUST default to `Nominal[Object]` so Kernel intrinsics
2415
- # like `require`, `require_relative`, `raise`, and `puts`
2416
- # dispatch through Object/Kernel rather than falling through
2417
- # to `Dynamic[Top]`.
2121
+ # Slice A-engine. Implicit-self calls (no `node.receiver`) adopt the surrounding scope's `self_type`
2122
+ # as their receiver so calls like `attr_reader_method_name` or `private_helper(...)` inside an
2123
+ # instance method dispatch against the enclosing class. Slice 7 phase 10 — when `self_type` is nil
2124
+ # (top-level program), the receiver MUST default to `Nominal[Object]` so Kernel intrinsics like
2125
+ # `require`, `require_relative`, `raise`, and `puts` dispatch through Object/Kernel rather than
2126
+ # falling through to `Dynamic[Top]`.
2418
2127
  def call_receiver_type_for(node)
2419
2128
  return type_of(node.receiver) if node.receiver
2420
2129
 
@@ -2432,28 +2141,20 @@ module Rigor
2432
2141
  arguments_node.arguments.map { |argument| type_of(argument) }
2433
2142
  end
2434
2143
 
2435
- # When the call carries a `Prism::BlockNode`, build the block's
2436
- # entry scope (outer locals plus parameter bindings driven by
2437
- # the receiving method's RBS signature), type the block body
2438
- # under that scope, and return the body's value type. The
2439
- # result feeds `MethodDispatcher.dispatch`'s `block_type:` so
2440
- # generic methods like `Array#map[U] { (Elem) -> U } -> Array[U]`
2441
- # resolve `U` to the block's return type. Returns `nil` when
2442
- # the call has no block, when the receiver is unknown, or
2443
- # when typing the body raises (defensive against malformed
2444
- # subtrees); the dispatcher then runs in its no-block-aware
2445
- # path.
2144
+ # When the call carries a `Prism::BlockNode`, build the block's entry scope (outer locals plus
2145
+ # parameter bindings driven by the receiving method's RBS signature), type the block body under that
2146
+ # scope, and return the body's value type. The result feeds `MethodDispatcher.dispatch`'s
2147
+ # `block_type:` so generic methods like `Array#map[U] { (Elem) -> U } -> Array[U]` resolve `U` to the
2148
+ # block's return type. Returns `nil` when the call has no block, when the receiver is unknown, or when
2149
+ # typing the body raises (defensive against malformed subtrees); the dispatcher then runs in its
2150
+ # no-block-aware path.
2446
2151
  #
2447
- # ADR-14 gap-#3 (d): a `Prism::BlockArgumentNode` carrying
2448
- # `&:symbol` (the Symbol#to_proc shorthand) is treated as
2449
- # a block. The block's return type is computed by
2450
- # dispatching `:symbol` on the expected block param type
2451
- # (per `Symbol#to_proc`'s `{ |x| x.symbol }` semantics).
2452
- # A precise inner dispatch produces the right return; any
2453
- # failure step falls back to `Dynamic[Top]` so the
2454
- # dispatcher still SEES a block — selecting the block-
2455
- # bearing overload of e.g. `Hash#transform_values` over
2456
- # the no-block overload that returns `Enumerator`.
2152
+ # ADR-14 gap-#3 (d): a `Prism::BlockArgumentNode` carrying `&:symbol` (the Symbol#to_proc shorthand) is
2153
+ # treated as a block. The block's return type is computed by dispatching `:symbol` on the expected
2154
+ # block param type (per `Symbol#to_proc`'s `{ |x| x.symbol }` semantics). A precise inner dispatch
2155
+ # produces the right return; any failure step falls back to `Dynamic[Top]` so the dispatcher still
2156
+ # SEES a block selecting the block-bearing overload of e.g. `Hash#transform_values` over the
2157
+ # no-block overload that returns `Enumerator`.
2457
2158
  def block_return_type_for(call_node, receiver_type, arg_types)
2458
2159
  block_arg = call_node.block
2459
2160
  return nil if block_arg.nil?
@@ -2465,11 +2166,9 @@ module Rigor
2465
2166
  arg_types: arg_types,
2466
2167
  environment: scope.environment
2467
2168
  )
2468
- # ADR-16 Tier A: when a registered plugin's `block_as_methods`
2469
- # entry matches `(receiver_type, call_node.name)`, narrow the
2470
- # block body's `self_type` to the receiver class's instance
2471
- # type. The narrowing is `nil` for unmatched calls, leaving
2472
- # the existing scope contract unchanged.
2169
+ # ADR-16 Tier A: when a registered plugin's `block_as_methods` entry matches `(receiver_type,
2170
+ # call_node.name)`, narrow the block body's `self_type` to the receiver class's instance type. The
2171
+ # narrowing is `nil` for unmatched calls, leaving the existing scope contract unchanged.
2473
2172
  narrowed_self = MacroBlockSelfType.narrow_self_type_for(
2474
2173
  scope: scope, call_node: call_node, receiver_type: receiver_type
2475
2174
  )
@@ -2490,15 +2189,11 @@ module Rigor
2490
2189
  end
2491
2190
  end
2492
2191
 
2493
- # `&:symbol` desugars to a one-arg Proc that dispatches
2494
- # `symbol` against its argument. When the param type is
2495
- # known and the resulting inner dispatch is precise,
2496
- # this returns the precise carrier; otherwise it
2497
- # returns `Dynamic[Top]` (still non-nil) so the outer
2498
- # dispatcher selects the block-bearing overload.
2499
- # `&proc_local` / `&method(:foo)` and friends — anything
2500
- # not a bare SymbolNode — still resolve to
2501
- # `Dynamic[Top]` for the same block-presence signal.
2192
+ # `&:symbol` desugars to a one-arg Proc that dispatches `symbol` against its argument. When the param
2193
+ # type is known and the resulting inner dispatch is precise, this returns the precise carrier;
2194
+ # otherwise it returns `Dynamic[Top]` (still non-nil) so the outer dispatcher selects the
2195
+ # block-bearing overload. `&proc_local` / `&method(:foo)` and friends — anything not a bare
2196
+ # SymbolNode — still resolve to `Dynamic[Top]` for the same block-presence signal.
2502
2197
  def symbol_block_return_type(block_arg, expected_param_types)
2503
2198
  expression = block_arg.expression
2504
2199
  return dynamic_top unless expression.is_a?(Prism::SymbolNode)
@@ -2525,19 +2220,14 @@ module Rigor
2525
2220
  block_scope.type_of(body)
2526
2221
  end
2527
2222
 
2528
- # v0.0.6 phase 2 — per-element block fold for Tuple
2529
- # receivers under `:map` / `:collect`. Walks every Tuple
2530
- # position, binds the block parameter to that element's
2531
- # type, and re-types the block body. The per-position
2532
- # results are assembled into `Tuple[U_1..U_n]`, strictly
2533
- # tighter than the RBS-projected `Array[union]`.
2223
+ # v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
2224
+ # Tuple position, binds the block parameter to that element's type, and re-types the block body. The
2225
+ # per-position results are assembled into `Tuple[U_1..U_n]`, strictly tighter than the RBS-projected
2226
+ # `Array[union]`.
2534
2227
  #
2535
- # Declines (returns nil) when the receiver is not a
2536
- # `Tuple` with at least one element, when the call has
2537
- # no `Prism::BlockNode`, when the method is outside the
2538
- # supported set, when block typing raises mid-loop, or
2539
- # when the block has no body. The decline path leaves
2540
- # the dispatch chain untouched.
2228
+ # Declines (returns nil) when the receiver is not a `Tuple` with at least one element, when the call
2229
+ # has no `Prism::BlockNode`, when the method is outside the supported set, when block typing raises
2230
+ # mid-loop, or when the block has no body. The decline path leaves the dispatch chain untouched.
2541
2231
  PER_ELEMENT_TUPLE_METHODS = Set[
2542
2232
  :map, :collect, :filter_map, :flat_map,
2543
2233
  :select, :filter, :reject,
@@ -2551,12 +2241,10 @@ module Rigor
2551
2241
  ].freeze
2552
2242
  private_constant :HASH_SHAPE_TRANSFORM_METHODS
2553
2243
 
2554
- # Cardinality cap for per-element block fold over
2555
- # finite-bound `Constant<Range>` receivers. Walking
2556
- # `(1..1_000_000).map { }` element-wise would balloon
2557
- # block-typing cost and explode the resulting Tuple, so
2558
- # only short ranges expand into per-position folds.
2559
- # Larger ranges decline so the RBS tier widens.
2244
+ # Cardinality cap for per-element block fold over finite-bound `Constant<Range>` receivers. Walking
2245
+ # `(1..1_000_000).map { … }` element-wise would balloon block-typing cost and explode the resulting
2246
+ # Tuple, so only short ranges expand into per-position folds. Larger ranges decline so the RBS tier
2247
+ # widens.
2560
2248
  PER_ELEMENT_RANGE_LIMIT = 8
2561
2249
  private_constant :PER_ELEMENT_RANGE_LIMIT
2562
2250
 
@@ -2573,18 +2261,14 @@ module Rigor
2573
2261
  assemble_per_element_result(call_node.name, per_position, element_types)
2574
2262
  end
2575
2263
 
2576
- # Evaluates the call's block once per receiver element.
2577
- # Two block shapes are supported:
2264
+ # Evaluates the call's block once per receiver element. Two block shapes are supported:
2578
2265
  #
2579
- # - `Prism::BlockNode` — a full `do … end` / `{ … }` block;
2580
- # the body is re-typed per position with the element
2581
- # bound to the block parameter.
2582
- # - `Prism::BlockArgumentNode` wrapping a `SymbolNode`
2583
- # the `&:predicate` shorthand; the symbol is dispatched
2584
- # as a zero-arg method on each element type.
2266
+ # - `Prism::BlockNode` — a full `do … end` / `{ … }` block; the body is re-typed per position with the
2267
+ # element bound to the block parameter.
2268
+ # - `Prism::BlockArgumentNode` wrapping a `SymbolNode` — the `&:predicate` shorthand; the symbol is
2269
+ # dispatched as a zero-arg method on each element type.
2585
2270
  #
2586
- # Any other shape (`&proc_local`, `&method(:foo)`, no
2587
- # block) returns `nil` so the fold declines.
2271
+ # Any other shape (`&proc_local`, `&method(:foo)`, no block) returns `nil` so the fold declines.
2588
2272
  def per_element_block_results(block, element_types)
2589
2273
  case block
2590
2274
  when Prism::BlockNode
@@ -2613,20 +2297,17 @@ module Rigor
2613
2297
  nil
2614
2298
  end
2615
2299
 
2616
- # Returns the per-position element types for a finite,
2617
- # statically-known receiver shape or nil when the
2618
- # receiver does not pin a finite element list.
2300
+ # Returns the per-position element types for a finite, statically-known receiver shape — or nil when
2301
+ # the receiver does not pin a finite element list.
2619
2302
  #
2620
2303
  # `Tuple[A, B, …]` → [A, B, …]
2621
2304
  # `Constant<a..b>` → [Constant[a], …, Constant[b]]
2622
2305
  # everything else → nil
2623
2306
  #
2624
- # Note: `Type::IntegerRange` is the bounded-Integer
2625
- # carrier (`int<a, b>` represents "an Integer between
2626
- # a and b"), not a Range value. Calls like `.map` /
2627
- # `.find` on an `IntegerRange` receiver would resolve
2628
- # to `Integer#map` / `Integer#find` — neither exists —
2629
- # so IntegerRange does NOT participate in this fold.
2307
+ # Note: `Type::IntegerRange` is the bounded-Integer carrier (`int<a, b>` represents "an Integer between
2308
+ # a and b"), not a Range value. Calls like `.map` / `.find` on an `IntegerRange` receiver would resolve
2309
+ # to `Integer#map` / `Integer#find` neither exists so IntegerRange does NOT participate in this
2310
+ # fold.
2630
2311
  def per_element_elements_of(receiver_type)
2631
2312
  case receiver_type
2632
2313
  when Type::Tuple then receiver_type.elements
@@ -2647,47 +2328,38 @@ module Rigor
2647
2328
  INJECT_METHODS = Set[:inject, :reduce].freeze
2648
2329
  private_constant :INJECT_METHODS
2649
2330
 
2650
- # Cap on the element count for the Part 2 constant-threading
2651
- # fold — mirrors `ReduceFolding::CONSTANT_FOLD_ELEMENT_CAP`. The
2652
- # size is checked BEFORE enumeration so `(1..1_000_000)` declines
2653
- # without materialising.
2331
+ # Cap on the element count for the Part 2 constant-threading fold — mirrors
2332
+ # `ReduceFolding::CONSTANT_FOLD_ELEMENT_CAP`. The size is checked BEFORE enumeration so `(1..1_000_000)`
2333
+ # declines without materialising.
2654
2334
  INJECT_CONSTANT_ELEMENT_CAP = 64
2655
2335
  private_constant :INJECT_CONSTANT_ELEMENT_CAP
2656
2336
 
2657
- # Magnitude cap on a folded Integer accumulator — mirrors
2658
- # `ReduceFolding`'s bit cap so factorial-style blow-up declines
2659
- # to the Part 1 nominal result rather than parking a heavy
2660
- # bignum literal in the type graph.
2337
+ # Magnitude cap on a folded Integer accumulator — mirrors `ReduceFolding`'s bit cap so factorial-style
2338
+ # blow-up declines to the Part 1 nominal result rather than parking a heavy bignum literal in the type
2339
+ # graph.
2661
2340
  INJECT_CONSTANT_BIT_CAP = 256
2662
2341
  private_constant :INJECT_CONSTANT_BIT_CAP
2663
2342
 
2664
2343
  # Block-form `inject` / `reduce` return-type fold.
2665
2344
  #
2666
- # Part 1 (soundness): the accumulator of a block-form fold must
2667
- # reach a fixpoint over an unknown number of iterations the
2668
- # RBS tier's generic `(S) { (S, E) -> S } -> S` binds `S` from a
2669
- # SINGLE block pass (acc=seed, elem=element-join), so
2670
- # `(1..5).inject(1) { |a, i| a * i }` types `int<1, 5>` while the
2671
- # runtime is 120 (out of range — unsound). We iterate the
2672
- # accumulator type to a capped fixpoint (ADR-55/56 `BodyFixpoint`)
2673
- # so the multiply converges to `Integer`, never a value-bounded
2345
+ # Part 1 (soundness): the accumulator of a block-form fold must reach a fixpoint over an unknown
2346
+ # number of iterations the RBS tier's generic `(S) { (S, E) -> S } -> S` binds `S` from a SINGLE
2347
+ # block pass (acc=seed, elem=element-join), so `(1..5).inject(1) { |a, i| a * i }` types `int<1, 5>`
2348
+ # while the runtime is 120 (out of range — unsound). We iterate the accumulator type to a capped
2349
+ # fixpoint (ADR-55/56 `BodyFixpoint`) so the multiply converges to `Integer`, never a value-bounded
2674
2350
  # interval the runtime escapes.
2675
2351
  #
2676
- # Part 2 (precision): when the receiver is a fully-constant
2677
- # finite collection (`Constant[Range]` / `Tuple` of `Constant`),
2678
- # the seed is `Constant` (or the no-seed first element), and the
2679
- # block body folds to a `Constant` on EVERY iteration with the
2680
- # running accumulator + element bound, thread the accumulator
2681
- # through per-element block evaluation and return the final
2682
- # `Constant` (`(1..5).inject(1) { |a, i| a * i } -> 120`).
2352
+ # Part 2 (precision): when the receiver is a fully-constant finite collection (`Constant[Range]` /
2353
+ # `Tuple` of `Constant`), the seed is `Constant` (or the no-seed first element), and the block body
2354
+ # folds to a `Constant` on EVERY iteration with the running accumulator + element bound, thread the
2355
+ # accumulator through per-element block evaluation and return the final `Constant` (`(1..5).inject(1)
2356
+ # { |a, i| a * i } -> 120`).
2683
2357
  #
2684
- # The two are layered: Part 2 is attempted first (a constant
2685
- # answer is strictly tighter); on any decline it falls through to
2686
- # the Part 1 sound nominal fixpoint, and on a Part 1 decline to
2687
- # the RBS tier. Captured-local write-back (ADR-56) runs at the
2688
- # statement level independent of this return-type computation, so
2689
- # a block that both accumulates and mutates captured state keeps
2690
- # its write-back regardless of which arm answers here.
2358
+ # The two are layered: Part 2 is attempted first (a constant answer is strictly tighter); on any
2359
+ # decline it falls through to the Part 1 sound nominal fixpoint, and on a Part 1 decline to the RBS
2360
+ # tier. Captured-local write-back (ADR-56) runs at the statement level independent of this return-type
2361
+ # computation, so a block that both accumulates and mutates captured state keeps its write-back
2362
+ # regardless of which arm answers here.
2691
2363
  #
2692
2364
  # @return [Rigor::Type, nil]
2693
2365
  def try_block_inject_fold(call_node, receiver, arg_types)
@@ -2705,9 +2377,8 @@ module Rigor
2705
2377
  try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2706
2378
  end
2707
2379
 
2708
- # Splits the positional args into the optional seed. A Symbol
2709
- # final arg (`inject(seed, :*)`) is the no-block Symbol form and
2710
- # never reaches here (the block guard already failed for it).
2380
+ # Splits the positional args into the optional seed. A Symbol final arg (`inject(seed, :*)`) is the
2381
+ # no-block Symbol form and never reaches here (the block guard already failed for it).
2711
2382
  #
2712
2383
  # @return [Array(Rigor::Type, nil), Boolean] `[seed, has_seed]`
2713
2384
  def inject_seed(arg_types)
@@ -2717,10 +2388,9 @@ module Rigor
2717
2388
  end
2718
2389
  end
2719
2390
 
2720
- # Part 2 — thread the accumulator through per-element block
2721
- # evaluation over a fully-constant finite receiver. Declines
2722
- # (nil) on a non-constant receiver / seed, a size or magnitude
2723
- # cap, or any per-step result that is not a foldable `Constant`.
2391
+ # Part 2 — thread the accumulator through per-element block evaluation over a fully-constant finite
2392
+ # receiver. Declines (nil) on a non-constant receiver / seed, a size or magnitude cap, or any per-step
2393
+ # result that is not a foldable `Constant`.
2724
2394
  def try_constant_inject_fold(receiver, block, seed, has_seed)
2725
2395
  members = inject_constant_members(receiver)
2726
2396
  return nil if members.nil?
@@ -2735,8 +2405,7 @@ module Rigor
2735
2405
  acc
2736
2406
  end
2737
2407
 
2738
- # Extracts the receiver's foldable constant values, size-capped
2739
- # before enumeration, or nil to decline.
2408
+ # Extracts the receiver's foldable constant values, size-capped before enumeration, or nil to decline.
2740
2409
  def inject_constant_members(receiver)
2741
2410
  case receiver
2742
2411
  when Type::Constant then inject_constant_range_members(receiver.value)
@@ -2767,11 +2436,9 @@ module Rigor
2767
2436
  elements.map(&:value)
2768
2437
  end
2769
2438
 
2770
- # Seeds the constant accumulator: with a seed the memo starts at
2771
- # the (foldable) seed value and every member is folded; without a
2772
- # seed the first member seeds the memo and the rest are folded.
2773
- # The accumulator is carried as a `Constant` type (so the block
2774
- # body sees a value-pinned param).
2439
+ # Seeds the constant accumulator: with a seed the memo starts at the (foldable) seed value and every
2440
+ # member is folded; without a seed the first member seeds the memo and the rest are folded. The
2441
+ # accumulator is carried as a `Constant` type (so the block body sees a value-pinned param).
2775
2442
  #
2776
2443
  # @return [Array(Rigor::Type::Constant, nil), Array] `[acc, rest]`
2777
2444
  def inject_constant_start(members, seed, has_seed)
@@ -2786,10 +2453,9 @@ module Rigor
2786
2453
  end
2787
2454
  end
2788
2455
 
2789
- # Evaluates the block body once with the running constant
2790
- # accumulator + the next constant element bound to the block
2791
- # params, returning the result when it is a foldable `Constant`
2792
- # within the magnitude cap, else nil to decline the whole fold.
2456
+ # Evaluates the block body once with the running constant accumulator + the next constant element
2457
+ # bound to the block params, returning the result when it is a foldable `Constant` within the
2458
+ # magnitude cap, else nil to decline the whole fold.
2793
2459
  def inject_constant_step(block, acc, element_value)
2794
2460
  element = Type::Combinator.constant_of(element_value)
2795
2461
  result = type_block_body_with_param(block, [acc, element])
@@ -2811,13 +2477,10 @@ module Rigor
2811
2477
  value.is_a?(Integer) && value.bit_length > INJECT_CONSTANT_BIT_CAP
2812
2478
  end
2813
2479
 
2814
- # Part 1 — the sound nominal accumulator fixpoint. Iterates
2815
- # `acc = join(acc, block(acc, element))` to a capped fixpoint with
2816
- # final `Constant -> Nominal` widening (ADR-55/56 `BodyFixpoint`),
2817
- # seeding `acc` from the seed type (or the element type for the
2818
- # no-seed form) and binding the element-join to the element param.
2819
- # Declines (nil) when the element type is unknown so the RBS tier
2820
- # owns the call.
2480
+ # Part 1 — the sound nominal accumulator fixpoint. Iterates `acc = join(acc, block(acc, element))` to
2481
+ # a capped fixpoint with final `Constant -> Nominal` widening (ADR-55/56 `BodyFixpoint`), seeding `acc`
2482
+ # from the seed type (or the element type for the no-seed form) and binding the element-join to the
2483
+ # element param. Declines (nil) when the element type is unknown so the RBS tier owns the call.
2821
2484
  def try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2822
2485
  element = MethodDispatcher::IteratorDispatch.element_type_of(receiver)
2823
2486
  return nil if element.nil?
@@ -2838,9 +2501,8 @@ module Rigor
2838
2501
  converged[:__inject_acc__] || seed_acc
2839
2502
  end
2840
2503
 
2841
- # `index(value)` and `find_index(value)` carry a positional
2842
- # argument and search by `==` rather than running the block.
2843
- # Decline so the RBS tier owns those forms.
2504
+ # `index(value)` and `find_index(value)` carry a positional argument and search by `==` rather than
2505
+ # running the block. Decline so the RBS tier owns those forms.
2844
2506
  def find_family_with_args?(call_node)
2845
2507
  return false unless %i[find_index index].include?(call_node.name)
2846
2508
 
@@ -2862,19 +2524,14 @@ module Rigor
2862
2524
  end
2863
2525
  end
2864
2526
 
2865
- # `select` / `filter` / `reject`: keeps each receiver
2866
- # element whose per-position predicate result folds to a
2867
- # decisive `Constant` Ruby-truthy for `select` / `filter`,
2868
- # Ruby-falsey for `reject`. The surviving elements assemble
2869
- # into a `Tuple`, strictly tighter than the RBS-projected
2870
- # `Array[Elem]`.
2527
+ # `select` / `filter` / `reject`: keeps each receiver element whose per-position predicate result
2528
+ # folds to a decisive `Constant` — Ruby-truthy for `select` / `filter`, Ruby-falsey for `reject`. The
2529
+ # surviving elements assemble into a `Tuple`, strictly tighter than the RBS-projected `Array[Elem]`.
2871
2530
  #
2872
- # Folds tightly only when EVERY position is a `Constant`:
2873
- # a single non-`Constant` position leaves the result
2874
- # cardinality unknown (the element might or might not
2875
- # survive), so the dispatcher declines and the RBS tier
2876
- # widens to `Array[Elem]`. `[].select` style empty results
2877
- # are sound — an empty `Tuple` is the empty-array carrier.
2531
+ # Folds tightly only when EVERY position is a `Constant`: a single non-`Constant` position leaves the
2532
+ # result cardinality unknown (the element might or might not survive), so the dispatcher declines and
2533
+ # the RBS tier widens to `Array[Elem]`. `[].select` style empty results are sound — an empty `Tuple`
2534
+ # is the empty-array carrier.
2878
2535
  def assemble_filter_result(per_position, element_types, keep_on_truthy:)
2879
2536
  return nil unless per_position.all?(Type::Constant)
2880
2537
 
@@ -2884,12 +2541,9 @@ module Rigor
2884
2541
  Type::Combinator.tuple_of(*kept)
2885
2542
  end
2886
2543
 
2887
- # `filter_map` folds tightly only when every per-position
2888
- # result is a `Constant`: positions whose value is `nil`
2889
- # or `false` drop, the rest survive in declaration order.
2890
- # When any position is non-Constant the dispatcher
2891
- # declines (returns nil) so the RBS tier widens to
2892
- # `Array[U]`.
2544
+ # `filter_map` folds tightly only when every per-position result is a `Constant`: positions whose
2545
+ # value is `nil` or `false` drop, the rest survive in declaration order. When any position is
2546
+ # non-Constant the dispatcher declines (returns nil) so the RBS tier widens to `Array[U]`.
2893
2547
  def assemble_filter_map_result(per_position)
2894
2548
  return nil unless per_position.all?(Type::Constant)
2895
2549
 
@@ -2897,18 +2551,14 @@ module Rigor
2897
2551
  Type::Combinator.tuple_of(*kept)
2898
2552
  end
2899
2553
 
2900
- # `flat_map` flattens a single level: if the per-position
2901
- # result is a `Tuple`, its elements are concatenated; if
2902
- # it's a non-Array scalar carrier (`Constant<…>` over a
2903
- # non-Array literal) it contributes one element. We fold
2904
- # tightly only when every per-position result is one of
2905
- # those two recognisable shapes — `Nominal[Array[T]]`,
2906
- # `Union[…]`, and other opaque carriers decline so the
2907
- # RBS tier widens to `Array[U]`.
2554
+ # `flat_map` flattens a single level: if the per-position result is a `Tuple`, its elements are
2555
+ # concatenated; if it's a non-Array scalar carrier (`Constant<…>` over a non-Array literal) it
2556
+ # contributes one element. We fold tightly only when every per-position result is one of those two
2557
+ # recognisable shapes — `Nominal[Array[T]]`, `Union[…]`, and other opaque carriers decline so the RBS
2558
+ # tier widens to `Array[U]`.
2908
2559
  #
2909
- # `Type::Constant` only ever holds non-Array scalars (the
2910
- # carrier rejects Array literals), so a single `Constant`
2911
- # safely contributes itself as a single Tuple element.
2560
+ # `Type::Constant` only ever holds non-Array scalars (the carrier rejects Array literals), so a single
2561
+ # `Constant` safely contributes itself as a single Tuple element.
2912
2562
  def assemble_flat_map_result(per_position)
2913
2563
  flattened = per_position.flat_map { |type| flat_map_contribution(type) }
2914
2564
  return nil if flattened.nil? || flattened.any?(&:nil?)
@@ -2924,16 +2574,13 @@ module Rigor
2924
2574
  end
2925
2575
  end
2926
2576
 
2927
- # `find` / `detect`: returns the first receiver element
2928
- # whose block result is Ruby-truthy, or `nil` when no
2929
- # position folds to truthy.
2577
+ # `find` / `detect`: returns the first receiver element whose block result is Ruby-truthy, or `nil`
2578
+ # when no position folds to truthy.
2930
2579
  #
2931
- # Folds tightly only when every per-position block result
2932
- # is a `Type::Constant` otherwise we cannot decide which
2933
- # position (if any) is "the first matching one". When the
2934
- # first decisive truthy position is found, the answer is
2935
- # the corresponding receiver element. When every position
2936
- # folds to falsey, the answer is `Constant[nil]`.
2580
+ # Folds tightly only when every per-position block result is a `Type::Constant` — otherwise we cannot
2581
+ # decide which position (if any) is "the first matching one". When the first decisive truthy position
2582
+ # is found, the answer is the corresponding receiver element. When every position folds to falsey,
2583
+ # the answer is `Constant[nil]`.
2937
2584
  def assemble_find_result(per_position, element_types)
2938
2585
  return nil unless per_position.all?(Type::Constant)
2939
2586
 
@@ -2943,8 +2590,8 @@ module Rigor
2943
2590
  element_types[first_truthy_index]
2944
2591
  end
2945
2592
 
2946
- # `find_index` / `index`: returns the index of the first
2947
- # truthy position, or `Constant[nil]` when nothing matches.
2593
+ # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
2594
+ # nothing matches.
2948
2595
  def assemble_find_index_result(per_position)
2949
2596
  return nil unless per_position.all?(Type::Constant)
2950
2597
 
@@ -2958,28 +2605,22 @@ module Rigor
2958
2605
  type.is_a?(Type::Constant) && type.value && type.value != false
2959
2606
  end
2960
2607
 
2961
- # Per-pair block fold for `HashShape#transform_keys` and
2962
- # `HashShape#transform_values` (and their bang variants).
2608
+ # Per-pair block fold for `HashShape#transform_keys` and `HashShape#transform_values` (and their bang
2609
+ # variants).
2963
2610
  #
2964
- # When the receiver is a closed `HashShape` with no optional
2965
- # keys, applies the call's block (a `Prism::BlockNode` or
2966
- # `Prism::BlockArgumentNode`) to each key/value pair
2967
- # independently and assembles a new `HashShape`:
2611
+ # When the receiver is a closed `HashShape` with no optional keys, applies the call's block (a
2612
+ # `Prism::BlockNode` or `Prism::BlockArgumentNode`) to each key/value pair independently and
2613
+ # assembles a new `HashShape`:
2968
2614
  #
2969
- # - `transform_values` / `transform_values!`: re-types
2970
- # each VALUE by binding it to the block parameter; keys
2971
- # are preserved unchanged.
2972
- # - `transform_keys` / `transform_keys!`: re-types each
2973
- # KEY by wrapping it in `Constant[k]` and passing it to
2974
- # the block; values are preserved unchanged. The result
2975
- # key must be a `Constant[Symbol | String]` — otherwise
2976
- # the tier declines (the new key cannot be used as a
2977
- # static HashShape index). Collisions (two old keys
2978
- # mapping to the same new key) also decline.
2615
+ # - `transform_values` / `transform_values!`: re-types each VALUE by binding it to the block
2616
+ # parameter; keys are preserved unchanged.
2617
+ # - `transform_keys` / `transform_keys!`: re-types each KEY by wrapping it in `Constant[k]` and
2618
+ # passing it to the block; values are preserved unchanged. The result key must be a
2619
+ # `Constant[Symbol | String]` otherwise the tier declines (the new key cannot be used as a static
2620
+ # HashShape index). Collisions (two old keys mapping to the same new key) also decline.
2979
2621
  #
2980
- # Returns `nil` on any decline so the dispatcher falls
2981
- # through to `RbsDispatch` and gets the widened `Hash[K, V]`
2982
- # answer.
2622
+ # Returns `nil` on any decline so the dispatcher falls through to `RbsDispatch` and gets the widened
2623
+ # `Hash[K, V]` answer.
2983
2624
  def try_hash_shape_block_fold(call_node, receiver_type)
2984
2625
  return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
2985
2626
  return nil unless receiver_type.is_a?(Type::HashShape)
@@ -3023,9 +2664,8 @@ module Rigor
3023
2664
  Type::Combinator.hash_shape_of(new_pairs)
3024
2665
  end
3025
2666
 
3026
- # Applies a single-argument block (either a full BlockNode
3027
- # or a `&:symbol` BlockArgumentNode) to `param_type` and
3028
- # returns the resulting type, or `nil` on failure.
2667
+ # Applies a single-argument block (either a full BlockNode or a `&:symbol` BlockArgumentNode) to
2668
+ # `param_type` and returns the resulting type, or `nil` on failure.
3029
2669
  def apply_hash_block(block_arg, param_type)
3030
2670
  case block_arg
3031
2671
  when Prism::BlockNode