rigortype 0.1.18 → 0.1.19

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 (89) 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 +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -6,6 +6,7 @@ require_relative "../type"
6
6
  require_relative "../ast"
7
7
  require_relative "../analysis/self_call_resolution_recorder"
8
8
  require_relative "block_parameter_binder"
9
+ require_relative "body_fixpoint"
9
10
  require_relative "budget_trace"
10
11
  require_relative "fallback"
11
12
  require_relative "flow_tracer"
@@ -199,7 +200,11 @@ module Rigor
199
200
  Prism::ForwardingArgumentsNode => :type_of_non_value,
200
201
  Prism::WhileNode => :type_of_loop,
201
202
  Prism::UntilNode => :type_of_loop,
202
- Prism::ForNode => :type_of_dynamic_top,
203
+ # `for` matches `eval_for`'s statement-path policy: the loop
204
+ # expression types `Constant[nil]` (no `break VALUE` observed),
205
+ # same as `while` / `until` — annotating a `for`'s `end` line as
206
+ # `Dynamic[top]` was a display artifact of the old mapping.
207
+ Prism::ForNode => :type_of_loop,
203
208
  Prism::DefinedNode => :type_of_defined,
204
209
  Prism::NumberedReferenceReadNode => :type_of_numbered_reference,
205
210
  Prism::BackReferenceReadNode => :type_of_back_reference,
@@ -708,7 +713,21 @@ module Rigor
708
713
  polarity = constant_value_polarity(left_type)
709
714
  return short_circuit_for(node, left_type, polarity) if polarity
710
715
 
711
- Type::Combinator.union(left_type, type_of(node.right))
716
+ # The left operand only flows through on the edge that short-
717
+ # circuits: `a || b` yields `a` solely when `a` is truthy, so its
718
+ # falsey constituents (`nil` / `false`) can never be the value of
719
+ # the OrNode (they hand off to `b`); `a && b` yields `a` solely
720
+ # when `a` is falsey. Narrow the surviving left edge before the
721
+ # union so `s || full` (with `s : String?`) types `String |
722
+ # <full>` rather than re-admitting the stripped `nil`. Mirrors
723
+ # `StatementEvaluator#eval_and_or`'s `skipped_type`.
724
+ surviving_left =
725
+ if node.is_a?(Prism::AndNode)
726
+ Narrowing.narrow_falsey(left_type)
727
+ else
728
+ Narrowing.narrow_truthy(left_type)
729
+ end
730
+ Type::Combinator.union(surviving_left, type_of(node.right))
712
731
  end
713
732
 
714
733
  def short_circuit_for(node, left_type, polarity)
@@ -969,11 +988,26 @@ module Rigor
969
988
  Type::Combinator.nominal_of(Regexp)
970
989
  end
971
990
 
991
+ # A range endpoint folds to a static value when it is a literal
992
+ # (`IntegerNode` / `StringNode`) or when its *evaluated* type is a
993
+ # `Constant<v>` carrying a range-able value (Integer / Float /
994
+ # String — matching the literal-path value kinds). The evaluated
995
+ # arm lets `(1..n)` fold to `Constant<Range>` when per-call body
996
+ # inference has pinned `n` to a constant (fact2 chain). A `nil`
997
+ # node is a beginless/endless boundary: keep today's static-nil
998
+ # behaviour (which yields `Constant<Range>` only when the *other*
999
+ # end is also static, preserving today's beginless/endless path).
972
1000
  def static_range_endpoint(node)
973
1001
  return [true, nil] if node.nil?
974
1002
  return [true, node.value] if node.is_a?(Prism::IntegerNode)
975
1003
  return [true, node.unescaped] if node.is_a?(Prism::StringNode) && node.respond_to?(:unescaped)
976
1004
 
1005
+ type = type_of(node)
1006
+ if type.is_a?(Type::Constant)
1007
+ value = type.value
1008
+ return [true, value] if value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(String)
1009
+ end
1010
+
977
1011
  [false, nil]
978
1012
  end
979
1013
 
@@ -1166,14 +1200,12 @@ module Rigor
1166
1200
  return nil unless local_def
1167
1201
 
1168
1202
  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)
1203
+ return local_inference if local_inference
1170
1204
 
1171
1205
  # 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
1206
+ # disqualified — the parameter shape is too complex for the
1207
+ # first-iteration binder (kwargs / optionals / rest), so the
1208
+ # body could not be re-typed. `Dynamic[Top]` is the
1177
1209
  # safest answer: RBS dispatch would be wrong (the method
1178
1210
  # is user-defined and shadows whatever ancestor method the
1179
1211
  # dispatch would find), and `Dynamic[Top]` propagates
@@ -1211,6 +1243,9 @@ module Rigor
1211
1243
  per_element = try_per_element_block_fold(node, receiver)
1212
1244
  return per_element if per_element
1213
1245
 
1246
+ inject_fold = try_block_inject_fold(node, receiver, arg_types)
1247
+ return inject_fold if inject_fold
1248
+
1214
1249
  hash_transform = try_hash_shape_block_fold(node, receiver)
1215
1250
  return hash_transform if hash_transform
1216
1251
 
@@ -1231,11 +1266,17 @@ module Rigor
1231
1266
  # the body with the call's argument types bound and
1232
1267
  # return the body's last-expression type.
1233
1268
  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
1269
+ return user_inference if user_inference
1270
+
1271
+ # Module-singleton call resolution (ADR-57 follow-up) — when the
1272
+ # receiver is `Singleton[Foo]` (a module/class constant or a
1273
+ # singleton-method `self`) and `Foo` declares a user-side
1274
+ # `def self.x` / `module_function` body, re-type that body
1275
+ # with the call args bound. Sits after the RBS dispatch tier, so
1276
+ # foreign / RBS-known singletons (`Math.sqrt`) keep their catalog
1277
+ # answer; only project-defined singleton methods reach here.
1278
+ singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1279
+ return singleton_inference if singleton_inference
1239
1280
 
1240
1281
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1241
1282
  # no positive rule resolves the call, the result inherits the
@@ -1341,34 +1382,102 @@ module Rigor
1341
1382
  # definitions and the file's top-level defs. Before
1342
1383
  # slice 1 every such call typed `Dynamic[top]`.
1343
1384
  #
1344
- # The adoption of the resolved return type is gated:
1385
+ # The resolved return type is adopted UNCONDITIONALLY — a resolved
1386
+ # user-method call site reads the callee's inferred return, exactly as a
1387
+ # toplevel call has since v0.0.3.
1345
1388
  #
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)
1389
+ # ADR-24 WD3 originally gated this: inside a class / method body only a
1390
+ # `Bot` return was adopted, everything else stayed `Dynamic[top]`, because
1391
+ # an early unconditional-adoption experiment regressed `rigor check lib`
1392
+ # by 16 diagnostics. ADR-55 / ADR-56 then chipped the gate open for the
1393
+ # recursive-fixpoint summary and the value-pinned unroll envelope. ADR-57
1394
+ # closed the arc: it re-ran the gate-open experiment per engine generation
1395
+ # and adjudicated every firing as genuine-or-artifact, fixing the
1396
+ # artifacts at their root — the tail-only body evaluator dropping explicit
1397
+ # `return` (slice 1), multi-value returns not contributing a Tuple
1398
+ # (slice 1), escaping block-captured content mutation surviving as a
1399
+ # precise seed both inline and across a method boundary (slices 2/3), two
1400
+ # over-strict self-authored RBS signatures (slice 1), and an over-optional
1401
+ # tuple-slot destructure (slice 3). With the residual all genuine-or-win,
1402
+ # the gate opened permanently on 2026-06-12 (ADR-57 WD2): the gate-open
1403
+ # `rigor check lib` + plugin self-check delta is zero, and the Mastodon /
1404
+ # haml / kramdown corpora show only adjudicated wins (a more precise error
1405
+ # message; FP removals).
1406
+ #
1407
+ # The historical `adoptable_self_call_result?` predicate (its
1408
+ # `self_type.nil?` / `Bot` / fixpoint-summary / unroll special cases) is
1409
+ # now subsumed by unconditional adoption and removed; `try_local_def_
1410
+ # dispatch` / `try_user_method_inference` simply return the inferred
1411
+ # return. `clamp_unroll_result` still backstops an untrustworthy unrolled
1412
+ # value independently of adoption.
1413
+
1414
+ # An extended (value-keyed) guard frame is `[plain_signature,
1415
+ # value_key]` where `plain_signature` is itself the `[receiver,
1416
+ # method]` pair; a plain frame is that pair directly.
1417
+ def extended_frame?(frame)
1418
+ frame.is_a?(Array) && frame.size == 2 && frame.first.is_a?(Array)
1419
+ end
1420
+
1421
+ # The plain `(receiver, method)` signature carried by a guard frame:
1422
+ # the frame itself for a plain frame, or its first element for an
1423
+ # extended (value-keyed) frame.
1424
+ def plain_part(frame)
1425
+ extended_frame?(frame) ? frame.first : frame
1426
+ end
1427
+
1428
+ # True when `type` is a concrete value — a `Type::Constant` or a
1429
+ # `Type::Tuple` whose elements are (recursively) all value-pinned.
1430
+ # ADR-55 slice 1: a value-pinned self-call result is adopted even
1431
+ # inside a class/method body (where WD3 otherwise keeps non-`Bot`
1432
+ # returns as `Dynamic[top]`). A concrete value at a call site is
1433
+ # strictly more precise and can never enable an undefined-method or
1434
+ # argument-type false positive — it is FP-neutral by construction.
1435
+ def fully_value_pinned?(type)
1436
+ case type
1437
+ when Type::Constant then true
1438
+ when Type::Tuple then type.elements.all? { |element| fully_value_pinned?(element) }
1439
+ else false
1440
+ end
1363
1441
  end
