rigortype 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -4,8 +4,10 @@ require "prism"
4
4
 
5
5
  require_relative "../type"
6
6
  require_relative "../ast"
7
+ require_relative "../source/constant_path"
7
8
  require_relative "../analysis/self_call_resolution_recorder"
8
9
  require_relative "block_parameter_binder"
10
+ require_relative "body_fixpoint"
9
11
  require_relative "budget_trace"
10
12
  require_relative "fallback"
11
13
  require_relative "flow_tracer"
@@ -13,6 +15,7 @@ require_relative "indexed_narrowing"
13
15
  require_relative "macro_block_self_type"
14
16
  require_relative "method_dispatcher"
15
17
  require_relative "narrowing"
18
+ require_relative "struct_fold_safety"
16
19
 
17
20
  module Rigor
18
21
  module Inference
@@ -199,7 +202,11 @@ module Rigor
199
202
  Prism::ForwardingArgumentsNode => :type_of_non_value,
200
203
  Prism::WhileNode => :type_of_loop,
201
204
  Prism::UntilNode => :type_of_loop,
202
- Prism::ForNode => :type_of_dynamic_top,
205
+ # `for` matches `eval_for`'s statement-path policy: the loop
206
+ # expression types `Constant[nil]` (no `break VALUE` observed),
207
+ # same as `while` / `until` — annotating a `for`'s `end` line as
208
+ # `Dynamic[top]` was a display artifact of the old mapping.
209
+ Prism::ForNode => :type_of_loop,
203
210
  Prism::DefinedNode => :type_of_defined,
204
211
  Prism::NumberedReferenceReadNode => :type_of_numbered_reference,
205
212
  Prism::BackReferenceReadNode => :type_of_back_reference,
@@ -318,12 +325,7 @@ module Rigor
318
325
  dynamic_top
319
326
  end
320
327
 
321
- # Recognised value-bearing position the Slice 2 engine does not yet
322
- # narrow: self, instance/class/global variable reads, block bodies.
323
- # Slice 3+ refines these in place; for now we acknowledge the node
324
- # class so the coverage scanner stops flagging it without recording
325
- # a fail-soft event for every occurrence.
326
- # Slice A-engine. `Prism::SelfNode` resolves to the scope's
328
+ # `Prism::SelfNode` resolves to the scope's
327
329
  # `self_type` when one has been injected (by
328
330
  # `StatementEvaluator` at class-body and method-body
329
331
  # boundaries) or `Dynamic[Top]` at the top level. Class-body
@@ -407,7 +409,7 @@ module Rigor
407
409
  end
408
410
 
409
411
  def type_of_constant_path(node)
410
- full_name = build_constant_path_name(node)
412
+ full_name = Source::ConstantPath.qualified_name_or_nil(node)
411
413
  return fallback_for(node, family: :prism) if full_name.nil?
412
414
 
413
415
  resolve_constant_name(full_name) || fallback_for(node, family: :prism)
@@ -476,24 +478,6 @@ module Rigor
476
478
  end
477
479
  end
478
480
 
479
- # Builds the dotted-colon name for a `Foo`, `Foo::Bar`, or `::Foo`
480
- # path. Returns nil when an inner segment is not itself a constant
481
- # reference (for example `expr::Foo`), so the caller can fall back.
482
- def build_constant_path_name(node)
483
- case node
484
- when Prism::ConstantReadNode
485
- node.name.to_s
486
- when Prism::ConstantPathNode
487
- parent = node.parent
488
- return node.name.to_s if parent.nil?
489
-
490
- parent_name = build_constant_path_name(parent)
491
- return nil if parent_name.nil?
492
-
493
- "#{parent_name}::#{node.name}"
494
- end
495
- end
496
-
497
481
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}`
498
482
  # when every entry is a static `AssocNode` whose key is a
499
483
  # `SymbolNode` or `StringNode` with a known value (covering the
@@ -708,7 +692,21 @@ module Rigor
708
692
  polarity = constant_value_polarity(left_type)
709
693
  return short_circuit_for(node, left_type, polarity) if polarity
710
694
 
711
- Type::Combinator.union(left_type, type_of(node.right))
695
+ # The left operand only flows through on the edge that short-
696
+ # circuits: `a || b` yields `a` solely when `a` is truthy, so its
697
+ # falsey constituents (`nil` / `false`) can never be the value of
698
+ # the OrNode (they hand off to `b`); `a && b` yields `a` solely
699
+ # when `a` is falsey. Narrow the surviving left edge before the
700
+ # union so `s || full` (with `s : String?`) types `String |
701
+ # <full>` rather than re-admitting the stripped `nil`. Mirrors
702
+ # `StatementEvaluator#eval_and_or`'s `skipped_type`.
703
+ surviving_left =
704
+ if node.is_a?(Prism::AndNode)
705
+ Narrowing.narrow_falsey(left_type)
706
+ else
707
+ Narrowing.narrow_truthy(left_type)
708
+ end
709
+ Type::Combinator.union(surviving_left, type_of(node.right))
712
710
  end
713
711
 
714
712
  def short_circuit_for(node, left_type, polarity)
@@ -818,7 +816,7 @@ module Rigor
818
816
  # Other pattern shapes (Range, Regexp, custom `===`) stay
819
817
  # `:maybe` — the existing union fallback handles them.
820
818
  def case_when_pattern_certainty(subject_type, pattern_node)
821
- class_name = build_constant_path_name(pattern_node)
819
+ class_name = Source::ConstantPath.qualified_name_or_nil(pattern_node)
822
820
  return Narrowing.class_pattern_certainty(subject_type, class_name, environment: scope.environment) if class_name
823
821
 
824
822
  literal = literal_pattern_value(pattern_node)
@@ -906,7 +904,8 @@ module Rigor
906
904
  end
907
905
 
908
906
  # `while` and `until` loops produce nil unless interrupted by
909
- # `break VALUE`, which Slice 3 phase 1 does not yet model.
907
+ # `break VALUE`; the expression value of `break VALUE` is not yet
908
+ # modeled (scope break-path propagation landed in `eval_loop`).
910
909
  # Returning Constant[nil] is safe and matches Ruby semantics for
911
910
  # the common case.
912
911
  def type_of_loop(_node)
@@ -969,11 +968,26 @@ module Rigor
969
968
  Type::Combinator.nominal_of(Regexp)
970
969
  end
971
970
 
971
+ # A range endpoint folds to a static value when it is a literal
972
+ # (`IntegerNode` / `StringNode`) or when its *evaluated* type is a
973
+ # `Constant<v>` carrying a range-able value (Integer / Float /
974
+ # String — matching the literal-path value kinds). The evaluated
975
+ # arm lets `(1..n)` fold to `Constant<Range>` when per-call body
976
+ # inference has pinned `n` to a constant (fact2 chain). A `nil`
977
+ # node is a beginless/endless boundary: keep today's static-nil
978
+ # behaviour (which yields `Constant<Range>` only when the *other*
979
+ # end is also static, preserving today's beginless/endless path).
972
980
  def static_range_endpoint(node)
973
981
  return [true, nil] if node.nil?
974
982
  return [true, node.value] if node.is_a?(Prism::IntegerNode)
975
983
  return [true, node.unescaped] if node.is_a?(Prism::StringNode) && node.respond_to?(:unescaped)
976
984
 
985
+ type = type_of(node)
986
+ if type.is_a?(Type::Constant)
987
+ value = type.value
988
+ return [true, value] if value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(String)
989
+ end
990
+
977
991
  [false, nil]
978
992
  end
979
993
 
@@ -1166,14 +1180,12 @@ module Rigor
1166
1180
  return nil unless local_def
1167
1181
 
1168
1182
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1169
- return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1183
+ return local_inference if local_inference
1170
1184
 
1171
1185
  # The local def matches by name but the inference was
1172
- # disqualified — either the parameter shape is too complex
1173
- # for the first-iteration binder (kwargs / optionals /
1174
- # rest), or ADR-24 slice 1's conservative gate declined
1175
- # the resolved return type inside a class body (see
1176
- # `adoptable_self_call_result?`). `Dynamic[Top]` is the
1186
+ # disqualified — the parameter shape is too complex for the
1187
+ # first-iteration binder (kwargs / optionals / rest), so the
1188
+ # body could not be re-typed. `Dynamic[Top]` is the
1177
1189
  # safest answer: RBS dispatch would be wrong (the method
1178
1190
  # is user-defined and shadows whatever ancestor method the
1179
1191
  # dispatch would find), and `Dynamic[Top]` propagates
@@ -1211,6 +1223,9 @@ module Rigor
1211
1223
  per_element = try_per_element_block_fold(node, receiver)
1212
1224
  return per_element if per_element
1213
1225
 
1226
+ inject_fold = try_block_inject_fold(node, receiver, arg_types)
1227
+ return inject_fold if inject_fold
1228
+
1214
1229
  hash_transform = try_hash_shape_block_fold(node, receiver)
