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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +72 -1
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +7 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
1203
|
+
return local_inference if local_inference
|
|
1170
1204
|
|
|
1171
1205
|
# The local def matches by name but the inference was
|
|
1172
|
-
# disqualified —
|
|
1173
|
-
#
|
|
1174
|
-
#
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
|
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
|
-
# -
|
|
1347
|
-
#
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
# -
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
#
|
|
1354
|
-
#
|
|
1355
|
-
#
|
|
1356
|
-
#
|
|
1357
|
-
#
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
#
|
|
1361
|
-
|
|
1362
|
-
|
|
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 =
|
|
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] =
|
|
1546
|
+
table[key] = compute_user_def_with_owner(class_name, method_name)
|
|
1426
1547
|
end
|
|
1427
1548
|
|
|
1428
|
-
def
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1531
|
-
|
|
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.
|