1364
1442
 
1365
1443
  def try_user_method_inference(receiver, call_node, arg_types)
1366
1444
  return nil unless receiver.is_a?(Type::Nominal)
1367
1445
 
1368
- def_node = resolve_user_def_through_ancestors(receiver.class_name, call_node.name)
1446
+ def_node, owner = resolve_user_def_with_owner(receiver.class_name, call_node.name)
1369
1447
  return nil if def_node.nil?
1370
1448
 
1371
- infer_user_method_return(def_node, receiver, arg_types)
1449
+ result = infer_user_method_return(def_node, receiver, arg_types)
1450
+ return result if result.nil?
1451
+
1452
+ degrade_if_overridable(result, owner, call_node.name, :instance)
1453
+ rescue StandardError
1454
+ nil
1455
+ end
1456
+
1457
+ # Module-singleton call resolution (ADR-57 follow-up) — resolves
1458
+ # `Foo.<name>` on a `Singleton[Foo]` receiver against `Foo`'s
1459
+ # user-side singleton defs (`def self.x`, `def Foo.x`, a
1460
+ # `class << self` body, or a `module_function` method) and re-types
1461
+ # the body with the call's argument types bound. The body scope's
1462
+ # `self_type` is the SAME `Singleton[Foo]` carrier, so an
1463
+ # implicit-self call inside (`def self.via; helper(x); end`)
1464
+ # re-enters this tier and resolves against the same singleton table
1465
+ # — the symmetric counterpart of the instance-side ancestor walk.
1466
+ #
1467
+ # Resolution is OWN-class only: the singleton-ancestry chain
1468
+ # (`extend`ed modules, inherited class-method dispatch) is not
1469
+ # walked at this slice. A miss degrades to today's `Dynamic[top]`,
1470
+ # never a false resolution (ADR-57 follow-up § module-singleton).
1471
+ def try_singleton_method_inference(receiver, call_node, arg_types)
1472
+ return nil unless receiver.is_a?(Type::Singleton)
1473
+
1474
+ def_node = scope.singleton_def_for(receiver.class_name, call_node.name)
1475
+ return nil if def_node.nil?
1476
+
1477
+ result = infer_user_method_return(def_node, receiver, arg_types)
1478
+ return result if result.nil?
1479
+
1480
+ degrade_if_overridable(result, receiver.class_name, call_node.name, :singleton)
1372
1481
  rescue StandardError
1373
1482
  nil
1374
1483
  end
@@ -1417,15 +1526,27 @@ module Rigor
1417
1526
  end
1418
1527
 
1419
1528
  def resolve_user_def_through_ancestors(class_name, method_name)
1529
+ resolve_user_def_with_owner(class_name, method_name).first
1530
+ end
1531
+
1532
+ # ADR-57 N5 follow-up — resolves the method's def node AND the
1533
+ # ancestor that owns it (the class/module whose own `def` table
1534
+ # holds the body, which may differ from `class_name` when the
1535
+ # method is inherited from a superclass or included module). The
1536
+ # owner is what the overridable-method adoption gate keys on. Both
1537
+ # are cached together (the walk is identical to the def-only path it
1538
+ # replaced) and returned as a `[def_node, owner]` pair; `owner` is
1539
+ # nil exactly when `def_node` is nil.
1540
+ def resolve_user_def_with_owner(class_name, method_name)
1420
1541
  cache = class_graph_buckets[:user_def]
1421
1542
  table = (cache[class_name.to_s] ||= {})
1422
1543
  key = method_name.to_sym
1423
1544
  return table[key] if table.key?(key)
1424
1545
 
1425
- table[key] = compute_user_def_through_ancestors(class_name, method_name)
1546
+ table[key] = compute_user_def_with_owner(class_name, method_name)
1426
1547
  end
1427
1548
 
1428
- def compute_user_def_through_ancestors(class_name, method_name)
1549
+ def compute_user_def_with_owner(class_name, method_name)
1429
1550
  queue = [class_name.to_s]
1430
1551
  seen = {}
1431
1552
  visited = 0
@@ -1437,15 +1558,15 @@ module Rigor
1437
1558
  visited += 1
1438
1559
  if visited > ANCESTOR_WALK_LIMIT
1439
1560
  BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
1440
- return nil
1561
+ return [nil, nil]
1441
1562
  end
1442
1563
 
1443
1564
  found = scope.user_def_for(current, method_name)
1444
- return found if found
1565
+ return [found, current] if found
1445
1566
 
1446
1567
  enqueue_ancestors(current, queue)
1447
1568
  end
1448
- nil
1569
+ [nil, nil]
1449
1570
  end
1450
1571
 
1451
1572
  # Pushes `current`'s direct ancestors onto the BFS queue:
@@ -1496,10 +1617,260 @@ module Rigor
1496
1617
  scope.discovered_includes.key?(name)
1497
1618
  end
1498
1619
 