1215
1230
  return hash_transform if hash_transform
1216
1231
 
@@ -1231,11 +1246,17 @@ module Rigor
1231
1246
  # the body with the call's argument types bound and
1232
1247
  # return the body's last-expression type.
1233
1248
  user_inference = try_user_method_inference(receiver, node, arg_types)
1234
- if user_inference
1235
- return user_inference if adoptable_self_call_result?(user_inference)
1236
-
1237
- return dynamic_top
1238
- end
1249
+ return user_inference if user_inference
1250
+
1251
+ # Module-singleton call resolution (ADR-57 follow-up) — when the
1252
+ # receiver is `Singleton[Foo]` (a module/class constant or a
1253
+ # singleton-method `self`) and `Foo` declares a user-side
1254
+ # `def self.x` / `module_function` body, re-type that body
1255
+ # with the call args bound. Sits after the RBS dispatch tier, so
1256
+ # foreign / RBS-known singletons (`Math.sqrt`) keep their catalog
1257
+ # answer; only project-defined singleton methods reach here.
1258
+ singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1259
+ return singleton_inference if singleton_inference
1239
1260
 
1240
1261
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1241
1262
  # no positive rule resolves the call, the result inherits the
@@ -1341,34 +1362,102 @@ module Rigor
1341
1362
  # definitions and the file's top-level defs. Before
1342
1363
  # slice 1 every such call typed `Dynamic[top]`.
1343
1364
  #
1344
- # The adoption of the resolved return type is gated:
1365
+ # The resolved return type is adopted UNCONDITIONALLY — a resolved
1366
+ # user-method call site reads the callee's inferred return, exactly as a
1367
+ # toplevel call has since v0.0.3.
1345
1368
  #
1346
- # - At top-level / inside a DSL block (`scope.self_type`
1347
- # is nil) the result is adopted unchanged this is
1348
- # the pre-slice-1 surface (the v0.0.3 A local-`def`
1349
- # shortcut) and MUST keep working.
1350
- # - Inside a class body / method body (`self_type` set)
1351
- # the result is adopted ONLY when it is `Bot`. A `Bot`
1352
- # return is an always-diverging guard helper; adopting
1353
- # it can only ever enable correct terminating-branch
1354
- # narrowing, never a new `undefined-method` /
1355
- # argument-type false positive. A non-`Bot` resolved
1356
- # return is kept as `Dynamic[top]` (WD3) — adopting
1357
- # precise non-`Bot` returns project-wide awaits the
1358
- # callee-return-inference precision a later slice
1359
- # brings (measured: unconditional adoption regressed
1360
- # `rigor check lib` by 16 diagnostics).
1361
- def adoptable_self_call_result?(type)
1362
- scope.self_type.nil? || type.is_a?(Type::Bot)
1369
+ # ADR-24 WD3 originally gated this: inside a class / method body only a
1370
+ # `Bot` return was adopted, everything else stayed `Dynamic[top]`, because
1371
+ # an early unconditional-adoption experiment regressed `rigor check lib`
1372
+ # by 16 diagnostics. ADR-55 / ADR-56 then chipped the gate open for the
1373
+ # recursive-fixpoint summary and the value-pinned unroll envelope. ADR-57
1374
+ # closed the arc: it re-ran the gate-open experiment per engine generation
1375
+ # and adjudicated every firing as genuine-or-artifact, fixing the
1376
+ # artifacts at their root — the tail-only body evaluator dropping explicit
1377
+ # `return` (slice 1), multi-value returns not contributing a Tuple
1378
+ # (slice 1), escaping block-captured content mutation surviving as a
1379
+ # precise seed both inline and across a method boundary (slices 2/3), two
1380
+ # over-strict self-authored RBS signatures (slice 1), and an over-optional
1381
+ # tuple-slot destructure (slice 3). With the residual all genuine-or-win,
1382
+ # the gate opened permanently on 2026-06-12 (ADR-57 WD2): the gate-open
1383
+ # `rigor check lib` + plugin self-check delta is zero, and the Mastodon /
1384
+ # haml / kramdown corpora show only adjudicated wins (a more precise error
1385
+ # message; FP removals).
1386
+ #
1387
+ # The historical `adoptable_self_call_result?` predicate (its
1388
+ # `self_type.nil?` / `Bot` / fixpoint-summary / unroll special cases) is
1389
+ # now subsumed by unconditional adoption and removed; `try_local_def_
1390
+ # dispatch` / `try_user_method_inference` simply return the inferred
1391
+ # return. `clamp_unroll_result` still backstops an untrustworthy unrolled
1392
+ # value independently of adoption.
1393
+
1394
+ # An extended (value-keyed) guard frame is `[plain_signature,
1395
+ # value_key]` where `plain_signature` is itself the `[receiver,
1396
+ # method]` pair; a plain frame is that pair directly.
1397
+ def extended_frame?(frame)
1398
+ frame.is_a?(Array) && frame.size == 2 && frame.first.is_a?(Array)
1399
+ end
1400
+
1401
+ # The plain `(receiver, method)` signature carried by a guard frame:
1402
+ # the frame itself for a plain frame, or its first element for an
1403
+ # extended (value-keyed) frame.
1404
+ def plain_part(frame)
1405
+ extended_frame?(frame) ? frame.first : frame
1406
+ end
1407
+
1408
+ # True when `type` is a concrete value — a `Type::Constant` or a
1409
+ # `Type::Tuple` whose elements are (recursively) all value-pinned.
1410
+ # ADR-55 slice 1: a value-pinned self-call result is adopted even
1411
+ # inside a class/method body (where WD3 otherwise keeps non-`Bot`
1412
+ # returns as `Dynamic[top]`). A concrete value at a call site is
1413
+ # strictly more precise and can never enable an undefined-method or
1414
+ # argument-type false positive — it is FP-neutral by construction.
1415
+ def fully_value_pinned?(type)
1416
+ case type
1417
+ when Type::Constant then true
1418
+ when Type::Tuple then type.elements.all? { |element| fully_value_pinned?(element) }
1419
+ else false
1420
+ end
1363
1421
  end
1364
1422
 
1365
1423
  def try_user_method_inference(receiver, call_node, arg_types)
1366
1424
  return nil unless receiver.is_a?(Type::Nominal)
1367
1425
 
1368
- def_node = resolve_user_def_through_ancestors(receiver.class_name, call_node.name)
1426
+ def_node, owner = resolve_user_def_with_owner(receiver.class_name, call_node.name)
1369
1427
  return nil if def_node.nil?
1370
1428
 
1371
- infer_user_method_return(def_node, receiver, arg_types)
1429
+ result = infer_user_method_return(def_node, receiver, arg_types)
1430
+ return result if result.nil?
1431
+
1432
+ degrade_if_overridable(result, owner, call_node.name, :instance)
1433
+ rescue StandardError
1434
+ nil
1435
+ end
1436
+
1437
+ # Module-singleton call resolution (ADR-57 follow-up) — resolves
1438
+ # `Foo.<name>` on a `Singleton[Foo]` receiver against `Foo`'s
1439
+ # user-side singleton defs (`def self.x`, `def Foo.x`, a
1440
+ # `class << self` body, or a `module_function` method) and re-types
1441
+ # the body with the call's argument types bound. The body scope's
1442
+ # `self_type` is the SAME `Singleton[Foo]` carrier, so an
1443
+ # implicit-self call inside (`def self.via; helper(x); end`)
1444
+ # re-enters this tier and resolves against the same singleton table
1445
+ # — the symmetric counterpart of the instance-side ancestor walk.
1446
+ #
1447
+ # Resolution is OWN-class only: the singleton-ancestry chain
1448
+ # (`extend`ed modules, inherited class-method dispatch) is not
1449
+ # walked at this slice. A miss degrades to today's `Dynamic[top]`,
1450
+ # never a false resolution (ADR-57 follow-up § module-singleton).
1451
+ def try_singleton_method_inference(receiver, call_node, arg_types)
1452
+ return nil unless receiver.is_a?(Type::Singleton)
1453
+
1454
+ def_node = scope.singleton_def_for(receiver.class_name, call_node.name)
1455
+ return nil if def_node.nil?
1456
+
1457
+ result = infer_user_method_return(def_node, receiver, arg_types)
1458
+ return result if result.nil?
1459
+
1460
+ degrade_if_overridable(result, receiver.class_name, call_node.name, :singleton)
1372
1461
  rescue StandardError
1373
1462
  nil
1374
1463
  end
@@ -1417,15 +1506,27 @@ module Rigor
1417
1506
  end
1418
1507
 
1419
1508
  def resolve_user_def_through_ancestors(class_name, method_name)