1620
+ # ADR-57 N5 — overridable-method adoption gate. A self-call resolved
1621
+ # to a project `def` whose owner has a discovered subclass / includer
1622
+ # that REDEFINES the same method (same instance-vs-singleton kind) is
1623
+ # a template-method site: the base body's literal return is the
1624
+ # *default*, not the value every receiver sees, so adopting it as a
1625
+ # flow constant is unsound (rgl `module Graph; def directed?; false`
1626
+ # folds `unless directed?` always-true, ignoring `DirectedAdjacencyGraph`
1627
+ # overriding it to `true` — the entire rgl warning set, per the
1628
+ # 2026-06-13 app/network survey N5 row). On such a hit the precise
1629
+ # return degrades to `Dynamic[top]`, deliberately re-opening a Dynamic
1630
+ # source ONLY for genuinely-overridden methods. A method with no
1631
+ # discovered override folds exactly as before — over-conservatism must
1632
+ # not re-open Dynamic for final methods.
1633
+ #
1634
+ # The gate only inspects a *flow-constant-foldable* result (a
1635
+ # `Constant`, or a `Tuple` of such): only a value-pinned return can
1636
+ # mislead a downstream `if`/`unless`/`case` into an
1637
+ # `always-truthy-condition` fold, which is exactly the unsoundness the
1638
+ # gate exists to remove. A `Nominal` / `Dynamic` / union return cannot
1639
+ # produce a flow constant, so adopting it from an overridden method is
1640
+ # harmless and is left untouched — this keeps the override-relation
1641
+ # walk off the hot path for the overwhelming majority of self-calls
1642
+ # (whose return is not a bare constant).
1643
+ def degrade_if_overridable(result, owner, method_name, kind)
1644
+ return result if owner.nil?
1645
+ return result unless fully_value_pinned?(result)
1646
+ return result unless overridden_in_project?(owner.to_s, method_name, kind)
1647
+
1648
+ dynamic_top
1649
+ end
1650
+
1651
+ OVERRIDE_GATE_CACHE_KEY = :__rigor_overridable_method_gate__
1652
+ private_constant :OVERRIDE_GATE_CACHE_KEY
1653
+
1654
+ # Run-scoped memo for {#overridden_in_project?}, keyed (like
1655
+ # `class_graph_buckets`) by the identity of the frozen discovery
1656
+ # trio so a new analysis generation lands in a fresh bucket, then
1657
+ # nested `kind → owner → method_name`. The predicate is a pure
1658
+ # function of those tables. Nesting avoids allocating a composite
1659
+ # cache key on the hot path (the gate runs on every adopted self-call
1660
+ # return), so a steady-state hit is three identity hash reads + two
1661
+ # string/symbol hash reads with zero allocation.
1662
+ def override_gate_buckets
1663
+ store = (Thread.current[OVERRIDE_GATE_CACHE_KEY] ||= {}.compare_by_identity)
1664
+ by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1665
+ by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1666
+ by_super[scope.discovered_includes] ||= { instance: {}, singleton: {} }
1667
+ end
1668
+
1669
+ # True when some discovered project class/module — distinct from
1670
+ # `owner` — redefines `(method_name, kind)` AND is related to `owner`
1671
+ # (a transitive discovered subclass of an owner class, or a
1672
+ # class/module that includes/prepends — extends, for singleton kind —
1673
+ # an owner module). A same-name reopen of `owner` itself is NOT an
1674
+ # override (monkey-patch reopen shares the owner identity). Memoized
1675
+ # per `(owner, method_name, kind)`.
1676
+ def overridden_in_project?(owner, method_name, kind)
1677
+ by_owner = (override_gate_buckets[kind][owner] ||= {})
1678
+ return by_owner[method_name] if by_owner.key?(method_name)
1679
+
1680
+ by_owner[method_name] = compute_overridden_in_project?(owner, method_name, kind)
1681
+ end
1682
+
1683
+ def compute_overridden_in_project?(owner, method_name, kind)
1684
+ redefiners_of(method_name, kind).any? do |candidate|
1685
+ next false if candidate == owner
1686
+
1687
+ related_to_owner?(candidate, owner)
1688
+ end
1689
+ end
1690
+
1691
+ # Every discovered project class/module whose OWN def table redefines
1692
+ # `(method_name, kind)`. Instance kind reads `discovered_def_nodes`,
1693
+ # singleton kind reads `discovered_singleton_def_nodes` — both are
1694
+ # genuine project `def` bodies (not RBS / accessor synthesis), so a
1695
+ # name's presence is a real redefinition. Served from a per-generation
1696
+ # inverted index (`method_name → [owner names]`) built once per def
1697
+ # table, so the lookup is a single hash read rather than a full-table
1698
+ # scan on every `(method_name, kind)` first-miss — the gate runs on
1699
+ # every adopted self-call return, so the full-table `filter_map` it
1700
+ # replaced was the dominant added allocation on a large `lib`.
1701
+ def redefiners_of(method_name, kind)
1702
+ method_definers_index(kind)[method_name] || EMPTY_REDEFINERS
1703
+ end
1704
+
1705
+ EMPTY_REDEFINERS = [].freeze
1706
+ private_constant :EMPTY_REDEFINERS
1707
+
1708
+ METHOD_DEFINERS_INDEX_KEY = :__rigor_method_definers_index__
1709
+ private_constant :METHOD_DEFINERS_INDEX_KEY
1710
+
1711
+ # Per-generation `method_name (Symbol) → [owner names]` inverted index
1712
+ # over the instance / singleton def tables, memoised by the identity
1713
+ # of the def table it inverts (a new analysis generation lands in a
1714
+ # fresh bucket). The toplevel sentinel is excluded — a toplevel `def`
1715
+ # has no class ancestry and so can never be an override.
1716
+ def method_definers_index(kind)
1717
+ table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
1718
+ store = (Thread.current[METHOD_DEFINERS_INDEX_KEY] ||= {}.compare_by_identity)
1719
+ store[table] ||= build_method_definers_index(table)
1720
+ end
1721
+
1722
+ def build_method_definers_index(table)
1723
+ index = {}
1724
+ table.each do |class_name, methods|
1725
+ next if class_name == Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
1726
+
1727
+ methods.each_key { |method_name| (index[method_name] ||= []) << class_name }
1728
+ end
1729
+ index
1730
+ end
1731
+
1732
+ # True when `candidate`'s transitive ancestor chain (superclasses +
1733
+ # included/prepended modules) reaches `owner` — i.e. `candidate` is a
1734
+ # subclass of an owner class or an includer of an owner module. Reuses
1735
+ # the same BFS resolver the method-resolution ancestor walk uses, so
1736
+ # name resolution (lexical nesting, RBS-known-ancestor pruning) is
1737
+ # identical.
1738
+ def related_to_owner?(candidate, owner)
1739
+ queue = []
1740
+ enqueue_ancestors(candidate, queue)
1741
+ seen = {}
1742
+ visited = 0
1743
+ until queue.empty?
1744
+ current = queue.shift
1745
+ next if current.nil? || seen[current]
1746
+
1747
+ return true if current == owner
1748
+
1749
+ seen[current] = true
1750
+ visited += 1
1751
+ return false if visited > ANCESTOR_WALK_LIMIT
1752
+
1753
+ enqueue_ancestors(current, queue)
1754
+ end
1755
+ false
1756
+ end
1757
+
1499
1758
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
1500
1759
  private_constant :INFERENCE_GUARD_KEY
1501
1760
 
1502
- def infer_user_method_return(def_node, receiver, arg_types)
1761
+ INFERENCE_UNROLL_FUEL_KEY = :__rigor_user_method_unroll_fuel__
1762
+ private_constant :INFERENCE_UNROLL_FUEL_KEY
1763
+
1764
+ # ADR-55 slice 2 — thread-local fixpoint return-summary table,
1765
+ # keyed by the plain `(receiver, method)` signature (NOT the
1766
+ # value-extended signature: extended frames from slice 1 share the
1767
+ # same summary). Each entry is `{ assumption:, consulted: }` where
1768
+ # `assumption` is the current Kleene iterate (seeded `bot`) and
1769
+ # `consulted` flips true when an in-cycle re-entry returns it.
1770
+ INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
1771
+ private_constant :INFERENCE_SUMMARY_KEY
1772
+
1773
+ # Monotonic per-thread counter, bumped once each time `consult_summary`
1774
+ # actually reads an in-flight fixpoint assumption (ADR-55 slice 2). A
1775
+ # method return computed across an interval in which this counter does
1776
+ # NOT move depended on no transient Kleene iterate, so it is FINAL and
1777
+ # safe to memoise — even when the `summaries` table is non-empty because
1778
+ # some unrelated outermost frame merely *seeded* (but never consulted)
1779
+ # its own entry. See `infer_user_method_return`'s post-hoc memo gate.
1780
+ SUMMARY_CONSULT_COUNTER_KEY = :__rigor_user_method_summary_consults__
1781
+ private_constant :SUMMARY_CONSULT_COUNTER_KEY
1782
+
1783
+ # Per-thread append-only log of the seed depths of every in-flight
1784
+ # summary `consult_summary` read (ADR-55 slice 2 mutual-recursion
1785
+ # soundness fix, 2026-06-12). Each fixpoint owner records the guard
1786
+ # stack size at seed time on its entry (`depth:`); a consult appends
1787
+ # the consulted entry's depth here. A fixpoint whose body evaluation
1788
+ # logged a depth SHALLOWER than its own seed depth read an ancestor
1789
+ # signature's transient Kleene iterate -- cross-signature mutual
1790
+ # recursion (`even?`/`odd?`) -- so its computed return is entangled
1791
+ # with a not-yet-converged foreign assumption and must degrade to
1792
+ # `untyped` rather than fold one branch's seed into a "final"
1793
+ # constant. Own-signature consults log depth == own depth, and a
1794
+ # nested fixpoint that completes within the evaluation logs depths
1795
+ # > own depth; neither is foreign. Cleared with the summary table
1796
+ # when the guard stack drains to empty.
1797
+ SUMMARY_CONSULT_DEPTHS_KEY = :__rigor_user_method_summary_consult_depths__
1798
+ private_constant :SUMMARY_CONSULT_DEPTHS_KEY
1799
+
1800
+ # ADR-57 follow-up — run-scoped memo for resolved user-method
1801
+ # return types. The ADR-57 gate-open made every resolved in-body
1802
+ # self-call adopt the callee's inferred return, which re-types the
1803
+ # callee body once per call site. With a project-wide discovery
1804
+ # index, file N re-types callees defined in files 1..N-1, so
1805
+ # whole-`lib` cost grows superlinearly in files-per-process (the
1806
+ # 2026-06-12 Rails survey's whole-`lib` scaling wall).
1807
+ #
1808
+ # `infer_user_method_return` is a pure function of
1809
+ # `(def_node, receiver, arg_types)` PLUS the frozen project
1810
+ # discovery index: `build_user_method_body_scope` binds the args
1811
+ # to the params in a FRESH `Scope` seeded from an empty fact /
1812
+ # narrowing store and inherits `scope.discovery` whole by
1813
+ # reference — the caller's narrowing state never enters. (This is
1814
+ # what makes a signature-keyed return memo sound where the
1815
+ # ADR-52 WD5 per-call-NODE contribution cache was not: that cache
1816
+ # keyed scope-sensitive results on the node; this memo keys a
1817
+ # scope-INSENSITIVE result on its real inputs.)
1818
+ #
1819
+ # Two dimensions are call-site-varying and so live IN the key:
1820
+ # the receiver carrier (`describe(:short)`) and the argument-type
1821
+ # signature (`describe(:short)` of each arg) — value-pinned args
1822
+ # change folds (`factorial(5)` vs `factorial(6)`), so a coarser
1823
+ # key would serve a stale fold. The third unsafe dimension —
1824
+ # the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
1825
+ # assumption / WD1 clamp) producing a TRANSIENT result rather
1826
+ # than a final return — is excluded structurally: the memo is
1827
+ # consulted and populated ONLY when the incoming guard stack is
1828
+ # empty (a genuine top-of-stack entry, whose result is final and
1829
+ # cannot be an in-progress assumption or a clamped value). Frames
1830
+ # entered with a non-empty stack bypass the memo entirely and
1831
+ # compute as before.
1832
+ #
1833
+ # Keyed by the identity of the frozen discovery `def_nodes`
1834
+ # table (a new analysis generation lands in a fresh bucket,
1835
+ # mirroring `class_graph_buckets`) then by the identity of the
1836
+ # `def_node` and the `[receiver, *args]` descriptor tuple.
1837
+ # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store
1838
+ # lives on `Thread.current`; fork-pool workers are separate
1839
+ # processes, so it never crosses a project boundary.
1840
+ RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
1841
+ private_constant :RETURN_MEMO_KEY
1842
+
1843
+ # Per-inference recursion context threaded through the guard /
1844
+ # fixpoint helpers (ADR-55 slice 2). Bundles the call descriptor
1845
+ # (`receiver`, `arg_types`, `plain_signature`), the thread-local
1846
+ # summary table, and the WD1 clamp flag so the helpers stay within the
1847
+ # parameter-list budget. `def_node` is carried separately (it is the
1848
+ # body owner, not call context).
1849
+ RecursionContext = Data.define(
1850
+ :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded
1851
+ )
1852
+ private_constant :RecursionContext
1853
+
1854
+ # Total body evaluations the fixpoint iteration is permitted per
1855
+ # outermost entry for a signature (ADR-55 WD2). Hard, non-configurable
1856
+ # — the iteration cap is part of the termination story (ADR-41 WD4).
1857
+ RECURSION_FIXPOINT_CAP = 3
1858
+ private_constant :RECURSION_FIXPOINT_CAP
1859
+
1860
+ # Hard, non-configurable caps for the ADR-55 slice 1 constant-arg
1861
+ # unroll. `RECURSION_UNROLL_FUEL` bounds the number of extended
1862
+ # (value-keyed) frames per outermost inference entry;
1863
+ # `RECURSION_VALUE_SIZE_CAP` disqualifies a frame whose pinned
1864
+ # argument values are structurally large. Both are termination
1865
+ # guards (ADR-41 WD4) — not measurement-gated precision budgets —
1866
+ # so they ship default-on with no opt-in.
1867
+ RECURSION_UNROLL_FUEL = 32
1868
+ private_constant :RECURSION_UNROLL_FUEL
1869
+
1870
+ RECURSION_VALUE_SIZE_CAP = 64
1871
+ private_constant :RECURSION_VALUE_SIZE_CAP
1872
+
1873
+ def infer_user_method_return(def_node, receiver, arg_types) # rubocop:disable Metrics/AbcSize
1503
1874
  return nil if def_node.body.nil?
1504
1875
 
1505
1876
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
@@ -1518,19 +1889,463 @@ module Rigor
1518
1889
  # resolving during the main walk. `describe(:short)`
1519
1890
  # keeps non-Nominal receivers (the implicit `Object`
1520
1891
  # carrier for top-level / DSL-block defs) printable.
1521
- signature = [receiver.describe(:short), def_node.name]
1892
+ plain_signature = [receiver.describe(:short), def_node.name]
1522
1893
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1894
+ summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
1895
+
1896
+ # ADR-57 follow-up — return memo. The inferred return is a pure
1897
+ # function of `(def_node, receiver, arg_types)` and the frozen
1898
+ # discovery index whenever the computation does NOT depend on a
1899
+ # transient ADR-55 Kleene assumption (an in-flight fixpoint summary).
1900
+ # Two structural preconditions decide whether THIS frame's result is
1901
+ # even a memo candidate, both stable across the body walk: the
1902
+ # signature must not already be on the recursion guard stack (else we
1903
+ # are inside its own cycle) and no constant-arg unroll may be in
1904
+ # flight (its value-keyed frames are transient). When both hold we
1905
+ # consult the memo, and on a miss we compute, then store the result
1906
+ # only if no fixpoint summary was *consulted* during the computation
1907
+ # (the post-hoc consult-counter check) — which is sound regardless of
1908
+ # whether the `summaries` table holds inert *seeded-but-unconsulted*
1909
+ # entries left by unrelated outermost frames. This is the fix for the
1910
+ # whole-`lib` scaling wall: a deep DAG of non-recursive private
1911
+ # readers (ActiveStorage `video_analyzer.rb`) seeded a summary on its
1912
+ # first outermost method and thereafter the old `summaries.empty?`
1913
+ # gate disabled the memo for every nested call, re-walking the shared
1914
+ # sub-readers combinatorially (~932k body evaluations for ~20 tiny
1915
+ # methods). The computation itself lives in
1916
+ # `compute_user_method_return`.
1917
+ unless memo_candidate?(stack, plain_signature)
1918
+ return compute_user_method_return(def_node, body_scope, stack, summaries,
1919
+ receiver, arg_types, plain_signature)
1920
+ end
1921
+
1922
+ memo = return_memo_bucket
1923
+ memo_key = [def_node.object_id, receiver.describe(:short),
1924
+ arg_types.map { |type| type.describe(:short) }]
1925
+ return memo[memo_key] if memo.key?(memo_key)
1926
+
1927
+ consults_before = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1928
+ result = compute_user_method_return(def_node, body_scope, stack, summaries,
1929
+ receiver, arg_types, plain_signature)
1930
+ consults_after = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
1931
+
1932
+ # Store only a FINAL result. If a fixpoint summary was consulted
1933
+ # during the computation, `result` embeds a transient Kleene iterate
1934
+ # whose value depends on the iteration in flight, so it must not be
1935
+ # shared across call sites.
1936
+ memo[memo_key] = result if consults_after == consults_before
1937
+ result
1938
+ end
1939
+
1940
+ # The ADR-55 recursion-guard + value-unroll + fixpoint body of
1941
+ # user-method return inference, factored out so
1942
+ # `infer_user_method_return` is a thin memo wrapper (the memo is
1943
+ # the ADR-57 follow-up; this is unchanged from pre-memo behaviour).
1944
+ def compute_user_method_return(def_node, body_scope, stack, summaries,
1945
+ receiver, arg_types, plain_signature)
1946
+ # ADR-55 slice 1: when every bound argument is value-pinned,
1947
+ # extend the guard key with a stable descriptor of the argument
1948
+ # *values* so distinct constant frames may recurse (e.g.
1949
+ # `factorial(5)` folds to `Constant[120]`). Distinct constant
1950
+ # frames are bounded by `RECURSION_UNROLL_FUEL` per outermost
1951
+ # entry; exhaustion or value blow-up falls back to the plain
1952
+ # `(receiver, method)` guard — today's behaviour. Non-constant
1953
+ # args never reach this path.
1954
+ signature = plain_signature
1955
+ value_key = constant_argument_value_key(arg_types)
1956
+ extended = value_key && unroll_fuel_remaining(stack).positive?
1957
+ signature = [plain_signature, value_key] if extended
1958
+
1523
1959
  if stack.include?(signature)