1509
+ resolve_user_def_with_owner(class_name, method_name).first
1510
+ end
1511
+
1512
+ # ADR-57 N5 follow-up — resolves the method's def node AND the
1513
+ # ancestor that owns it (the class/module whose own `def` table
1514
+ # holds the body, which may differ from `class_name` when the
1515
+ # method is inherited from a superclass or included module). The
1516
+ # owner is what the overridable-method adoption gate keys on. Both
1517
+ # are cached together (the walk is identical to the def-only path it
1518
+ # replaced) and returned as a `[def_node, owner]` pair; `owner` is
1519
+ # nil exactly when `def_node` is nil.
1520
+ def resolve_user_def_with_owner(class_name, method_name)
1420
1521
  cache = class_graph_buckets[:user_def]
1421
1522
  table = (cache[class_name.to_s] ||= {})
1422
1523
  key = method_name.to_sym
1423
1524
  return table[key] if table.key?(key)
1424
1525
 
1425
- table[key] = compute_user_def_through_ancestors(class_name, method_name)
1526
+ table[key] = compute_user_def_with_owner(class_name, method_name)
1426
1527
  end
1427
1528
 
1428
- def compute_user_def_through_ancestors(class_name, method_name)
1529
+ def compute_user_def_with_owner(class_name, method_name)
1429
1530
  queue = [class_name.to_s]
1430
1531
  seen = {}
1431
1532
  visited = 0
@@ -1437,15 +1538,15 @@ module Rigor
1437
1538
  visited += 1
1438
1539
  if visited > ANCESTOR_WALK_LIMIT
1439
1540
  BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
1440
- return nil
1541
+ return [nil, nil]
1441
1542
  end
1442
1543
 
1443
1544
  found = scope.user_def_for(current, method_name)
1444
- return found if found
1545
+ return [found, current] if found
1445
1546
 
1446
1547
  enqueue_ancestors(current, queue)
1447
1548
  end
1448
- nil
1549
+ [nil, nil]
1449
1550
  end
1450
1551
 
1451
1552
  # Pushes `current`'s direct ancestors onto the BFS queue:
@@ -1496,10 +1597,260 @@ module Rigor
1496
1597
  scope.discovered_includes.key?(name)
1497
1598
  end
1498
1599
 
1600
+ # ADR-57 N5 — overridable-method adoption gate. A self-call resolved
1601
+ # to a project `def` whose owner has a discovered subclass / includer
1602
+ # that REDEFINES the same method (same instance-vs-singleton kind) is
1603
+ # a template-method site: the base body's literal return is the
1604
+ # *default*, not the value every receiver sees, so adopting it as a
1605
+ # flow constant is unsound (rgl `module Graph; def directed?; false`
1606
+ # folds `unless directed?` always-true, ignoring `DirectedAdjacencyGraph`
1607
+ # overriding it to `true` — the entire rgl warning set, per the
1608
+ # 2026-06-13 app/network survey N5 row). On such a hit the precise
1609
+ # return degrades to `Dynamic[top]`, deliberately re-opening a Dynamic
1610
+ # source ONLY for genuinely-overridden methods. A method with no
1611
+ # discovered override folds exactly as before — over-conservatism must
1612
+ # not re-open Dynamic for final methods.
1613
+ #
1614
+ # The gate only inspects a *flow-constant-foldable* result (a
1615
+ # `Constant`, or a `Tuple` of such): only a value-pinned return can
1616
+ # mislead a downstream `if`/`unless`/`case` into an
1617
+ # `always-truthy-condition` fold, which is exactly the unsoundness the
1618
+ # gate exists to remove. A `Nominal` / `Dynamic` / union return cannot
1619
+ # produce a flow constant, so adopting it from an overridden method is
1620
+ # harmless and is left untouched — this keeps the override-relation
1621
+ # walk off the hot path for the overwhelming majority of self-calls
1622
+ # (whose return is not a bare constant).
1623
+ def degrade_if_overridable(result, owner, method_name, kind)
1624
+ return result if owner.nil?
1625
+ return result unless fully_value_pinned?(result)
1626
+ return result unless overridden_in_project?(owner.to_s, method_name, kind)
1627
+
1628
+ dynamic_top
1629
+ end
1630
+
1631
+ OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
1632
+ private_constant :OVERRIDE_GATE_CACHE_KEY
1633
+
1634
+ # Run-scoped memo for {#overridden_in_project?}, keyed (like
1635
+ # `class_graph_buckets`) by the identity of the frozen discovery
1636
+ # trio so a new analysis generation lands in a fresh bucket, then
1637
+ # nested `kind → owner → method_name`. The predicate is a pure
1638
+ # function of those tables. Nesting avoids allocating a composite
1639
+ # cache key on the hot path (the gate runs on every adopted self-call
1640
+ # return), so a steady-state hit is three identity hash reads + two
1641
+ # string/symbol hash reads with zero allocation.
1642
+ def override_gate_buckets
1643
+ store = (Thread.current[OVERRIDE_GATE_CACHE_KEY] ||= {}.compare_by_identity)
1644
+ by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1645
+ by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1646
+ by_super[scope.discovered_includes] ||= { instance: {}, singleton: {} }
1647
+ end
1648
+
1649
+ # True when some discovered project class/module — distinct from
1650
+ # `owner` — redefines `(method_name, kind)` AND is related to `owner`
1651
+ # (a transitive discovered subclass of an owner class, or a
1652
+ # class/module that includes/prepends — extends, for singleton kind —
1653
+ # an owner module). A same-name reopen of `owner` itself is NOT an
1654
+ # override (monkey-patch reopen shares the owner identity). Memoized
1655
+ # per `(owner, method_name, kind)`.
1656
+ def overridden_in_project?(owner, method_name, kind)
1657
+ by_owner = (override_gate_buckets[kind][owner] ||= {})
1658
+ return by_owner[method_name] if by_owner.key?(method_name)
1659
+
1660
+ by_owner[method_name] = compute_overridden_in_project?(owner, method_name, kind)
1661
+ end
1662
+
1663
+ def compute_overridden_in_project?(owner, method_name, kind)
1664
+ redefiners_of(method_name, kind).any? do |candidate|
1665
+ next false if candidate == owner
1666
+
1667
+ related_to_owner?(candidate, owner)
1668
+ end
1669
+ end
1670
+
1671
+ # Every discovered project class/module whose OWN def table redefines
1672
+ # `(method_name, kind)`. Instance kind reads `discovered_def_nodes`,
1673
+ # singleton kind reads `discovered_singleton_def_nodes` — both are
1674
+ # genuine project `def` bodies (not RBS / accessor synthesis), so a
1675
+ # name's presence is a real redefinition. Served from a per-generation
1676
+ # inverted index (`method_name → [owner names]`) built once per def
1677
+ # table, so the lookup is a single hash read rather than a full-table
1678
+ # scan on every `(method_name, kind)` first-miss — the gate runs on
1679
+ # every adopted self-call return, so the full-table `filter_map` it
1680
+ # replaced was the dominant added allocation on a large `lib`.
1681
+ def redefiners_of(method_name, kind)
1682
+ method_definers_index(kind)[method_name] || EMPTY_REDEFINERS
1683
+ end
1684
+
1685
+ EMPTY_REDEFINERS = [].freeze
1686
+ private_constant :EMPTY_REDEFINERS
1687
+
1688
+ METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
1689
+ private_constant :METHOD_DEFINERS_INDEX_KEY
1690
+
1691
+ # Per-generation `method_name (Symbol) → [owner names]` inverted index
1692
+ # over the instance / singleton def tables, memoised by the identity
1693
+ # of the def table it inverts (a new analysis generation lands in a
1694
+ # fresh bucket). The toplevel sentinel is excluded — a toplevel `def`
1695
+ # has no class ancestry and so can never be an override.
1696
+ def method_definers_index(kind)
1697
+ table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
1698
+ store = (Thread.current[METHOD_DEFINERS_INDEX_KEY] ||= {}.compare_by_identity)
1699
+ store[table] ||= build_method_definers_index(table)
1700
+ end
1701
+
1702
+ def build_method_definers_index(table)
1703
+ index = {}
1704
+ table.each do |class_name, methods|
1705
+ next if class_name == Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
1706
+
1707
+ methods.each_key { |method_name| (index[method_name] ||= []) << class_name }
1708
+ end
1709
+ index
1710
+ end
1711
+
1712
+ # True when `candidate`'s transitive ancestor chain (superclasses +
1713
+ # included/prepended modules) reaches `owner` — i.e. `candidate` is a
1714
+ # subclass of an owner class or an includer of an owner module. Reuses
1715
+ # the same BFS resolver the method-resolution ancestor walk uses, so
1716
+ # name resolution (lexical nesting, RBS-known-ancestor pruning) is
1717
+ # identical.
1718
+ def related_to_owner?(candidate, owner)
1719
+ queue = []
1720
+ enqueue_ancestors(candidate, queue)
1721
+ seen = {}
1722
+ visited = 0
1723
+ until queue.empty?
1724
+ current = queue.shift
1725
+ next if current.nil? || seen[current]
1726
+
1727
+ return true if current == owner
1728
+
1729
+ seen[current] = true
1730
+ visited += 1
1731
+ return false if visited > ANCESTOR_WALK_LIMIT
1732
+
1733
+ enqueue_ancestors(current, queue)
1734
+ end
1735
+ false
1736
+ end
1737
+
1499
1738
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
1500
1739
  private_constant :INFERENCE_GUARD_KEY