1524
1960
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
1525
- return Type::Combinator.untyped
1961
+ # ADR-55 slice 2: in-cycle re-entries return the current assumed
1962
+ # summary (Kleene iterate, seeded `bot`) instead of bare
1963
+ # `untyped`. The fixpoint loop below seeds the entry on the
1964
+ # outermost frame; if a re-entry beats it here the entry already
1965
+ # exists. The WD4 composition: slice 1's clamp/fuel fallbacks
1966
+ # also route here when a summary is active.
1967
+ return consult_summary(summaries, plain_signature)
1526
1968
  end
1527
1969
 
1970
+ # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may
1971
+ # only ever surface a fully value-pinned result; any other outcome
1972
+ # must be byte-identical to the plain guard's `untyped`. A frame
1973
+ # that took the extended (value-keyed) path but whose plain
1974
+ # `(receiver, method)` signature is already on the stack — in
1975
+ # plain form or as the plain part of an extended frame — would
1976
+ # have been guarded before slice 1. If such a frame's body folds
1977
+ # to a non-pinned type, the unroll surfaced a precise value the
1978
+ # plain guard would have masked (and the body evaluator's blind
1979
+ # spots can make that value wrong), so clamp it back to `untyped`.
1980
+ would_have_been_guarded =
1981
+ extended &&
1982
+ stack.any? { |frame| plain_part(frame) == plain_signature }
1983
+
1984
+ context = RecursionContext.new(
1985
+ receiver: receiver, arg_types: arg_types, plain_signature: plain_signature,
1986
+ summaries: summaries, would_have_been_guarded: would_have_been_guarded
1987
+ )
1988
+ evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1989
+ end
1990
+
1991
+ # True when this frame's result is a candidate for the return memo:
1992
+ # the structural preconditions, both stable across the body walk, that
1993
+ # are necessary (but not sufficient) for a FINAL result. Sufficiency is
1994
+ # decided post-hoc in `infer_user_method_return` by the consult-counter
1995
+ # check (no transient fixpoint summary was read during the compute) —
1996
+ # so unlike the prior `memoisable_return?` this deliberately does NOT
1997
+ # require an empty `summaries` table: inert seeded-but-unconsulted
1998
+ # entries left by unrelated outermost frames do not contaminate a
1999
+ # result, and gating on them disabled the memo for an entire non-
2000
+ # recursive DAG (the scaling wall). The two preconditions: no constant-
2001
+ # arg unroll in flight (its value-keyed frames are transient) and this
2002
+ # plain signature not itself on the recursion guard stack (else we are
2003
+ # inside its own cycle, returning a Kleene iterate).
2004
+ def memo_candidate?(stack, plain_signature)
2005
+ # Read the unroll fuel WITHOUT the decrement side effect of
2006
+ # `unroll_fuel_remaining`: a constant-arg unroll has begun iff the
2007
+ # thread-local is set and the stack is non-empty (the `ensure` in
2008
+ # `evaluate_guarded_user_method_body` clears it at stack-empty).
2009
+ unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
2010
+ unroll_idle &&
2011
+ stack.none? { |frame| plain_part(frame) == plain_signature }
2012
+ end
2013
+
2014
+ # Run-scoped return-memo bucket for the current discovery
2015
+ # generation. Keyed by the identity of the frozen `def_nodes`
2016
+ # table so a new analysis generation (or any scope that swaps the
2017
+ # index) transparently lands in a fresh bucket. See RETURN_MEMO_KEY.
2018
+ def return_memo_bucket
2019
+ store = (Thread.current[RETURN_MEMO_KEY] ||= {}.compare_by_identity)
2020
+ store[scope.discovered_def_nodes] ||= {}
2021
+ end
2022
+
2023
+ # Pushes the recursion-guard frame, evaluates the body (the outermost
2024
+ # frame for a plain signature runs the ADR-55 slice 2 fixpoint; nested
2025
+ # extended frames evaluate once and let the owner iterate), and on the
2026
+ # way out pops the frame and resets the per-outermost-entry fuel and
2027
+ # summary tables when the guard stack drains to empty.
2028
+ def evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
2029
+ # The outermost frame for this plain signature owns the summary
2030
+ # entry and runs the fixpoint loop. ADR-55 WD2.
2031
+ outermost = stack.none? { |frame| plain_part(frame) == context.plain_signature }
1528
2032
  stack.push(signature)
1529
2033
  begin
1530
- type, _post = body_scope.evaluate(def_node.body)
1531
- type
2034
+ if outermost
2035
+ fixpoint_user_method_return(def_node, body_scope, context)
2036
+ else
2037
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
2038
+ clamp_unroll_result(type, context.would_have_been_guarded)
2039
+ end
1532
2040
  ensure
1533
2041
  stack.pop
2042
+ if stack.empty?
2043
+ Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
2044
+ Thread.current[INFERENCE_SUMMARY_KEY] = nil
2045
+ Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
2046
+ end
2047
+ end
2048
+ end
2049
+
2050
+ # Evaluates a method body and joins the value types of every explicit
2051
+ # `return value` reached during the walk with the body's tail type.
2052
+ #
2053
+ # The tail-only evaluator (`statements_type_for` → `type_of(body.last)`)
2054
+ # models only the fall-through value; an early `return false` or a
2055
+ # block-internal `return x` produces `Bot` at its own position and is
2056
+ # otherwise invisible to method-return inference. Without this join a
2057
+ # predicate helper shaped `return false unless cond; ...; true` infers
2058
+ # `Constant[true]` (the early `return false` dropped), which folds
2059
+ # `if helper` to always-truthy. `StatementEvaluator.with_return_sink`
2060
+ # collects the returns (nested `def`/lambda are barriers; block-internal
2061
+ # returns correctly bubble to the enclosing method) so the inferred
2062
+ # return is `tail | return_1 | … | return_n`, matching Ruby semantics.
2063
+ def evaluate_body_with_returns(body_scope, body)
2064
+ (type, post_scope), returns = StatementEvaluator.with_return_sink do
2065
+ body_scope.evaluate(body)
2066
+ end
2067
+ joined = returns.empty? ? type : Type::Combinator.union(type, *returns)
2068
+ [joined, post_scope]
2069
+ end
2070
+
2071
+ # ADR-55 slice 2 — Kleene fixpoint over a recursive method's return
2072
+ # summary. Seeds the assumption to `bot`, evaluates the body, and (only
2073
+ # if the summary was actually consulted during evaluation — i.e. the
2074
+ # method really recursed) iterates: if the computed return is subsumed
2075
+ # by the assumption the fixpoint is reached; otherwise the assumption
2076
+ # joins in the computed return and the body re-evaluates. Capped at
2077
+ # `RECURSION_FIXPOINT_CAP` total evaluations; the final permitted
2078
+ # iteration widens value-pinned constituents to their nominal base to
2079
+ # force convergence, and any residual instability collapses to
2080
+ # `untyped` (today's behaviour).
2081
+ def fixpoint_user_method_return(def_node, body_scope, context, widened: false)
2082
+ plain_signature = context.plain_signature
2083
+ summaries = context.summaries
2084
+ depth = seed_fixpoint_summary(summaries, plain_signature)
2085
+ consult_depths = (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= [])
2086
+ computed = nil
2087
+
2088
+ RECURSION_FIXPOINT_CAP.times do |iteration|
2089
+ summaries[plain_signature][:consulted] = false
2090
+ consult_mark = consult_depths.size
2091
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
2092
+ computed = clamp_unroll_result(type, context.would_have_been_guarded)
2093
+
2094
+ # Cross-signature mutual recursion (ADR-55 soundness fix,
2095
+ # 2026-06-12): the evaluation consulted an ANCESTOR signature's
2096
+ # in-flight summary (seed depth shallower than this frame's), so
2097
+ # `computed` embeds a transient foreign Kleene iterate -- e.g.
2098
+ # `odd?` folding `even?`'s seeded `bot` into `Constant[false]`.
2099
+ # The per-signature iteration below cannot converge such an
2100
+ # entangled pair (each side's iterate is conditioned on the
2101
+ # other's unfinished assumption), so degrade this frame to the
2102
+ # sound `untyped` floor instead of surfacing a one-sided value.
2103
+ if consult_depths[consult_mark..].any? { |d| d < depth }
2104
+ return degrade_entangled_fixpoint(summaries, plain_signature)
2105
+ end
2106
+
2107
+ # The summary was never consulted — the method did not recurse on
2108
+ # this evaluation, so there is no fixpoint to chase. Return the
2109
+ # computed type directly (pre-fixpoint behaviour for non-recursive
2110
+ # bodies that merely share `infer_user_method_return`).
2111
+ return computed unless summaries.dig(plain_signature, :consulted)
2112
+
2113
+ # ADR-55 slice 2 bot-collapse fix (2026-06-11). When the recursive
2114
+ # method's only contribution this evaluation was the seeded `bot`
2115
+ # assumption (so `computed` is `bot` even though the body recursed),
2116
+ # the `joined == assumption` check below would trivially converge at
2117
+ # the seed and return `bot` — UNSOUND for a method with a reachable
2118
+ # non-recursive exit (`passthrough` returns `:done`, `pick` returns
2119
+ # `nil`). `bot` means "never returns", which feeds ADR-47
2120
+ # reachability / always-falsey diagnostics, so it must be reserved
2121
+ # for genuinely diverging methods (`spin`).
2122
+ if computed.is_a?(Type::Bot)
2123
+ resolved = resolve_bot_collapse(def_node, context, widened: widened)
2124
+ return resolved unless resolved.nil?
2125
+ end
2126
+
2127
+ step = fixpoint_step(summaries, plain_signature, computed, iteration)
2128
+ return step unless step == :continue
2129
+ end
2130
+ end
2131
+
2132
+ # Seeds the thread-local summary entry for a fixpoint owner: the `bot`
2133
+ # Kleene seed plus the guard-stack depth at seed time (the frame for
2134
+ # this signature is already pushed), which `consult_summary` logs so
2135
+ # nested fixpoints can detect a foreign in-flight (ancestor) consult.
2136
+ # Returns the seed depth. ADR-55 slice 2.
2137
+ def seed_fixpoint_summary(summaries, plain_signature)
2138
+ depth = (Thread.current[INFERENCE_GUARD_KEY] || []).size
2139
+ summaries[plain_signature] = {
2140
+ assumption: Type::Combinator.bot, consulted: false, depth: depth
2141
+ }
2142
+ depth
2143
+ end
2144
+
2145
+ # Degrades an entangled mutual-recursion fixpoint to the sound
2146
+ # `untyped` floor (ADR-55 mutual-recursion soundness fix, 2026-06-12),
2147
+ # parking `untyped` in the assumption so any consumer that still reads
2148
+ # this signature's summary sees the floor, not the stale `bot` seed.
2149
+ def degrade_entangled_fixpoint(summaries, plain_signature)
2150
+ BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2151
+ summaries[plain_signature][:assumption] = Type::Combinator.untyped
2152
+ Type::Combinator.untyped
2153
+ end
2154
+
2155
+ # One Kleene-iteration step of the fixpoint loop. Joins `computed` into
2156
+ # the running assumption (widening value-pinned constituents on the
2157
+ # final permitted iteration to force convergence) and either returns a
2158
+ # final type — convergence, or the capped `untyped` collapse — or
2159
+ # `:continue` to request another body evaluation, having advanced the
2160
+ # stored assumption. ADR-55 WD2.
2161
+ def fixpoint_step(summaries, plain_signature, computed, iteration)
2162
+ assumption = summaries[plain_signature][:assumption]
2163
+ last_iteration = iteration == RECURSION_FIXPOINT_CAP - 1
2164
+ candidate = last_iteration ? widen_value_pinned(computed) : computed
2165
+ joined = Type::Combinator.union(assumption, candidate)
2166
+
2167
+ # Convergence: the assumption already subsumes the computed return
2168
+ # (joining it back changes nothing).
2169
+ return candidate if joined == assumption
2170
+
2171
+ if last_iteration
2172
+ # Out of iterations and still unstable — collapse to today's
2173
+ # widening behaviour.
2174
+ BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
2175
+ summaries[plain_signature][:assumption] = Type::Combinator.untyped
2176
+ return Type::Combinator.untyped
2177
+ end
2178
+
2179
+ summaries[plain_signature][:assumption] = joined
2180
+ :continue
2181
+ end
2182
+
2183
+ # Rebuilds the user-method body scope with every bound positional
2184
+ # parameter widened to its nominal base (`1 | 2 | 3` → `Integer`,
2185
+ # `Constant[:x]` → `Symbol`). Used by the bot-collapse retry in
2186
+ # `fixpoint_user_method_return`: call-site argument narrowing can prune a
2187
+ # recursive method's base case, and widening restores the declared-type
2188
+ # view under which the base case is reachable. Returns `nil` when the
2189
+ # parameter shape is not inferable (mirrors `build_user_method_body_scope`).
2190
+ def widened_user_method_body_scope(def_node, receiver, arg_types)
2191
+ widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
2192
+ build_user_method_body_scope(def_node, receiver, widened_args)
2193
+ end
2194
+
2195
+ # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a
2196
+ # fixpoint iteration computed `bot` for a recursive body. Two escape
2197
+ # hatches keep `bot` reserved for genuinely diverging methods:
2198
+ #
2199
+ # 1. Re-run the fixpoint ONCE over a parameter-widened body scope
2200
+ # (`1 | 2 | 3` → `Integer`): call-site argument narrowing can prune
2201
+ # a base-case *tail* branch (`n <= 0 ? :done : recurse` with a
2202
+ # positive-only `n`), and widening un-prunes it so the base
2203
+ # constituent (`:done`) surfaces. `passthrough` recovers here.
2204
+ #
2205
+ # 2. If the (possibly widened) body STILL computes `bot` but contains
2206
+ # a reachable explicit `return` — whose value the tail-only body
2207
+ # evaluator never folds into the result (`pick`'s `return nil`) —
2208
+ # fall to the conservative `Dynamic[top]` floor (the pre-slice-2
2209
+ # observable) rather than the unsound `bot`.
2210
+ #
2211
+ # Returns the resolved type, or `nil` to let the caller's normal
2212
+ # fixpoint convergence proceed (genuine divergence — `spin`).
2213
+ def resolve_bot_collapse(def_node, context, widened:)
2214
+ unless widened
2215
+ widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types)
2216
+ return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
2217
+ end
2218
+
2219
+ return Type::Combinator.untyped if body_has_explicit_return?(def_node.body)
2220
+
2221
+ nil
2222
+ end
2223
+
2224
+ # True when `node` contains a reachable explicit `return` statement —
2225
+ # one not nested inside a return barrier (`def` / lambda / block). The
2226
+ # tail-only body evaluator in `infer_user_method_return` never folds an
2227
+ # early-return value into the method result, so a recursive method whose
2228
+ # base case is spelled as `return value` (rather than a tail branch)
2229
+ # looks like it only diverges. This detector is the signal that such a
2230
+ # method has a non-recursive exit, so its bot-collapse must floor to
2231
+ # `Dynamic[top]` rather than `bot` (ADR-55 slice 2, 2026-06-11).
2232
+ RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
2233
+ private_constant :RETURN_BARRIER_NODES
2234
+
2235
+ def body_has_explicit_return?(node)
2236
+ return false unless node.is_a?(Prism::Node)
2237
+ return false if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
2238
+ return true if node.is_a?(Prism::ReturnNode)
2239
+
2240
+ node.compact_child_nodes.any? { |child| body_has_explicit_return?(child) }
2241
+ end
2242
+
2243
+ # Returns the current assumed summary for `plain_signature`, recording
2244
+ # that it was consulted (so the fixpoint owner knows the body actually
2245
+ # recursed). Falls back to `untyped` when no summary is active — e.g. a
2246
+ # nested extended frame guarded before its plain signature seeded an
2247
+ # entry, which is the pre-slice-2 observable.
2248
+ def consult_summary(summaries, plain_signature)
2249
+ entry = summaries[plain_signature]
2250
+ return Type::Combinator.untyped if entry.nil?
2251
+
2252
+ entry[:consulted] = true
2253
+ (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= []) << entry[:depth]
2254
+ entry[:assumption]
2255
+ end
2256
+
2257
+ # ADR-55 WD1 governing-rule clamp. When the just-evaluated frame
2258
+ # took the extended (value-keyed) path but its plain signature was
2259
+ # already guarded (`would_have_been_guarded`), the unroll may only
2260
+ # surface a fully value-pinned result; any other outcome must be
2261
+ # byte-identical to the plain guard's `untyped` (and counts a
2262
+ # `RECURSION_GUARD` hit, matching the pre-slice-1 observable).
2263
+ def clamp_unroll_result(type, would_have_been_guarded)
2264
+ return type unless would_have_been_guarded && !fully_value_pinned?(type)
2265
+
2266
+ BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2267
+ # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
2268
+ # must be byte-identical to the plain guard's `untyped`. This path
2269
+ # deliberately does NOT route to the in-progress fixpoint summary:
2270
+ # the summary is a Kleene lower bound mid-iteration, while the clamp
2271
+ # is a soundness backstop for an untrustworthy unrolled value, so it
2272
+ # must stay the conservative `untyped` upper bound. (WD4's
2273
+ # summary-composition applies to the in-cycle guard and fuel paths,
2274
+ # which DO return the assumed summary — see `consult_summary`.)
2275
+ Type::Combinator.untyped
2276
+ end
2277
+
2278
+ # Widens every value-pinned constituent of `type` to its nominal base
2279
+ # (`Constant[1]` → `Integer`, `Tuple[Constant…]` → its element bases),
2280
+ # leaving non-pinned constituents untouched. Used on the fixpoint's
2281
+ # final permitted iteration (ADR-55 WD2) to force convergence — the
2282
+ # tower of distinct constant iterates collapses to one nominal type.
2283
+ def widen_value_pinned(type)
2284
+ Type::Combinator.widen_value_pinned(type)
2285
+ end
2286
+
2287
+ # Consumes one unit from the thread-local unroll-fuel counter and
2288
+ # returns the units that were available *before* this consumption
2289
+ # (so a positive return means the extended value-key may be used).
2290
+ # Fuel is per-outermost inference entry: at the top level (empty
2291
+ # guard stack) it seeds to `RECURSION_UNROLL_FUEL`, and the
2292
+ # `ensure` in `infer_user_method_return` clears it once the stack
2293
+ # drains back to empty. On exhaustion (return 0) it records a
2294
+ # `RECURSION_UNROLL_FUEL` hit so the caller keeps the plain
2295
+ # `(receiver, method)` signature — today's behaviour.
2296
+ def unroll_fuel_remaining(stack)
2297
+ remaining = Thread.current[INFERENCE_UNROLL_FUEL_KEY]
2298
+ remaining = RECURSION_UNROLL_FUEL if remaining.nil? || stack.empty?
2299
+ if remaining.positive?
2300
+ Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
2301
+ else
2302
+ BudgetTrace.hit(BudgetTrace::RECURSION_UNROLL_FUEL)
2303
+ end
2304
+ remaining
2305
+ end
2306
+
2307
+ # A stable, hashable descriptor of the argument values when EVERY
2308
+ # element of `arg_types` is value-pinned: a `Type::Constant`, or a
2309
+ # `Type::Tuple` whose elements are (recursively) all value-pinned.
2310
+ # Returns nil when any argument is not value-pinned (the ordinary
2311
+ # type-keyed path) or when any pinned value's structural size
2312
+ # exceeds `RECURSION_VALUE_SIZE_CAP` (value blow-up → fall back).
2313
+ def constant_argument_value_key(arg_types)
2314
+ return nil if arg_types.empty?
2315
+
2316
+ keys = []
2317
+ arg_types.each do |arg|
2318
+ descriptor = pinned_value_descriptor(arg)
2319
+ return nil if descriptor.nil?
2320
+
2321
+ keys << descriptor
2322
+ end
2323
+ return nil if keys.sum { |_, size| size } > RECURSION_VALUE_SIZE_CAP
2324
+
2325
+ keys.map(&:first)
2326
+ end
2327
+
2328
+ # Returns `[descriptor, structural_size]` for a value-pinned type,
2329
+ # or nil for anything else. Strings count by a cheap length proxy
2330
+ # (length > 256 ≈ 64+ nodes) so a long built string disqualifies
2331
+ # the frame without a deep walk; tuples recurse.
2332
+ def pinned_value_descriptor(arg)
2333
+ case arg
2334
+ when Type::Constant
2335
+ value = arg.value
2336
+ size = value.is_a?(String) ? (value.length / 4) + 1 : 1
2337
+ [["c", arg.describe(:short)], size]
2338
+ when Type::Tuple
2339
+ parts = []
2340
+ total = 1
2341
+ arg.elements.each do |element|
2342
+ descriptor = pinned_value_descriptor(element)
2343
+ return nil if descriptor.nil?
2344
+
2345
+ parts << descriptor.first
2346
+ total += descriptor.last
2347
+ end
2348
+ [["t", parts], total]
1534
2349
  end