1501
1740
 
1502
- def infer_user_method_return(def_node, receiver, arg_types)
1741
+ INFERENCE_UNROLL_FUEL_KEY = :__rigor_user_method_unroll_fuel__
1742
+ private_constant :INFERENCE_UNROLL_FUEL_KEY
1743
+
1744
+ # ADR-55 slice 2 — thread-local fixpoint return-summary table,
1745
+ # keyed by the plain `(receiver, method)` signature (NOT the
1746
+ # value-extended signature: extended frames from slice 1 share the
1747
+ # same summary). Each entry is `{ assumption:, consulted: }` where
1748
+ # `assumption` is the current Kleene iterate (seeded `bot`) and
1749
+ # `consulted` flips true when an in-cycle re-entry returns it.
1750
+ INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
1751
+ private_constant :INFERENCE_SUMMARY_KEY
1752
+
1753
+ # Monotonic per-thread counter, bumped once each time `consult_summary`
1754
+ # actually reads an in-flight fixpoint assumption (ADR-55 slice 2). A
1755
+ # method return computed across an interval in which this counter does
1756
+ # NOT move depended on no transient Kleene iterate, so it is FINAL and
1757
+ # safe to memoise — even when the `summaries` table is non-empty because
1758
+ # some unrelated outermost frame merely *seeded* (but never consulted)
1759
+ # its own entry. See `infer_user_method_return`'s post-hoc memo gate.
1760
+ SUMMARY_CONSULT_COUNTER_KEY = :__rigor_user_method_summary_consults__
1761
+ private_constant :SUMMARY_CONSULT_COUNTER_KEY
1762
+
1763
+ # Per-thread append-only log of the seed depths of every in-flight
1764
+ # summary `consult_summary` read (ADR-55 slice 2 mutual-recursion
1765
+ # soundness fix, 2026-06-12). Each fixpoint owner records the guard
1766
+ # stack size at seed time on its entry (`depth:`); a consult appends
1767
+ # the consulted entry's depth here. A fixpoint whose body evaluation
1768
+ # logged a depth SHALLOWER than its own seed depth read an ancestor
1769
+ # signature's transient Kleene iterate -- cross-signature mutual
1770
+ # recursion (`even?`/`odd?`) -- so its computed return is entangled
1771
+ # with a not-yet-converged foreign assumption and must degrade to
1772
+ # `untyped` rather than fold one branch's seed into a "final"
1773
+ # constant. Own-signature consults log depth == own depth, and a
1774
+ # nested fixpoint that completes within the evaluation logs depths
1775
+ # > own depth; neither is foreign. Cleared with the summary table
1776
+ # when the guard stack drains to empty.
1777
+ SUMMARY_CONSULT_DEPTHS_KEY = :__rigor_user_method_summary_consult_depths__
1778
+ private_constant :SUMMARY_CONSULT_DEPTHS_KEY
1779
+
1780
+ # ADR-57 follow-up — run-scoped memo for resolved user-method
1781
+ # return types. The ADR-57 gate-open made every resolved in-body
1782
+ # self-call adopt the callee's inferred return, which re-types the
1783
+ # callee body once per call site. With a project-wide discovery
1784
+ # index, file N re-types callees defined in files 1..N-1, so
1785
+ # whole-`lib` cost grows superlinearly in files-per-process (the
1786
+ # 2026-06-12 Rails survey's whole-`lib` scaling wall).
1787
+ #
1788
+ # `infer_user_method_return` is a pure function of
1789
+ # `(def_node, receiver, arg_types)` PLUS the frozen project
1790
+ # discovery index: `build_user_method_body_scope` binds the args
1791
+ # to the params in a FRESH `Scope` seeded from an empty fact /
1792
+ # narrowing store and inherits `scope.discovery` whole by
1793
+ # reference — the caller's narrowing state never enters. (This is
1794
+ # what makes a signature-keyed return memo sound where the
1795
+ # ADR-52 WD5 per-call-NODE contribution cache was not: that cache
1796
+ # keyed scope-sensitive results on the node; this memo keys a
1797
+ # scope-INSENSITIVE result on its real inputs.)
1798
+ #
1799
+ # Two dimensions are call-site-varying and so live IN the key:
1800
+ # the receiver carrier (`describe(:short)`) and the argument-type
1801
+ # signature (`describe(:short)` of each arg) — value-pinned args
1802
+ # change folds (`factorial(5)` vs `factorial(6)`), so a coarser
1803
+ # key would serve a stale fold. The third unsafe dimension —
1804
+ # the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1805
+ # assumption / WD1 clamp) producing a TRANSIENT result rather
1806
+ # than a final return — is excluded structurally: the memo is
1807
+ # consulted and populated ONLY when the incoming guard stack is
1808
+ # empty (a genuine top-of-stack entry, whose result is final and
1809
+ # cannot be an in-progress assumption or a clamped value). Frames
1810
+ # entered with a non-empty stack bypass the memo entirely and
1811
+ # compute as before.
1812
+ #
1813
+ # Keyed by the identity of the frozen discovery `def_nodes`
1814
+ # table (a new analysis generation lands in a fresh bucket,
1815
+ # mirroring `class_graph_buckets`) then by the identity of the
1816
+ # `def_node` and the `[receiver, *args]` descriptor tuple.
1817
+ # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1818
+ # lives on `Thread.current`; fork-pool workers are separate
1819
+ # processes, so it never crosses a project boundary.
1820
+ RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
1821
+ private_constant :RETURN_MEMO_KEY
1822
+
1823
+ # Per-inference recursion context threaded through the guard /
1824
+ # fixpoint helpers (ADR-55 slice 2). Bundles the call descriptor
1825
+ # (`receiver`, `arg_types`, `plain_signature`), the thread-local
1826
+ # summary table, and the WD1 clamp flag so the helpers stay within the
1827
+ # parameter-list budget. `def_node` is carried separately (it is the
1828
+ # body owner, not call context).
1829
+ RecursionContext = Data.define(
1830
+ :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded
1831
+ )
1832
+ private_constant :RecursionContext
1833
+
1834
+ # Total body evaluations the fixpoint iteration is permitted per
1835
+ # outermost entry for a signature (ADR-55 WD2). Hard, non-configurable
1836
+ # — the iteration cap is part of the termination story (ADR-41 WD4).
1837
+ RECURSION_FIXPOINT_CAP = 3
1838
+ private_constant :RECURSION_FIXPOINT_CAP
1839
+
1840
+ # Hard, non-configurable caps for the ADR-55 slice 1 constant-arg
1841
+ # unroll. `RECURSION_UNROLL_FUEL` bounds the number of extended
1842
+ # (value-keyed) frames per outermost inference entry;
1843
+ # `RECURSION_VALUE_SIZE_CAP` disqualifies a frame whose pinned
1844
+ # argument values are structurally large. Both are termination
1845
+ # guards (ADR-41 WD4) — not measurement-gated precision budgets —
1846
+ # so they ship default-on with no opt-in.
1847
+ RECURSION_UNROLL_FUEL = 32
1848
+ private_constant :RECURSION_UNROLL_FUEL
1849
+
1850
+ RECURSION_VALUE_SIZE_CAP = 64
1851
+ private_constant :RECURSION_VALUE_SIZE_CAP
1852
+
1853
+ def infer_user_method_return(def_node, receiver, arg_types) # rubocop:disable Metrics/AbcSize
1503
1854
  return nil if def_node.body.nil?
1504
1855
 
1505
1856
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
@@ -1518,19 +1869,463 @@ module Rigor
1518
1869
  # resolving during the main walk. `describe(:short)`
1519
1870
  # keeps non-Nominal receivers (the implicit `Object`
1520
1871
  # carrier for top-level / DSL-block defs) printable.