1535
2350
  end
1536
2351
 
@@ -1825,6 +2640,200 @@ module Rigor
1825
2640
  value.to_a.map { |v| Type::Combinator.constant_of(v) }
1826
2641
  end
1827
2642
 
2643
+ INJECT_METHODS = Set[:inject, :reduce].freeze
2644
+ private_constant :INJECT_METHODS
2645
+
2646
+ # Cap on the element count for the Part 2 constant-threading
2647
+ # fold — mirrors `ReduceFolding::CONSTANT_FOLD_ELEMENT_CAP`. The
2648
+ # size is checked BEFORE enumeration so `(1..1_000_000)` declines
2649
+ # without materialising.
2650
+ INJECT_CONSTANT_ELEMENT_CAP = 64
2651
+ private_constant :INJECT_CONSTANT_ELEMENT_CAP
2652
+
2653
+ # Magnitude cap on a folded Integer accumulator — mirrors
2654
+ # `ReduceFolding`'s bit cap so factorial-style blow-up declines
2655
+ # to the Part 1 nominal result rather than parking a heavy
2656
+ # bignum literal in the type graph.
2657
+ INJECT_CONSTANT_BIT_CAP = 256
2658
+ private_constant :INJECT_CONSTANT_BIT_CAP
2659
+
2660
+ # Block-form `inject` / `reduce` return-type fold.
2661
+ #
2662
+ # Part 1 (soundness): the accumulator of a block-form fold must
2663
+ # reach a fixpoint over an unknown number of iterations — the
2664
+ # RBS tier's generic `(S) { (S, E) -> S } -> S` binds `S` from a
2665
+ # SINGLE block pass (acc=seed, elem=element-join), so
2666
+ # `(1..5).inject(1) { |a, i| a * i }` types `int<1, 5>` while the
2667
+ # runtime is 120 (out of range — unsound). We iterate the
2668
+ # accumulator type to a capped fixpoint (ADR-55/56 `BodyFixpoint`)
2669
+ # so the multiply converges to `Integer`, never a value-bounded
2670
+ # interval the runtime escapes.
2671
+ #
2672
+ # Part 2 (precision): when the receiver is a fully-constant
2673
+ # finite collection (`Constant[Range]` / `Tuple` of `Constant`),
2674
+ # the seed is `Constant` (or the no-seed first element), and the
2675
+ # block body folds to a `Constant` on EVERY iteration with the
2676
+ # running accumulator + element bound, thread the accumulator
2677
+ # through per-element block evaluation and return the final
2678
+ # `Constant` (`(1..5).inject(1) { |a, i| a * i } -> 120`).
2679
+ #
2680
+ # The two are layered: Part 2 is attempted first (a constant
2681
+ # answer is strictly tighter); on any decline it falls through to
2682
+ # the Part 1 sound nominal fixpoint, and on a Part 1 decline to
2683
+ # the RBS tier. Captured-local write-back (ADR-56) runs at the
2684
+ # statement level independent of this return-type computation, so
2685
+ # a block that both accumulates and mutates captured state keeps
2686
+ # its write-back regardless of which arm answers here.
2687
+ #
2688
+ # @return [Rigor::Type, nil]
2689
+ def try_block_inject_fold(call_node, receiver, arg_types)
2690
+ return nil unless INJECT_METHODS.include?(call_node.name)
2691
+
2692
+ block = call_node.block
2693
+ return nil unless block.is_a?(Prism::BlockNode)
2694
+
2695
+ seed, has_seed = inject_seed(arg_types)
2696
+ return nil if arg_types.size > (has_seed ? 1 : 0)
2697
+
2698
+ constant = try_constant_inject_fold(receiver, block, seed, has_seed)
2699
+ return constant if constant
2700
+
2701
+ try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2702
+ end
2703
+
2704
+ # Splits the positional args into the optional seed. A Symbol
2705
+ # final arg (`inject(seed, :*)`) is the no-block Symbol form and
2706
+ # never reaches here (the block guard already failed for it).
2707
+ #
2708
+ # @return [Array(Rigor::Type, nil), Boolean] `[seed, has_seed]`
2709
+ def inject_seed(arg_types)
2710
+ case arg_types.size
2711
+ when 0 then [nil, false]
2712
+ else [arg_types.first, true]
2713
+ end
2714
+ end
2715
+
2716
+ # Part 2 — thread the accumulator through per-element block
2717
+ # evaluation over a fully-constant finite receiver. Declines
2718
+ # (nil) on a non-constant receiver / seed, a size or magnitude
2719
+ # cap, or any per-step result that is not a foldable `Constant`.
2720
+ def try_constant_inject_fold(receiver, block, seed, has_seed)
2721
+ members = inject_constant_members(receiver)
2722
+ return nil if members.nil?
2723
+
2724
+ acc, rest = inject_constant_start(members, seed, has_seed)
2725
+ return nil if acc.nil?
2726
+
2727
+ rest.each do |element_value|
2728
+ acc = inject_constant_step(block, acc, element_value)
2729
+ return nil if acc.nil?
2730
+ end
2731
+ acc
2732
+ end
2733
+
2734
+ # Extracts the receiver's foldable constant values, size-capped
2735
+ # before enumeration, or nil to decline.
2736
+ def inject_constant_members(receiver)
2737
+ case receiver
2738
+ when Type::Constant then inject_constant_range_members(receiver.value)
2739
+ when Type::Tuple then inject_constant_tuple_members(receiver.elements)
2740
+ end
2741
+ end
2742
+
2743
+ def inject_constant_range_members(value)
2744
+ return nil unless value.is_a?(Range)
2745
+
2746
+ first = value.begin
2747
+ last = value.end
2748
+ return nil unless inject_foldable?(first) && inject_foldable?(last)
2749
+
2750
+ size = value.size
2751
+ return nil unless size.is_a?(Integer)
2752
+ return nil if size > INJECT_CONSTANT_ELEMENT_CAP
2753
+
2754
+ value.to_a
2755
+ rescue StandardError
2756
+ nil
2757
+ end
2758
+
2759
+ def inject_constant_tuple_members(elements)
2760
+ return nil if elements.size > INJECT_CONSTANT_ELEMENT_CAP
2761
+ return nil unless elements.all? { |e| e.is_a?(Type::Constant) && inject_foldable?(e.value) }
2762
+
2763
+ elements.map(&:value)
2764
+ end
2765
+
2766
+ # Seeds the constant accumulator: with a seed the memo starts at
2767
+ # the (foldable) seed value and every member is folded; without a
2768
+ # seed the first member seeds the memo and the rest are folded.
2769
+ # The accumulator is carried as a `Constant` type (so the block
2770
+ # body sees a value-pinned param).
2771
+ #
2772
+ # @return [Array(Rigor::Type::Constant, nil), Array] `[acc, rest]`
2773
+ def inject_constant_start(members, seed, has_seed)
2774
+ if has_seed
2775
+ return [nil, []] unless seed.is_a?(Type::Constant) && inject_foldable?(seed.value)
2776
+
2777
+ [seed, members]
2778
+ else
2779
+ return [nil, []] if members.empty?
2780
+
2781
+ [Type::Combinator.constant_of(members.first), members[1..]]
2782
+ end
2783
+ end
2784
+
2785
+ # Evaluates the block body once with the running constant
2786
+ # accumulator + the next constant element bound to the block
2787
+ # params, returning the result when it is a foldable `Constant`
2788
+ # within the magnitude cap, else nil to decline the whole fold.
2789
+ def inject_constant_step(block, acc, element_value)
2790
+ element = Type::Combinator.constant_of(element_value)
2791
+ result = type_block_body_with_param(block, [acc, element])
2792
+ return nil unless result.is_a?(Type::Constant)
2793
+ return nil unless inject_foldable?(result.value)
2794
+ return nil if inject_magnitude_too_large?(result.value)
2795
+
2796
+ result
2797
+ end
2798
+
2799
+ INJECT_FOLDABLE_CLASSES = [Integer, Float, Rational].freeze
2800
+ private_constant :INJECT_FOLDABLE_CLASSES
2801
+
2802
+ def inject_foldable?(value)
2803
+ INJECT_FOLDABLE_CLASSES.any? { |klass| value.is_a?(klass) }
2804
+ end
2805
+
2806
+ def inject_magnitude_too_large?(value)
2807
+ value.is_a?(Integer) && value.bit_length > INJECT_CONSTANT_BIT_CAP
2808
+ end
2809
+
2810
+ # Part 1 — the sound nominal accumulator fixpoint. Iterates
2811
+ # `acc = join(acc, block(acc, element))` to a capped fixpoint with
2812
+ # final `Constant -> Nominal` widening (ADR-55/56 `BodyFixpoint`),
2813
+ # seeding `acc` from the seed type (or the element type for the
2814
+ # no-seed form) and binding the element-join to the element param.
2815
+ # Declines (nil) when the element type is unknown so the RBS tier
2816
+ # owns the call.
2817
+ def try_nominal_inject_fixpoint(receiver, block, seed, has_seed)
2818
+ element = MethodDispatcher::IteratorDispatch.element_type_of(receiver)
2819
+ return nil if element.nil?
2820
+
2821
+ seed_acc = has_seed ? seed : element
2822
+ return nil if seed_acc.nil?
2823
+
2824
+ converged = BodyFixpoint.converge(
2825
+ names: [:__inject_acc__],
2826
+ seed_bindings: { __inject_acc__: seed_acc },
2827
+ widen: method(:widen_value_pinned),
2828
+ evaluate_body: lambda do |bindings|
2829
+ acc = bindings[:__inject_acc__]
2830
+ result = type_block_body_with_param(block, [acc, element])
2831
+ result.nil? ? {} : { __inject_acc__: result }
2832
+ end
2833
+ )
2834
+ converged[:__inject_acc__] || seed_acc
2835
+ end
2836
+
1828
2837
  # `index(value)` and `find_index(value)` carry a positional
1829
2838
  # argument and search by `==` rather than running the block.
1830
2839
  # Decline so the RBS tier owns those forms.