1521
- signature = [receiver.describe(:short), def_node.name]
1872
+ plain_signature = [receiver.describe(:short), def_node.name]
1522
1873
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1874
+ summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
1875
+
1876
+ # ADR-57 follow-up — return memo. The inferred return is a pure
1877
+ # function of `(def_node, receiver, arg_types)` and the frozen
1878
+ # discovery index whenever the computation does NOT depend on a
1879
+ # transient ADR-55 Kleene assumption (an in-flight fixpoint summary).
1880
+ # Two structural preconditions decide whether THIS frame's result is
1881
+ # even a memo candidate, both stable across the body walk: the
1882
+ # signature must not already be on the recursion guard stack (else we
1883
+ # are inside its own cycle) and no constant-arg unroll may be in
1884
+ # flight (its value-keyed frames are transient). When both hold we
1885
+ # consult the memo, and on a miss we compute, then store the result
1886
+ # only if no fixpoint summary was *consulted* during the computation
1887
+ # (the post-hoc consult-counter check) — which is sound regardless of
1888
+ # whether the `summaries` table holds inert *seeded-but-unconsulted*
1889
+ # entries left by unrelated outermost frames. This is the fix for the
1890
+ # whole-`lib` scaling wall: a deep DAG of non-recursive private
1891
+ # readers (ActiveStorage `video_analyzer.rb`) seeded a summary on its
1892
+ # first outermost method and thereafter the old `summaries.empty?`
1893
+ # gate disabled the memo for every nested call, re-walking the shared
1894
+ # sub-readers combinatorially (~932k body evaluations for ~20 tiny
1895
+ # methods). The computation itself lives in
1896
+ # `compute_user_method_return`.
1897
+ unless memo_candidate?(stack, plain_signature)
1898
+ return compute_user_method_return(def_node, body_scope, stack, summaries,
1899
+ receiver, arg_types, plain_signature)
1900
+ end
1901
+
1902
+ memo = return_memo_bucket
1903
+ memo_key = [def_node.object_id, receiver.describe(:short),
1904
+ arg_types.map { |type| type.describe(:short) }]
1905
+ return memo[memo_key] if memo.key?(memo_key)
1906
+
1907
+ consults_before = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1908
+ result = compute_user_method_return(def_node, body_scope, stack, summaries,
1909
+ receiver, arg_types, plain_signature)
1910
+ consults_after = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1911
+
1912
+ # Store only a FINAL result. If a fixpoint summary was consulted
1913
+ # during the computation, `result` embeds a transient Kleene iterate
1914
+ # whose value depends on the iteration in flight, so it must not be
1915
+ # shared across call sites.
1916
+ memo[memo_key] = result if consults_after == consults_before
1917
+ result
1918
+ end
1919
+
1920
+ # The ADR-55 recursion-guard + value-unroll + fixpoint body of
1921
+ # user-method return inference, factored out so
1922
+ # `infer_user_method_return` is a thin memo wrapper (the memo is
1923
+ # the ADR-57 follow-up; this is unchanged from pre-memo behaviour).
1924
+ def compute_user_method_return(def_node, body_scope, stack, summaries,
1925
+ receiver, arg_types, plain_signature)
1926
+ # ADR-55 slice 1: when every bound argument is value-pinned,
1927
+ # extend the guard key with a stable descriptor of the argument
1928
+ # *values* so distinct constant frames may recurse (e.g.
1929
+ # `factorial(5)` folds to `Constant[120]`). Distinct constant
1930
+ # frames are bounded by `RECURSION_UNROLL_FUEL` per outermost
1931
+ # entry; exhaustion or value blow-up falls back to the plain
1932
+ # `(receiver, method)` guard — today's behaviour. Non-constant
1933
+ # args never reach this path.
1934
+ signature = plain_signature
1935
+ value_key = constant_argument_value_key(arg_types)
1936
+ extended = value_key && unroll_fuel_remaining(stack).positive?
1937
+ signature = [plain_signature, value_key] if extended
1938
+
1523
1939
  if stack.include?(signature)
1524
1940
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
1525
- return Type::Combinator.untyped
1941
+ # ADR-55 slice 2: in-cycle re-entries return the current assumed
1942
+ # summary (Kleene iterate, seeded `bot`) instead of bare
1943
+ # `untyped`. The fixpoint loop below seeds the entry on the
1944
+ # outermost frame; if a re-entry beats it here the entry already
1945
+ # exists. The WD4 composition: slice 1's clamp/fuel fallbacks
1946
+ # also route here when a summary is active.
1947
+ return consult_summary(summaries, plain_signature)
1526
1948
  end
1527
1949
 
1950
+ # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may
1951
+ # only ever surface a fully value-pinned result; any other outcome
1952
+ # must be byte-identical to the plain guard's `untyped`. A frame
1953
+ # that took the extended (value-keyed) path but whose plain
1954
+ # `(receiver, method)` signature is already on the stack — in
1955
+ # plain form or as the plain part of an extended frame — would
1956
+ # have been guarded before slice 1. If such a frame's body folds
1957
+ # to a non-pinned type, the unroll surfaced a precise value the
1958
+ # plain guard would have masked (and the body evaluator's blind
1959
+ # spots can make that value wrong), so clamp it back to `untyped`.
1960
+ would_have_been_guarded =
1961
+ extended &&
1962
+ stack.any? { |frame| plain_part(frame) == plain_signature }
1963
+
1964
+ context = RecursionContext.new(
1965
+ receiver: receiver, arg_types: arg_types, plain_signature: plain_signature,
1966
+ summaries: summaries, would_have_been_guarded: would_have_been_guarded
1967
+ )
1968
+ evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1969
+ end
1970
+
1971
+ # True when this frame's result is a candidate for the return memo:
1972
+ # the structural preconditions, both stable across the body walk, that
1973
+ # are necessary (but not sufficient) for a FINAL result. Sufficiency is
1974
+ # decided post-hoc in `infer_user_method_return` by the consult-counter
1975
+ # check (no transient fixpoint summary was read during the compute) —
1976
+ # so unlike the prior `memoisable_return?` this deliberately does NOT
1977
+ # require an empty `summaries` table: inert seeded-but-unconsulted
1978
+ # entries left by unrelated outermost frames do not contaminate a
1979
+ # result, and gating on them disabled the memo for an entire non-
1980
+ # recursive DAG (the scaling wall). The two preconditions: no constant-
1981
+ # arg unroll in flight (its value-keyed frames are transient) and this
1982
+ # plain signature not itself on the recursion guard stack (else we are
1983
+ # inside its own cycle, returning a Kleene iterate).
1984
+ def memo_candidate?(stack, plain_signature)
1985
+ # Read the unroll fuel WITHOUT the decrement side effect of
1986
+ # `unroll_fuel_remaining`: a constant-arg unroll has begun iff the
1987
+ # thread-local is set and the stack is non-empty (the `ensure` in
1988
+ # `evaluate_guarded_user_method_body` clears it at stack-empty).
1989
+ unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
1990
+ unroll_idle &&
1991
+ stack.none? { |frame| plain_part(frame) == plain_signature }
1992
+ end
1993
+
1994
+ # Run-scoped return-memo bucket for the current discovery
1995
+ # generation. Keyed by the identity of the frozen `def_nodes`
1996
+ # table so a new analysis generation (or any scope that swaps the
1997
+ # index) transparently lands in a fresh bucket. See RETURN_MEMO_KEY.
1998
+ def return_memo_bucket
1999
+ store = (Thread.current[RETURN_MEMO_KEY] ||= {}.compare_by_identity)
2000
+ store[scope.discovered_def_nodes] ||= {}
2001
+ end
2002
+
2003
+ # Pushes the recursion-guard frame, evaluates the body (the outermost
2004
+ # frame for a plain signature runs the ADR-55 slice 2 fixpoint; nested
2005
+ # extended frames evaluate once and let the owner iterate), and on the
2006
+ # way out pops the frame and resets the per-outermost-entry fuel and
2007
+ # summary tables when the guard stack drains to empty.
2008
+ def evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2009
+ # The outermost frame for this plain signature owns the summary
2010
+ # entry and runs the fixpoint loop. ADR-55 WD2.
2011
+ outermost = stack.none? { |frame| plain_part(frame) == context.plain_signature }
1528
2012
  stack.push(signature)
1529
2013
  begin
1530
- type, _post = body_scope.evaluate(def_node.body)
1531
- type
2014
+ if outermost
2015
+ fixpoint_user_method_return(def_node, body_scope, context)
2016
+ else
2017
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
2018
+ clamp_unroll_result(type, context.would_have_been_guarded)
2019
+ end
1532
2020
  ensure
1533
2021
  stack.pop
2022
+ if stack.empty?
2023
+ Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
2024
+ Thread.current[INFERENCE_SUMMARY_KEY] = nil
2025
+ Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
2026
+ end
2027
+ end
2028
+ end
2029
+
2030
+ # Evaluates a method body and joins the value types of every explicit
2031
+ # `return value` reached during the walk with the body's tail type.
2032
+ #
2033
+ # The tail-only evaluator (`statements_type_for` → `type_of(body.last)`)
2034
+ # models only the fall-through value; an early `return false` or a
2035
+ # block-internal `return x` produces `Bot` at its own position and is
2036
+ # otherwise invisible to method-return inference. Without this join a
2037
+ # predicate helper shaped `return false unless cond; ...; true` infers
2038
+ # `Constant[true]` (the early `return false` dropped), which folds
2039
+ # `if helper` to always-truthy. `StatementEvaluator.with_return_sink`
2040
+ # collects the returns (nested `def`/lambda are barriers; block-internal
2041
+ # returns correctly bubble to the enclosing method) so the inferred
2042
+ # return is `tail | return_1 | … | return_n`, matching Ruby semantics.
2043
+ def evaluate_body_with_returns(body_scope, body)
2044
+ (type, post_scope), returns = StatementEvaluator.with_return_sink do
2045
+ body_scope.evaluate(body)
2046
+ end
2047
+ joined = returns.empty? ? type : Type::Combinator.union(type, *returns)
2048
+ [joined, post_scope]
2049
+ end
2050
+
2051
+ # ADR-55 slice 2 — Kleene fixpoint over a recursive method's return
2052
+ # summary. Seeds the assumption to `bot`, evaluates the body, and (only
2053
+ # if the summary was actually consulted during evaluation — i.e. the
2054
+ # method really recursed) iterates: if the computed return is subsumed
2055
+ # by the assumption the fixpoint is reached; otherwise the assumption
2056
+ # joins in the computed return and the body re-evaluates. Capped at
2057
+ # `RECURSION_FIXPOINT_CAP` total evaluations; the final permitted
2058
+ # iteration widens value-pinned constituents to their nominal base to
2059
+ # force convergence, and any residual instability collapses to
2060
+ # `untyped` (today's behaviour).
2061
+ def fixpoint_user_method_return(def_node, body_scope, context, widened: false)
2062
+ plain_signature = context.plain_signature
2063
+ summaries = context.summaries
2064
+ depth = seed_fixpoint_summary(summaries, plain_signature)
2065
+ consult_depths = (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= [])
2066
+ computed = nil
2067
+
2068
+ RECURSION_FIXPOINT_CAP.times do |iteration|
2069
+ summaries[plain_signature][:consulted] = false
2070
+ consult_mark = consult_depths.size
2071
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
2072
+ computed = clamp_unroll_result(type, context.would_have_been_guarded)
2073
+
2074
+ # Cross-signature mutual recursion (ADR-55 soundness fix,
2075
+ # 2026-06-12): the evaluation consulted an ANCESTOR signature's
2076
+ # in-flight summary (seed depth shallower than this frame's), so
2077
+ # `computed` embeds a transient foreign Kleene iterate -- e.g.
2078
+ # `odd?` folding `even?`'s seeded `bot` into `Constant[false]`.
2079
+ # The per-signature iteration below cannot converge such an
2080
+ # entangled pair (each side's iterate is conditioned on the
2081
+ # other's unfinished assumption), so degrade this frame to the
2082
+ # sound `untyped` floor instead of surfacing a one-sided value.
2083
+ if consult_depths[consult_mark..].any? { |d| d < depth }
2084
+ return degrade_entangled_fixpoint(summaries, plain_signature)
2085
+ end
2086
+
2087
+ # The summary was never consulted — the method did not recurse on
2088
+ # this evaluation, so there is no fixpoint to chase. Return the
2089
+ # computed type directly (pre-fixpoint behaviour for non-recursive
2090
+ # bodies that merely share `infer_user_method_return`).
2091
+ return computed unless summaries.dig(plain_signature, :consulted)
2092
+
2093
+ # ADR-55 slice 2 bot-collapse fix (2026-06-11). When the recursive
2094
+ # method's only contribution this evaluation was the seeded `bot`
2095
+ # assumption (so `computed` is `bot` even though the body recursed),
2096
+ # the `joined == assumption` check below would trivially converge at
2097
+ # the seed and return `bot` — UNSOUND for a method with a reachable
2098
+ # non-recursive exit (`passthrough` returns `:done`, `pick` returns
2099
+ # `nil`). `bot` means "never returns", which feeds ADR-47
2100
+ # reachability / always-falsey diagnostics, so it must be reserved
2101
+ # for genuinely diverging methods (`spin`).
2102
+ if computed.is_a?(Type::Bot)
2103
+ resolved = resolve_bot_collapse(def_node, context, widened: widened)
2104
+ return resolved unless resolved.nil?
2105
+ end
2106
+
2107
+ step = fixpoint_step(summaries, plain_signature, computed, iteration)
2108
+ return step unless step == :continue
2109
+ end
2110
+ end
2111
+
2112
+ # Seeds the thread-local summary entry for a fixpoint owner: the `bot`
2113
+ # Kleene seed plus the guard-stack depth at seed time (the frame for
2114
+ # this signature is already pushed), which `consult_summary` logs so
2115
+ # nested fixpoints can detect a foreign in-flight (ancestor) consult.
2116
+ # Returns the seed depth. ADR-55 slice 2.
2117
+ def seed_fixpoint_summary(summaries, plain_signature)
2118
+ depth = (Thread.current[INFERENCE_GUARD_KEY] || []).size
2119
+ summaries[plain_signature] = {
2120
+ assumption: Type::Combinator.bot, consulted: false, depth: depth
2121
+ }
2122
+ depth
2123
+ end
2124
+
2125
+ # Degrades an entangled mutual-recursion fixpoint to the sound
2126
+ # `untyped` floor (ADR-55 mutual-recursion soundness fix, 2026-06-12),
2127
+ # parking `untyped` in the assumption so any consumer that still reads
2128
+ # this signature's summary sees the floor, not the stale `bot` seed.
2129
+ def degrade_entangled_fixpoint(summaries, plain_signature)
2130
+ BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2131
+ summaries[plain_signature][:assumption] = Type::Combinator.untyped
2132
+ Type::Combinator.untyped
2133
+ end
2134
+
2135
+ # One Kleene-iteration step of the fixpoint loop. Joins `computed` into
2136
+ # the running assumption (widening value-pinned constituents on the
2137
+ # final permitted iteration to force convergence) and either returns a
2138
+ # final type — convergence, or the capped `untyped` collapse — or
2139
+ # `:continue` to request another body evaluation, having advanced the
2140
+ # stored assumption. ADR-55 WD2.
2141
+ def fixpoint_step(summaries, plain_signature, computed, iteration)
2142
+ assumption = summaries[plain_signature][:assumption]
2143
+ last_iteration = iteration == RECURSION_FIXPOINT_CAP - 1
2144
+ candidate = last_iteration ? widen_value_pinned(computed) : computed
2145
+ joined = Type::Combinator.union(assumption, candidate)
2146
+
2147
+ # Convergence: the assumption already subsumes the computed return
2148
+ # (joining it back changes nothing).
2149
+ return candidate if joined == assumption
2150
+
2151
+ if last_iteration
2152
+ # Out of iterations and still unstable — collapse to today's
2153
+ # widening behaviour.
2154
+ BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
2155
+ summaries[plain_signature][:assumption] = Type::Combinator.untyped
2156
+ return Type::Combinator.untyped
2157
+ end
2158
+
2159
+ summaries[plain_signature][:assumption] = joined
2160
+ :continue
2161
+ end
2162
+
2163
+ # Rebuilds the user-method body scope with every bound positional
2164
+ # parameter widened to its nominal base (`1 | 2 | 3` → `Integer`,
2165
+ # `Constant[:x]` → `Symbol`). Used by the bot-collapse retry in
2166
+ # `fixpoint_user_method_return`: call-site argument narrowing can prune a
2167
+ # recursive method's base case, and widening restores the declared-type
2168
+ # view under which the base case is reachable. Returns `nil` when the
2169
+ # parameter shape is not inferable (mirrors `build_user_method_body_scope`).
2170
+ def widened_user_method_body_scope(def_node, receiver, arg_types)
2171
+ widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
2172
+ build_user_method_body_scope(def_node, receiver, widened_args)
2173
+ end
2174
+
2175
+ # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a
2176
+ # fixpoint iteration computed `bot` for a recursive body. Two escape
2177
+ # hatches keep `bot` reserved for genuinely diverging methods:
2178
+ #
2179
+ # 1. Re-run the fixpoint ONCE over a parameter-widened body scope
2180
+ # (`1 | 2 | 3` → `Integer`): call-site argument narrowing can prune
2181
+ # a base-case *tail* branch (`n <= 0 ? :done : recurse` with a
2182
+ # positive-only `n`), and widening un-prunes it so the base
2183
+ # constituent (`:done`) surfaces. `passthrough` recovers here.
2184
+ #
2185
+ # 2. If the (possibly widened) body STILL computes `bot` but contains
2186
+ # a reachable explicit `return` — whose value the tail-only body
2187
+ # evaluator never folds into the result (`pick`'s `return nil`) —
2188
+ # fall to the conservative `Dynamic[top]` floor (the pre-slice-2
2189
+ # observable) rather than the unsound `bot`.
2190
+ #
2191
+ # Returns the resolved type, or `nil` to let the caller's normal
2192
+ # fixpoint convergence proceed (genuine divergence — `spin`).
2193
+ def resolve_bot_collapse(def_node, context, widened:)
2194
+ unless widened
2195
+ widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types)
2196
+ return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
2197
+ end
2198
+
2199
+ return Type::Combinator.untyped if body_has_explicit_return?(def_node.body)
2200
+
2201
+ nil
2202
+ end
2203
+
2204
+ # True when `node` contains a reachable explicit `return` statement —
2205
+ # one not nested inside a return barrier (`def` / lambda / block). The
2206
+ # tail-only body evaluator in `infer_user_method_return` never folds an
2207
+ # early-return value into the method result, so a recursive method whose
2208
+ # base case is spelled as `return value` (rather than a tail branch)
2209
+ # looks like it only diverges. This detector is the signal that such a
2210
+ # method has a non-recursive exit, so its bot-collapse must floor to
2211
+ # `Dynamic[top]` rather than `bot` (ADR-55 slice 2, 2026-06-11).
2212
+ RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
2213
+ private_constant :RETURN_BARRIER_NODES
2214
+
2215
+ def body_has_explicit_return?(node)
2216
+ return false unless node.is_a?(Prism::Node)
2217
+ return false if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
2218
+ return true if node.is_a?(Prism::ReturnNode)
2219
+
2220
+ node.compact_child_nodes.any? { |child| body_has_explicit_return?(child) }
2221
+ end
2222
+
2223
+ # Returns the current assumed summary for `plain_signature`, recording
2224
+ # that it was consulted (so the fixpoint owner knows the body actually
2225
+ # recursed). Falls back to `untyped` when no summary is active — e.g. a
2226
+ # nested extended frame guarded before its plain signature seeded an
2227
+ # entry, which is the pre-slice-2 observable.
2228
+ def consult_summary(summaries, plain_signature)
2229
+ entry = summaries[plain_signature]
2230
+ return Type::Combinator.untyped if entry.nil?
2231
+
2232
+ entry[:consulted] = true
2233
+ (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= []) << entry[:depth]
2234
+ entry[:assumption]
2235
+ end
2236
+
2237
+ # ADR-55 WD1 governing-rule clamp. When the just-evaluated frame
2238
+ # took the extended (value-keyed) path but its plain signature was
2239
+ # already guarded (`would_have_been_guarded`), the unroll may only
2240
+ # surface a fully value-pinned result; any other outcome must be
2241
+ # byte-identical to the plain guard's `untyped` (and counts a
2242
+ # `RECURSION_GUARD` hit, matching the pre-slice-1 observable).
2243
+ def clamp_unroll_result(type, would_have_been_guarded)
2244
+ return type unless would_have_been_guarded && !fully_value_pinned?(type)
2245
+
2246
+ BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2247
+ # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
2248
+ # must be byte-identical to the plain guard's `untyped`. This path
2249
+ # deliberately does NOT route to the in-progress fixpoint summary:
2250
+ # the summary is a Kleene lower bound mid-iteration, while the clamp
2251
+ # is a soundness backstop for an untrustworthy unrolled value, so it
2252
+ # must stay the conservative `untyped` upper bound. (WD4's
2253
+ # summary-composition applies to the in-cycle guard and fuel paths,
2254
+ # which DO return the assumed summary — see `consult_summary`.)
2255
+ Type::Combinator.untyped
2256
+ end
2257
+
2258
+ # Widens every value-pinned constituent of `type` to its nominal base
2259
+ # (`Constant[1]` → `Integer`, `Tuple[Constant…]` → its element bases),
2260
+ # leaving non-pinned constituents untouched. Used on the fixpoint's
2261
+ # final permitted iteration (ADR-55 WD2) to force convergence — the
2262
+ # tower of distinct constant iterates collapses to one nominal type.
2263
+ def widen_value_pinned(type)
2264
+ Type::Combinator.widen_value_pinned(type)
2265
+ end
2266
+
2267
+ # Consumes one unit from the thread-local unroll-fuel counter and
2268
+ # returns the units that were available *before* this consumption
2269
+ # (so a positive return means the extended value-key may be used).
2270
+ # Fuel is per-outermost inference entry: at the top level (empty
2271
+ # guard stack) it seeds to `RECURSION_UNROLL_FUEL`, and the
2272
+ # `ensure` in `infer_user_method_return` clears it once the stack
2273
+ # drains back to empty. On exhaustion (return 0) it records a
2274
+ # `RECURSION_UNROLL_FUEL` hit so the caller keeps the plain
2275
+ # `(receiver, method)` signature — today's behaviour.
2276
+ def unroll_fuel_remaining(stack)
2277
+ remaining = Thread.current[INFERENCE_UNROLL_FUEL_KEY]
2278
+ remaining = RECURSION_UNROLL_FUEL if remaining.nil? || stack.empty?
2279
+ if remaining.positive?
2280
+ Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
2281
+ else
2282
+ BudgetTrace.hit(BudgetTrace::RECURSION_UNROLL_FUEL)
2283
+ end
2284
+ remaining
2285
+ end
2286
+
2287
+ # A stable, hashable descriptor of the argument values when EVERY
2288
+ # element of `arg_types` is value-pinned: a `Type::Constant`, or a
2289
+ # `Type::Tuple` whose elements are (recursively) all value-pinned.
2290
+ # Returns nil when any argument is not value-pinned (the ordinary
2291
+ # type-keyed path) or when any pinned value's structural size
2292
+ # exceeds `RECURSION_VALUE_SIZE_CAP` (value blow-up → fall back).
2293
+ def constant_argument_value_key(arg_types)
2294
+ return nil if arg_types.empty?
2295
+
2296
+ keys = []
2297
+ arg_types.each do |arg|
2298
+ descriptor = pinned_value_descriptor(arg)
2299
+ return nil if descriptor.nil?
2300
+
2301
+ keys << descriptor
2302
+ end
2303
+ return nil if keys.sum { |_, size| size } > RECURSION_VALUE_SIZE_CAP
2304
+
2305
+ keys.map(&:first)
2306
+ end
2307
+
2308
+ # Returns `[descriptor, structural_size]` for a value-pinned type,
2309
+ # or nil for anything else. Strings count by a cheap length proxy
2310
+ # (length > 256 ≈ 64+ nodes) so a long built string disqualifies
2311
+ # the frame without a deep walk; tuples recurse.
2312
+ def pinned_value_descriptor(arg)
2313
+ case arg
2314
+ when Type::Constant
2315
+ value = arg.value
2316
+ size = value.is_a?(String) ? (value.length / 4) + 1 : 1
2317
+ [["c", arg.describe(:short)], size]
2318
+ when Type::Tuple
2319
+ parts = []
2320
+ total = 1
2321
+ arg.elements.each do |element|
2322
+ descriptor = pinned_value_descriptor(element)
2323
+ return nil if descriptor.nil?
2324
+
2325
+ parts << descriptor.first
2326
+ total += descriptor.last
2327
+ end
2328
+ [["t", parts], total]
1534
2329
  end
1535
2330
  end
1536
2331
 
@@ -1567,7 +2362,19 @@ module Rigor
1567
2362
  environment: scope.environment,
1568
2363
  locals: locals.freeze,
1569
2364
  self_type: receiver,
1570
- discovery: scope.discovery
2365
+ discovery: scope.discovery,
2366
+ struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body)
2367
+ )
2368
+ end
2369
+
2370
+ # ADR-48 Struct slice 3 — the fold-safe-local set for a method body
2371
+ # (runs only on a return-memo miss, so the per-call cost is bounded —
2372
+ # measured perf-neutral). Struct member layouts of constant receivers
2373
+ # are resolved through the discovery side-table the body scope inherits.
2374
+ def struct_fold_safe_locals_for(body)
2375
+ StructFoldSafety.fold_safe_locals(
2376
+ body,
2377
+ ->(name) { scope.struct_member_layout(name)&.[](:members) }
1571
2378
  )
1572
2379
  end
1573
2380
 
@@ -1825,6 +2632,200 @@ module Rigor
1825
2632
  value.to_a.map { |v| Type::Combinator.constant_of(v) }
1826
2633
  end
1827
2634
 
2635
+ INJECT_METHODS = Set[:inject, :reduce].freeze
2636
+ private_constant :INJECT_METHODS
2637
+
2638
+ # Cap on the element count for the Part 2 constant-threading
2639
+ # fold — mirrors `ReduceFolding::CONSTANT_FOLD_ELEMENT_CAP`. The
2640
+ # size is checked BEFORE enumeration so `(1..1_000_000)` declines
2641
+ # without materialising.
2642
+ INJECT_CONSTANT_ELEMENT_CAP = 64
2643
+ private_constant :INJECT_CONSTANT_ELEMENT_CAP
2644
+
2645
+ # Magnitude cap on a folded Integer accumulator — mirrors
2646
+ # `ReduceFolding`'s bit cap so factorial-style blow-up declines
2647
+ # to the Part 1 nominal result rather than parking a heavy
2648
+ # bignum literal in the type graph.
2649
+ INJECT_CONSTANT_BIT_CAP = 256
2650
+ private_constant :INJECT_CONSTANT_BIT_CAP
2651
+
2652
+ # Block-form `inject` / `reduce` return-type fold.
2653
+ #
2654
+ # Part 1 (soundness): the accumulator of a block-form fold must
2655
+ # reach a fixpoint over an unknown number of iterations — the
2656
+ # RBS tier's generic `(S) { (S, E) -> S } -> S` binds `S` from a
2657
+ # SINGLE block pass (acc=seed, elem=element-join), so
2658
+ # `(1..5).inject(1) { |a, i| a * i }` types `int<1, 5>` while the
2659
+ # runtime is 120 (out of range — unsound). We iterate the
2660
+ # accumulator type to a capped fixpoint (ADR-55/56 `BodyFixpoint`)
2661
+ # so the multiply converges to `Integer`, never a value-bounded
2662
+ # interval the runtime escapes.
2663
+ #
2664
+ # Part 2 (precision): when the receiver is a fully-constant
2665
+ # finite collection (`Constant[Range]` / `Tuple` of `Constant`),
2666
+ # the seed is `Constant` (or the no-seed first element), and the
2667
+ # block body folds to a `Constant` on EVERY iteration with the
2668
+ # running accumulator + element bound, thread the accumulator
2669
+ # through per-element block evaluation and return the final
2670
+ # `Constant` (`(1..5).inject(1) { |a, i| a * i } -> 120`).
2671
+ #
2672
+ # The two are layered: Part 2 is attempted first (a constant
2673
+ # answer is strictly tighter); on any decline it falls through to
2674
+ # the Part 1 sound nominal fixpoint, and on a Part 1 decline to
2675
+ # the RBS tier. Captured-local write-back (ADR-56) runs at the
2676
+ # statement level independent of this return-type computation, so
2677
+ # a block that both accumulates and mutates captured state keeps
2678
+ # its write-back regardless of which arm answers here.
2679
+ #
2680
+ # @return [Rigor::Type, nil]
2681
+ def try_block_inject_fold(call_node, receiver, arg_types)
2682
+ return nil unless INJECT_METHODS.include?(call_node.name)
2683
+
2684
+ block = call_node.block
2685
+ return nil unless block.is_a?(Prism::BlockNode)
2686
+
2687
+ seed, has_seed = inject_seed(arg_types)
2688
+ return nil if arg_types.size > (has_seed ? 1 : 0)
2689
+
2690
+ constant = try_constant_inject_fold(receiver, block, seed, has_seed)
2691
+ return constant if constant
2692
+
2693
+ try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2694
+ end
2695
+
2696
+ # Splits the positional args into the optional seed. A Symbol
2697
+ # final arg (`inject(seed, :*)`) is the no-block Symbol form and
2698
+ # never reaches here (the block guard already failed for it).
2699
+ #
2700
+ # @return [Array(Rigor::Type, nil), Boolean] `[seed, has_seed]`
2701
+ def inject_seed(arg_types)
2702
+ case arg_types.size
2703
+ when 0 then [nil, false]
2704
+ else [arg_types.first, true]
2705
+ end
2706
+ end
2707
+
2708
+ # Part 2 — thread the accumulator through per-element block
2709
+ # evaluation over a fully-constant finite receiver. Declines
2710
+ # (nil) on a non-constant receiver / seed, a size or magnitude
2711
+ # cap, or any per-step result that is not a foldable `Constant`.
2712
+ def try_constant_inject_fold(receiver, block, seed, has_seed)
2713
+ members = inject_constant_members(receiver)
2714
+ return nil if members.nil?
2715
+
2716
+ acc, rest = inject_constant_start(members, seed, has_seed)
2717
+ return nil if acc.nil?
2718
+
2719
+ rest.each do |element_value|
2720
+ acc = inject_constant_step(block, acc, element_value)
2721
+ return nil if acc.nil?
2722
+ end
2723
+ acc
2724
+ end
2725
+
2726
+ # Extracts the receiver's foldable constant values, size-capped
2727
+ # before enumeration, or nil to decline.
2728
+ def inject_constant_members(receiver)
2729
+ case receiver
2730
+ when Type::Constant then inject_constant_range_members(receiver.value)
2731
+ when Type::Tuple then inject_constant_tuple_members(receiver.elements)
2732
+ end
2733
+ end
2734
+
2735
+ def inject_constant_range_members(value)
2736
+ return nil unless value.is_a?(Range)
2737
+
2738
+ first = value.begin
2739
+ last = value.end
2740
+ return nil unless inject_foldable?(first) && inject_foldable?(last)
2741
+
2742
+ size = value.size
2743
+ return nil unless size.is_a?(Integer)
2744
+ return nil if size > INJECT_CONSTANT_ELEMENT_CAP
2745
+
2746
+ value.to_a
2747
+ rescue StandardError
2748
+ nil
2749
+ end
2750
+
2751
+ def inject_constant_tuple_members(elements)
2752
+ return nil if elements.size > INJECT_CONSTANT_ELEMENT_CAP
2753
+ return nil unless elements.all? { |e| e.is_a?(Type::Constant) && inject_foldable?(e.value) }
2754
+
2755
+ elements.map(&:value)
2756
+ end
2757
+
2758
+ # Seeds the constant accumulator: with a seed the memo starts at
2759
+ # the (foldable) seed value and every member is folded; without a
2760
+ # seed the first member seeds the memo and the rest are folded.
2761
+ # The accumulator is carried as a `Constant` type (so the block
2762
+ # body sees a value-pinned param).
2763
+ #
2764
+ # @return [Array(Rigor::Type::Constant, nil), Array] `[acc, rest]`
2765
+ def inject_constant_start(members, seed, has_seed)
2766
+ if has_seed
2767
+ return [nil, []] unless seed.is_a?(Type::Constant) && inject_foldable?(seed.value)
2768
+
2769
+ [seed, members]
2770
+ else
2771
+ return [nil, []] if members.empty?
2772
+
2773
+ [Type::Combinator.constant_of(members.first), members[1..]]
2774
+ end
2775
+ end
2776
+
2777
+ # Evaluates the block body once with the running constant
2778
+ # accumulator + the next constant element bound to the block
2779
+ # params, returning the result when it is a foldable `Constant`
2780
+ # within the magnitude cap, else nil to decline the whole fold.
2781
+ def inject_constant_step(block, acc, element_value)
2782
+ element = Type::Combinator.constant_of(element_value)
2783
+ result = type_block_body_with_param(block, [acc, element])
2784
+ return nil unless result.is_a?(Type::Constant)
2785
+ return nil unless inject_foldable?(result.value)
2786
+ return nil if inject_magnitude_too_large?(result.value)
2787
+
2788
+ result
2789
+ end
2790
+
2791
+ INJECT_FOLDABLE_CLASSES = [Integer, Float, Rational].freeze
2792
+ private_constant :INJECT_FOLDABLE_CLASSES
2793
+
2794
+ def inject_foldable?(value)
2795
+ INJECT_FOLDABLE_CLASSES.any? { |klass| value.is_a?(klass) }
2796
+ end
2797
+
2798
+ def inject_magnitude_too_large?(value)
2799
+ value.is_a?(Integer) && value.bit_length > INJECT_CONSTANT_BIT_CAP
2800
+ end
2801
+
2802
+ # Part 1 — the sound nominal accumulator fixpoint. Iterates
2803
+ # `acc = join(acc, block(acc, element))` to a capped fixpoint with
2804
+ # final `Constant -> Nominal` widening (ADR-55/56 `BodyFixpoint`),
2805
+ # seeding `acc` from the seed type (or the element type for the
2806
+ # no-seed form) and binding the element-join to the element param.
2807
+ # Declines (nil) when the element type is unknown so the RBS tier
2808
+ # owns the call.
2809
+ def try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2810
+ element = MethodDispatcher::IteratorDispatch.element_type_of(receiver)
2811
+ return nil if element.nil?
2812
+
2813
+ seed_acc = has_seed ? seed : element
2814
+ return nil if seed_acc.nil?
2815
+
2816
+ converged = BodyFixpoint.converge(
2817
+ names: [:__inject_acc__],
2818
+ seed_bindings: { __inject_acc__: seed_acc },
2819
+ widen: method(:widen_value_pinned),
2820
+ evaluate_body: lambda do |bindings|
2821
+ acc = bindings[:__inject_acc__]
2822
+ result = type_block_body_with_param(block, [acc, element])
2823
+ result.nil? ? {} : { __inject_acc__: result }
2824
+ end
2825
+ )
2826
+ converged[:__inject_acc__] || seed_acc
2827
+ end
2828
+
1828
2829
  # `index(value)` and `find_index(value)` carry a positional
1829
2830
  # argument and search by `==` rather than running the block.
1830
2831
  # Decline so the RBS tier owns those forms.