rigortype 0.1.17 → 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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  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 +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
9
  require_relative "block_parameter_binder"
10
+ require_relative "body_fixpoint"
10
11
  require_relative "closure_escape_analyzer"
11
12
  require_relative "indexed_narrowing"
12
13
  require_relative "method_dispatcher"
@@ -98,10 +99,22 @@ module Rigor
98
99
  Prism::SingletonClassNode => :eval_singleton_class,
99
100
  Prism::CallNode => :eval_call,
100
101
  Prism::BlockNode => :eval_block,
102
+ Prism::ReturnNode => :eval_return,
101
103
  Prism::MatchWriteNode => :eval_match_write
102
104
  }.freeze
103
105
  private_constant :HANDLERS
104
106
 
107
+ # Thread-local sink (an Array) collecting the value types of explicit
108
+ # `return value` nodes reached while evaluating a method body, so
109
+ # `ExpressionTyper#infer_user_method_return` can join them into the
110
+ # method's inferred return type. The flow value of a `return` is still
111
+ # `Bot` (it transfers control rather than producing a value); the sink
112
+ # only records what the method *returns* through that edge. nil means
113
+ # "not collecting" — a top-level / DSL-block walk, or inside a nested
114
+ # `def` barrier (whose returns belong to the inner method).
115
+ RETURN_SINK_KEY = :rigor_return_sink
116
+ private_constant :RETURN_SINK_KEY
117
+
105
118
  # Lexical class frame: the `name:` field is the qualified class
106
119
  # name as it would render in Ruby (e.g., `"Foo::Bar"`); the
107
120
  # `singleton:` field is `true` for `class << self` frames so
@@ -121,11 +134,40 @@ module Rigor
121
134
  # by {#eval_def} to look up the method's RBS signature. Each
122
135
  # `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
123
136
  # over `self` flips the innermost frame to singleton mode.
124
- def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze)
137
+ # @param converged_loop_recording [Boolean] when true (and an
138
+ # `on_enter` recorder is installed), {#eval_loop} re-evaluates a
139
+ # fixpoint-tracked loop body ONE extra time from the CONVERGED
140
+ # bindings so the last-visit-wins per-node scope index reflects
141
+ # the post-writeback state instead of the cap-N intermediate
142
+ # assumption (`result *= i` annotating `1 | 2` rather than
143
+ # `Integer`). Display-path only — `rigor check` leaves it off,
144
+ # keeping its diagnostics and wall-clock unchanged.
145
+ def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
146
+ converged_loop_recording: false)
125
147
  @scope = scope
126
148
  @tracer = tracer
127
149
  @on_enter = on_enter
128
150
  @class_context = class_context.freeze
151
+ @converged_loop_recording = converged_loop_recording
152
+ end
153
+
154
+ # Runs `block` with a fresh return sink installed, then yields the
155
+ # collected explicit-`return` value types to the caller. The sink is
156
+ # an array of `Rigor::Type`. Nested invocations stack: the previous
157
+ # sink is restored on exit so a `def` evaluated inside another method's
158
+ # body (which itself installed a sink) does not corrupt the outer one.
159
+ # Used by `ExpressionTyper#infer_user_method_return` to join the
160
+ # explicit returns into the inferred method-return type.
161
+ def self.with_return_sink
162
+ previous = Thread.current[RETURN_SINK_KEY]
163
+ sink = []
164
+ Thread.current[RETURN_SINK_KEY] = sink
165
+ begin
166
+ result = yield
167
+ ensure
168
+ Thread.current[RETURN_SINK_KEY] = previous
169
+ end
170
+ [result, sink]
129
171
  end
130
172
 
131
173
  # Evaluate `node` under the receiver scope. Returns `[type, scope']`
@@ -178,9 +220,28 @@ module Rigor
178
220
  # default branch in {#evaluate}.
179
221
  def eval_local_write(node)
180
222
  rhs_type, post_rhs = sub_eval(node.value, scope)
223
+ # ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely
224
+ # declaration-sourced makes `r` declaration-sourced too (the survey's
225
+ # exact rotation/traversal shape `r = @right; r.key`). The mark is
226
+ # computed on the RHS *value*'s provenance — a pure ivar read of a
227
+ # currently declaration-sourced ivar — so it survives the local copy.
228
+ # Any other RHS (a call result, a method-local-nil-bearing value)
229
+ # leaves the local flow-live and the diagnostic fires as before.
230
+ if declaration_sourced_ivar_read?(node.value, post_rhs)
231
+ return [rhs_type, post_rhs.with_declaration_sourced_local(node.name, rhs_type)]
232
+ end
233
+
181
234
  [rhs_type, post_rhs.with_local(node.name, rhs_type)]
182
235
  end
183
236
 
237
+ # True when `value_node` is a bare instance-variable read whose binding
238
+ # in `scope_at_read` is currently marked declaration-sourced.
239
+ def declaration_sourced_ivar_read?(value_node, scope_at_read)
240
+ return false unless value_node.is_a?(Prism::InstanceVariableReadNode)
241
+
242
+ scope_at_read.declaration_sourced?(:ivar, value_node.name)
243
+ end
244
+
184
245
  # Slice 7 phase 1 — instance/class/global variable
185
246
  # writes. Each handler evaluates the rvalue under the
186
247
  # entry scope and binds the named variable into the
@@ -476,31 +537,19 @@ module Rigor
476
537
  # carriers like `Nominal[Integer]` (Integer is always truthy
477
538
  # in Ruby — including 0) also collapse the dead else.
478
539
  def live_branch_for_if(node, pred_type, post_pred)
479
- case predicate_certainty(pred_type)
480
- when :always_truthy then eval_branch_or_nil(node.statements, post_pred)
481
- when :always_falsey then eval_branch_or_nil(node.subsequent, post_pred)
540
+ case Narrowing.predicate_certainty(pred_type)
541
+ when :truthy then eval_branch_or_nil(node.statements, post_pred)
542
+ when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
482
543
  end
483
544
  end
484
545
 
485
546
  def live_branch_for_unless(node, pred_type, post_pred)
486
- case predicate_certainty(pred_type)
487
- when :always_truthy then eval_branch_or_nil(node.else_clause, post_pred)
488
- when :always_falsey then eval_branch_or_nil(node.statements, post_pred)
547
+ case Narrowing.predicate_certainty(pred_type)
548
+ when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
549
+ when :falsey then eval_branch_or_nil(node.statements, post_pred)
489
550
  end
490
551
  end
491
552
 
492
- def predicate_certainty(pred_type)
493
- return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
494
-
495
- truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
496
- falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
497
-
498
- return :always_falsey if truthy_bot && !falsey_bot
499
- return :always_truthy if !truthy_bot && falsey_bot
500
-
501
- nil
502
- end
503
-
504
553
  def eval_else(node)
505
554
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
506
555
 
@@ -518,12 +567,33 @@ module Rigor
518
567
  else_result = eval_case_else(node.else_clause, falsey_scope)
519
568
 
520
569
  all_results = [*branch_results, else_result]
570
+ branch_nodes = [*node.conditions, node.else_clause]
521
571
  [
522
572
  Type::Combinator.union(*all_results.map(&:first)),
523
- reduce_scopes_with_nil_injection(all_results.map(&:last))
573
+ join_case_branch_scopes(all_results, branch_nodes)
524
574
  ]
525
575
  end
526
576
 
577
+ # Joins the post-scopes of every `when`/`in`/`else` branch, dropping
578
+ # the scope of any branch that terminates (raises / returns / throws /
579
+ # types to `Bot`) before the merge — control never falls through such
580
+ # a branch, so its half-bound locals must not nil-inject the names a
581
+ # live sibling branch assigned. Mirrors the `branch_terminates?` rule
582
+ # `eval_if`/`eval_unless` already apply to the if/else merge: e.g.
583
+ # `case x; when 1 then v="a"; when 2 then v="b"; else raise; end`
584
+ # keeps `v: "a" | "b"` instead of `... | nil`. When every branch
585
+ # terminates the merge is itself unreachable; fall back to the full
586
+ # join so the continuation scope stays well-formed.
587
+ def join_case_branch_scopes(results, nodes)
588
+ live = []
589
+ results.each_with_index do |(type, branch_scope), i|
590
+ live << branch_scope unless branch_terminates?(nodes[i], type)
591
+ end
592
+
593
+ live = results.map(&:last) if live.empty?
594
+ reduce_scopes_with_nil_injection(live)
595
+ end
596
+
527
597
  def eval_case_when_branches(subject, conditions, entry_scope)
528
598
  results = []
529
599
  falsey_scope = entry_scope
@@ -822,13 +892,204 @@ module Rigor
822
892
  # common case where no `break VALUE` is observed.
823
893
  def eval_loop(node)
824
894
  _pred_type, post_pred = sub_eval(node.predicate, scope)
825
- return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
826
-
895
+ return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
896
+
897
+ # The historical single body pass joined with the pre-loop scope.
898
+ # This continues to carry everything the fixpoint does NOT track:
899
+ # receiver-mutation widening of non-rebound locals (`buf.push(i)`
900
+ # widens `buf`'s Tuple), body-introduced locals' nil-injection, and
901
+ # the loop value itself. The fixpoint then OVERLAYS only the
902
+ # rebound-local bindings it corrects.
827
903
  _body_type, body_scope = sub_eval(node.statements, post_pred)
828
- [
829
- Type::Combinator.constant_of(nil),
830
- join_with_nil_injection(post_pred, body_scope)
831
- ]
904
+ base_scope = join_with_nil_injection(post_pred, body_scope)
905
+
906
+ rebound, body_first = loop_body_local_writes(node.statements, post_pred)
907
+ names = rebound + body_first
908
+
909
+ # Fast path: a loop whose body rebinds no local skips the rebind
910
+ # fixpoint, but still needs the slice-C content writeback (a loop may
911
+ # content-mutate a collection without rebinding any local — `acc <<
912
+ # x`), so apply it to the single-pass join before returning.
913
+ if names.empty?
914
+ fast = loop_content_writeback(node.statements, base_scope)
915
+ return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
916
+ end
917
+
918
+ # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
919
+ # may compound (`d *= 2`), so the historical single body pass joined
920
+ # with the pre-loop scope kept stale folded constants
921
+ # (`d = 1; while …; d *= 2; end` → `1 | 2`, never reaching `4, 8`).
922
+ # Fold each body-written local's continuation binding through the
923
+ # same capped fixpoint slice A uses for non-escaping block captures.
924
+ #
925
+ # Seed: a pre-existing local seeds with its post-predicate binding;
926
+ # a local FIRST assigned inside the body seeds with `nil` so the
927
+ # 0-iteration path (the body may never run) degrades it to
928
+ # `T | nil`, matching the historical nil-injection treatment.
929
+ result = loop_rebind_fixpoint(node, post_pred, names, body_first)
930
+ # Display-path re-record: the fixpoint's body re-evaluations fire
931
+ # `on_enter` with the cap-N INTERMEDIATE assumptions, so the
932
+ # last-visit-wins scope index would annotate loop-body lines with
933
+ # stale pre-convergence constants. One extra pass from the
934
+ # converged bindings (result discarded) re-records the body's
935
+ # entry scopes post-writeback.
936
+ record_converged_loop_body(node, post_pred, result, names, body_first)
937
+ post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
938
+ # ADR-56 slice C — loop-body receiver-content element-type join. A
939
+ # loop that content-mutates a collection (`acc << n`) keeps only the
940
+ # seed's element types after the single-pass widen (B1 unsoundness:
941
+ # `acc = [0]; while …; acc << n; end` → `Array[0]`, runtime
942
+ # `[0, n, …]`). Join the appended/stored types into the continuation
943
+ # collection. Pre-state is read from `post_loop` so a local both
944
+ # rebound and content-mutated composes.
945
+ post_loop = loop_content_writeback(node.statements, post_loop)
946
+ post_loop = narrow_loop_exit_edge(node, post_loop)
947
+ [Type::Combinator.constant_of(nil), post_loop]
948
+ end
949
+
950
+ # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
951
+ # loop exits PRECISELY on the predicate's exit edge: `while` exits when
952
+ # `pred` is falsey, `until` when `pred` is truthy. So after the loop the
953
+ # predicate-assignment target carries the exit polarity — `until line =
954
+ # io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
955
+ # until `gets` returned a truthy (non-nil) line. Apply the exit edge of
956
+ # `Narrowing.predicate_scopes` to the continuation scope.
957
+ #
958
+ # Guarded against `break`: a `break` exits the loop WITHOUT the predicate
959
+ # ever going false (`while line = gets; break if done; end` can leave
960
+ # `line` truthy on a `while`, or exit before the `until` predicate fires),
961
+ # so the exit-edge proof does not hold and the loop is left un-narrowed.
962
+ # `break` inside a NESTED loop/block does not target this loop, but a
963
+ # nested-loop `break` is rare in predicate-assignment loops and the
964
+ # conservative bail only costs precision, never soundness.
965
+ def narrow_loop_exit_edge(node, post_loop)
966
+ return post_loop if loop_body_breaks?(node.statements)
967
+
968
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_loop)
969
+ node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
970
+ end
971
+
972
+ # True when the loop body can `break` out of THIS loop. Conservatively
973
+ # treats any `BreakNode` under the body as a break for this loop (a
974
+ # break inside a nested loop/block actually targets the inner construct,
975
+ # but bailing is precision-only).
976
+ def loop_body_breaks?(statements)
977
+ return false if statements.nil?
978
+
979
+ found = false
980
+ Source::NodeWalker.each(statements) do |descendant|
981
+ found = true if descendant.is_a?(Prism::BreakNode)
982
+ end
983
+ found
984
+ end
985
+
986
+ # Joins loop-body content mutations into the continuation collection
987
+ # bindings. The mutator arguments are typed against `post_loop`, whose
988
+ # locals already carry the loop-body fixpoint widening (so an
989
+ # appended `n` that the loop decrements types `Integer`, not its
990
+ # entry `Constant[3]` — otherwise only the first iteration's value
991
+ # would be captured, an unsound under-approximation). Pre-state is
992
+ # read from `post_loop` too. A loop body shares the surrounding scope,
993
+ # so the receiver is any `LocalVariableReadNode` (no depth filter).
994
+ def loop_content_writeback(statements, post_loop)
995
+ return post_loop if statements.nil?
996
+
997
+ mutations = Hash.new { |h, k| h[k] = [] }
998
+ Source::NodeWalker.each(statements) do |descendant|
999
+ name, node = content_mutation_target(descendant) { |_r| true }
1000
+ mutations[name] << node unless name.nil?
1001
+ end
1002
+ return post_loop if mutations.empty?
1003
+
1004
+ mutations.reduce(post_loop) do |acc, (name, calls)|
1005
+ joined = join_content_for_local(name, calls, acc, post_loop)
1006
+ joined.nil? ? acc : acc.with_local(name, joined)
1007
+ end
1008
+ end
1009
+
1010
+ # Re-evaluates the loop body once from the converged fixpoint
1011
+ # bindings, solely for the `on_enter` side effect of re-recording
1012
+ # the body's per-node entry scopes. Gated behind the
1013
+ # display-path-only `converged_loop_recording` flag so the check
1014
+ # path neither pays the extra body evaluation nor risks any
1015
+ # diagnostic drift.
1016
+ def record_converged_loop_body(node, post_pred, bindings, names, body_first)
1017
+ return unless @converged_loop_recording && @on_enter
1018
+
1019
+ loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1020
+ nil
1021
+ end
1022
+
1023
+ # Runs the slice-B loop-body rebind fixpoint, returning the per-name
1024
+ # continuation binding. Seed: a pre-existing local seeds with its
1025
+ # post-predicate binding; a local FIRST assigned inside the body seeds
1026
+ # with `nil` so the 0-iteration path (the body may never run) degrades
1027
+ # it to `T | nil`, matching the historical nil-injection treatment.
1028
+ def loop_rebind_fixpoint(node, post_pred, names, body_first)
1029
+ nil_const = Type::Combinator.constant_of(nil)
1030
+ seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
1031
+ BodyFixpoint.converge(
1032
+ names: names,
1033
+ seed_bindings: seed,
1034
+ widen: Type::Combinator.method(:widen_value_pinned),
1035
+ evaluate_body: ->(bindings) { loop_body_exit_bindings(node, post_pred, bindings, names, body_first) }
1036
+ )
1037
+ end
1038
+
1039
+ # Names of locals the loop body can rebind, partitioned into those
1040
+ # already bound in `base_scope` (their pre-loop binding seeds the
1041
+ # fixpoint) and those FIRST assigned inside the body (no pre-state, so
1042
+ # they seed with `nil` for 0-iteration soundness). A loop body
1043
+ # introduces no new binding scope — every write leaks to the
1044
+ # surrounding scope — so unlike a block there is no introduced-name
1045
+ # filter; every local-write form under the body node counts.
1046
+ def loop_body_local_writes(statements, base_scope)
1047
+ pre_existing = []
1048
+ body_first = []
1049
+ Source::NodeWalker.each(statements) do |descendant|
1050
+ next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1051
+
1052
+ name = descendant.name
1053
+ if base_scope.locals.key?(name)
1054
+ pre_existing << name
1055
+ else
1056
+ body_first << name
1057
+ end
1058
+ end
1059
+ [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
1060
+ end
1061
+
1062
+ # Evaluates the loop body once with each fixpoint-tracked local bound
1063
+ # to the supplied running assumption and returns the per-name exit
1064
+ # binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
1065
+ #
1066
+ # The body runs from `post_pred` overlaid with the assumptions, then
1067
+ # narrowed by the predicate's loop-entry edge: a `while` body only
1068
+ # runs when the predicate is TRUTHY, an `until` body only when it is
1069
+ # FALSEY. Re-applying that narrowing per iteration keeps loop-carried
1070
+ # narrowing sound — without it, an accumulator whose rebind can
1071
+ # introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
1072
+ # `while prefix && …`) would re-enter the body with `nil` un-narrowed
1073
+ # and false-fire `possible nil receiver` on the guarded re-read. The
1074
+ # historical single body pass (which seeds these locals from their
1075
+ # never-nil pre-loop binding) did not need this; the fixpoint, which
1076
+ # feeds the widened assumption back in, does.
1077
+ #
1078
+ # A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
1079
+ # into the body-entry scope: when the body runs it assigns the local
1080
+ # before any use, exactly as the historical single body pass saw it.
1081
+ # Its `nil` seed exists only to model the 0-iteration path and is kept
1082
+ # as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
1083
+ # back into the body re-evaluation would leak it past a condition-form
1084
+ # assignment the engine does not thread into the branch (`if exps.size
1085
+ # > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
1086
+ def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1087
+ overlaid = bindings.except(*body_first)
1088
+ entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
1089
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
1090
+ body_entry = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
1091
+ _type, exit_scope = sub_eval(node.statements, body_entry)
1092
+ names.to_h { |name| [name, exit_scope.local(name)] }
832
1093
  end
833
1094
 
834
1095
  # `for index in collection; body; end`. Unlike `each {}` blocks,
@@ -1058,8 +1319,18 @@ module Rigor
1058
1319
  # like a singleton-side call. Observed surfacing 915 false
1059
1320
  # positives in `prism-1.9.0`'s auto-generated `copy`
1060
1321
  # methods alone.
1061
- sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
1062
- sub_eval(node.body, body_scope, class_context: @class_context) if node.body
1322
+ # A nested `def` is a return barrier: its body's `return`s belong to
1323
+ # the inner method, not the one currently being inferred. Suspend the
1324
+ # return sink across the nested body so `eval_return` does not record
1325
+ # them into the outer method's return type.
1326
+ outer_sink = Thread.current[RETURN_SINK_KEY]
1327
+ Thread.current[RETURN_SINK_KEY] = nil
1328
+ begin
1329
+ sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
1330
+ sub_eval(node.body, body_scope, class_context: @class_context) if node.body
1331
+ ensure
1332
+ Thread.current[RETURN_SINK_KEY] = outer_sink
1333
+ end
1063
1334
  [Type::Combinator.constant_of(node.name), scope]
1064
1335
  end
1065
1336
 
@@ -1081,6 +1352,11 @@ module Rigor
1081
1352
  # observe the outer scope, matching Ruby evaluation order.
1082
1353
  def eval_call(node)
1083
1354
  call_type = scope.type_of(node, tracer: tracer)
1355
+ # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
1356
+ # returns the memo; the engine otherwise types the call `Dynamic[top]`.
1357
+ # Compute the joined memo type from the block's content mutations of
1358
+ # the memo block-param and adopt it as the call's return type.
1359
+ call_type = each_with_object_return(node, call_type)
1084
1360
  evaluate_block_if_present(node)
1085
1361
  # `ruby2_keywords def foo(...)` (and similar wrappers like
1086
1362
  # `private def`, `public def`, `module_function def`) parse
@@ -1098,6 +1374,14 @@ module Rigor
1098
1374
  # `self_type` for the def's body.
1099
1375
  evaluate_def_arguments(node)
1100
1376
  post_scope = record_closure_escape_if_any(node)
1377
+ # ADR-56 slice A — non-escaping block captured-local write-back.
1378
+ # A `:non_escaping` block (each / times / upto / map …) that
1379
+ # rebinds an outer local must not leave that local's pre-call
1380
+ # binding unmodified in the continuation scope; the spec MUST in
1381
+ # § "Fact stability and mutation" names captured locals a
1382
+ # first-class invalidation category. (The escaping / unknown path
1383
+ # already widened to Dynamic[top] via `record_closure_escape_if_any`.)
1384
+ post_scope = write_back_block_captures(node, post_scope)
1101
1385
  post_scope = apply_rbs_extended_assertions(node, post_scope)
1102
1386
  post_scope = apply_plugin_assertions(node, post_scope)
1103
1387
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
@@ -1108,6 +1392,18 @@ module Rigor
1108
1392
  # justification when the value is mutated. Always-safe
1109
1393
  # (loses precision, never invents facts).
1110
1394
  post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1395
+ # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
1396
+ # self-call resolves to a user method that CONTENT-mutates one of its
1397
+ # parameters inside an escaping block (the `build_option_parser(opts)`
1398
+ # idiom — the callee returns an `OptionParser` whose
1399
+ # `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
1400
+ # the matching caller-argument local. The callee's escape is invisible
1401
+ # across the boundary, so without this the caller's `options` keeps its
1402
+ # seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
1403
+ # fires only when the resolved callee actually escape-mutates that
1404
+ # parameter (not for every self-call), and sound — only loses
1405
+ # precision on the floored argument.
1406
+ post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1111
1407
  # And the same widening for outer-scope locals / ivars
1112
1408
  # mutated inside the block body (`items.each { |x| arr << x }`):
1113
1409
  # the block lives in a child scope so without an explicit
@@ -1116,6 +1412,14 @@ module Rigor
1116
1412
  # precision — so blindly applying is safe regardless of
1117
1413
  # whether the block actually runs.
1118
1414
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1415
+ # ADR-56 slice C — receiver-content element-type join. The widening
1416
+ # above forgets a content-mutated collection's literal arity but
1417
+ # keeps only the seed's element types (the B1 unsound under-
1418
+ # approximation for a non-empty seed). Join the appended / stored
1419
+ # element / key / value types into the continuation collection's
1420
+ # parameter so `out = [0]; arr.each { |x| out << x }` types
1421
+ # `Array[0 | Integer]`, not `Array[0]`. Always sound — only widens.
1422
+ post_scope = content_writeback_block_captures(node, post_scope)
1119
1423
  # Indexed-collection narrowing — drop any
1120
1424
  # `receiver[key] ||= default` narrowing the analyzer
1121
1425
  # recorded earlier when an intervening `[]=` writes the
@@ -1142,9 +1446,47 @@ module Rigor
1142
1446
  # new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
1143
1447
  # § "Flow-folding" — G2 intervening-call case.
1144
1448
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
1449
+ # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
1450
+ # narrowed to non-nil on a successful-match edge; a later call
1451
+ # that itself runs a regex match rebinds them, so the narrowed
1452
+ # facts must be dropped. We forget them only when the call is
1453
+ # match-CAPABLE (a regex-matching method, or an implicit-self /
1454
+ # unknown-receiver call whose body we cannot prove match-free).
1455
+ # A call provably match-free on a known receiver — `$3.to_i`,
1456
+ # `year < 50` — does NOT clobber, so the multi-statement
1457
+ # `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1458
+ # while a genuinely interposed match still invalidates.
1459
+ post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1145
1460
  [call_type, post_scope]
1146
1461
  end
1147
1462
 
1463
+ # Method names that (may) run a regex match and therefore rebind
1464
+ # the `$~` family. Conservative over-approximation — a few set
1465
+ # globals only with a Regexp argument, but we do not inspect args.
1466
+ MATCH_CAPABLE_METHODS = %i[
1467
+ =~ match match? gsub gsub! sub sub! scan split slice slice!
1468
+ [] partition rpartition index rindex === grep grep_v
1469
+ ].freeze
1470
+ private_constant :MATCH_CAPABLE_METHODS
1471
+
1472
+ # True when `node` could rebind the regex match-data globals:
1473
+ # a known regex-matching method by name, or an implicit-self /
1474
+ # self-receiver call whose body we cannot inspect for an internal
1475
+ # match. An explicit-receiver call to a non-matching method
1476
+ # (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
1477
+ # the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
1478
+ # narrowed globals. The over-approximation is one-directional: a
1479
+ # user method that secretly matches on an explicit receiver is the
1480
+ # only escape, and re-narrowing on the next real guard recovers —
1481
+ # weighed against the false-positive cost, precision wins here.
1482
+ def match_capable_call?(node)
1483
+ return true unless node.is_a?(Prism::CallNode)
1484
+ return true if MATCH_CAPABLE_METHODS.include?(node.name)
1485
+
1486
+ receiver = node.receiver
1487
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
1488
+ end
1489
+
1148
1490
  # Returns a scope with each ivar's narrowed local binding
1149
1491
  # widened back to its class-ivar seed value when the call
1150
1492
  # is one that could plausibly mutate ivars on the enclosing
@@ -1421,10 +1763,9 @@ module Rigor
1421
1763
  end
1422
1764
  end
1423
1765
 
1424
- # ADR-37 slice 2 — gathers each plugin's post-return narrowing from
1425
- # BOTH the narrow `type_specifier` DSL (method-gated, wrapped as a
1426
- # facts-only `FlowContribution`) and the legacy
1427
- # `flow_contribution_for` escape valve, swallowing per-plugin
1766
+ # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
1767
+ # narrowing from the method-gated `type_specifier` DSL, wrapped as
1768
+ # a facts-only `FlowContribution`, swallowing per-plugin
1428
1769
  # exceptions so a buggy plugin can't abort the assertion path.
1429
1770
  EMPTY_CONTRIBUTIONS = [].freeze
1430
1771
  private_constant :EMPTY_CONTRIBUTIONS
@@ -1432,8 +1773,11 @@ module Rigor
1432
1773
  # Per-dispatch collection of plugin narrowing contributions. Mirrors
1433
1774
  # `MethodDispatcher#collect_plugin_contributions`: visit only the
1434
1775
  # registry-ordered subset of plugins that implement a per-call path
1435
- # (`for_statement` = overrides `flow_contribution_for` or declares a
1436
- # `type_specifier`), gate each path by membership, and accumulate
1776
+ # (`for_statement` = declares a `type_specifier`), gate each path
1777
+ # by membership AND by the ADR-52 WD1 method-name gates (every
1778
+ # `type_specifier` rule is `methods:`-gated, so the common
1779
+ # no-candidate case is a single Set probe; a pruned
1780
+ # consultation could only have returned `[]`), and accumulate
1437
1781
  # lazily (shared frozen empty array otherwise). Same contributions in
1438
1782
  # the same order as visiting every plugin; the caller is read-only.
1439
1783
  def collect_plugin_contributions(registry, call_node, current_scope)
@@ -1441,16 +1785,21 @@ module Rigor
1441
1785
  relevant = index.for_statement
1442
1786
  return EMPTY_CONTRIBUTIONS if relevant.empty?
1443
1787
 
1788
+ name = call_node.respond_to?(:name) ? call_node.name : nil
1789
+ return EMPTY_CONTRIBUTIONS unless index.statement_candidate?(name)
1790
+
1791
+ collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1792
+ end
1793
+
1794
+ # The post-gate walk, in registry order — the same order the
1795
+ # ungated walk used.
1796
+ def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1444
1797
  result = nil
1445
1798
  relevant.each do |plugin|
1446
- if index.flow?(plugin)
1447
- legacy = plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1448
- (result ||= []) << legacy if legacy.is_a?(Rigor::FlowContribution)
1449
- end
1450
- if index.type_specifier?(plugin)
1451
- facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1452
- (result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1453
- end
1799
+ next unless index.type_specifier_candidate_for?(plugin, name)
1800
+
1801
+ facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1802
+ (result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1454
1803
  rescue StandardError
1455
1804
  next
1456
1805
  end
@@ -1611,12 +1960,25 @@ module Rigor
1611
1960
  # A `:non_escaping` classification (or any block-less call)
1612
1961
  # leaves the post-call scope unchanged.
1613
1962
  def record_closure_escape_if_any(node)
1614
- return scope unless node.block.is_a?(Prism::BlockNode)
1963
+ # ADR-57 slice 3 work-item 1: an escaping block can also be attached
1964
+ # to a RECEIVER call in a chain rather than to `node` itself — the
1965
+ # canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
1966
+ # .parse!(argv)` idiom, where the content-mutating block hangs off
1967
+ # `OptionParser.new` but the statement-level call node is the chained
1968
+ # `.parse!`. A receiver call is evaluated as an expression, never as a
1969
+ # statement, so its block never reaches this escape handler on its own.
1970
+ # Fold each escaping receiver-chain block's content widening into the
1971
+ # continuation here so the captured collection is floored regardless of
1972
+ # how deep in the receiver chain its mutating block lives.
1973
+ post_scope = widen_escaping_receiver_chain_captures(node, scope)
1974
+
1975
+ return post_scope unless node.block.is_a?(Prism::BlockNode)
1615
1976
 
1616
1977
  classification = classify_closure_escape(node)
1617
- return scope if classification == :non_escaping
1978
+ return post_scope if classification == :non_escaping
1618
1979
 
1619
- post_scope = drop_captured_narrowing(node.block, scope)
1980
+ post_scope = drop_captured_narrowing(node.block, post_scope)
1981
+ post_scope = widen_escaping_content_captures(node.block, post_scope)
1620
1982
  post_scope.with_fact(
1621
1983
  Analysis::FactStore::Fact.new(
1622
1984
  bucket: :dynamic_origin,
@@ -1628,6 +1990,224 @@ module Rigor
1628
1990
  )
1629
1991
  end
1630
1992
 
1993
+ # Floor each caller-argument local whose matching parameter the resolved
1994
+ # callee escape-mutates (see the call-site comment). Only self-dispatch
1995
+ # calls resolving to a discovered user def are considered; the per-def
1996
+ # "which parameters escape-mutate" set is memoised on the def node.
1997
+ def widen_callee_escaped_argument_captures(node, base_scope)
1998
+ # Apply to the statement call AND every call in its receiver chain: the
1999
+ # `build_option_parser(options).parse!(argv)` idiom puts the escape-
2000
+ # mutating helper call in the RECEIVER position, where its argument is
2001
+ # never the statement node's own argument.
2002
+ acc = floor_callee_escaped_args_for_call(node, base_scope)
2003
+ receiver = node.receiver
2004
+ while receiver.is_a?(Prism::CallNode)
2005
+ acc = floor_callee_escaped_args_for_call(receiver, acc)
2006
+ receiver = receiver.receiver
2007
+ end
2008
+ acc
2009
+ end
2010
+
2011
+ def floor_callee_escaped_args_for_call(node, base_scope)
2012
+ return base_scope unless self_dispatch_call?(node)
2013
+ # Fast path — the floor only ever touches a local passed as an
2014
+ # argument, so a call with no arguments cannot floor anything. Skip the
2015
+ # def resolution + body scan entirely (the overwhelming common case).
2016
+ return base_scope unless call_passes_local_argument?(node)
2017
+
2018
+ def_node = resolve_self_callee_def(node)
2019
+ return base_scope if def_node.nil?
2020
+
2021
+ escaped = escaped_content_parameters(def_node)
2022
+ return base_scope if escaped.empty?
2023
+
2024
+ floor_arguments_at_positions(node, escaped, base_scope)
2025
+ end
2026
+
2027
+ # The user def a self-dispatch `node` resolves to in the enclosing class,
2028
+ # or nil. Reuses the discovery index `Scope#user_def_for` reads; no
2029
+ # ancestor walk (the boundary-escape idiom is same-class), keeping this
2030
+ # off the hot path for the overwhelming majority of self-calls that
2031
+ # resolve to nothing escaping.
2032
+ def resolve_self_callee_def(node)
2033
+ class_name = enclosing_class_name_for(scope.self_type)
2034
+ return scope.top_level_def_for(node.name) if class_name.nil?
2035
+
2036
+ scope.user_def_for(class_name, node.name)
2037
+ end
2038
+
2039
+ def self_dispatch_call?(node)
2040
+ return false unless node.is_a?(Prism::CallNode)
2041
+
2042
+ node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
2043
+ end
2044
+
2045
+ # The set of `[name, position]` parameters of `def_node` whose content a
2046
+ # block in the body escape-mutates. Memoised per def node (the body walk
2047
+ # is otherwise repeated at every call site). A parameter is "escape-
2048
+ # mutated" when a `param[k] = v` / `param << x` mutation on it appears
2049
+ # inside a block whose receiving call is not proven non-escaping.
2050
+ def escaped_content_parameters(def_node)
2051
+ cache = (@escaped_param_cache ||= {}.compare_by_identity)
2052
+ cache[def_node] ||= compute_escaped_content_parameters(def_node)
2053
+ end
2054
+
2055
+ def compute_escaped_content_parameters(def_node)
2056
+ positions = positional_parameter_positions(def_node)
2057
+ return {} if positions.empty?
2058
+
2059
+ mutated = Set.new
2060
+ Source::NodeWalker.each(def_node.body) do |descendant|
2061
+ next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
2062
+ next if syntactically_non_escaping_call?(descendant)
2063
+
2064
+ collect_content_mutations(descendant.block.body).each_key do |name|
2065
+ mutated << name if positions.key?(name)
2066
+ end
2067
+ end
2068
+ positions.slice(*mutated)
2069
+ end
2070
+
2071
+ # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
2072
+ # non-escaping verdict, used when scanning a callee body where the block-
2073
+ # owning call's receiver TYPE is not available. A call whose method name
2074
+ # is a known structural iterator (`each` / `map` / `tap` / …) runs its
2075
+ # block synchronously and does not retain it, so its captured mutations
2076
+ # are not a cross-boundary escape. Any other name (`on`, `subscribe`,
2077
+ # `define_method`, an unknown DSL hook) is treated as escaping — sound,
2078
+ # since mis-classifying a truly-non-escaping call only floors an argument
2079
+ # that was about to be precise.
2080
+ SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
2081
+ ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
2082
+ ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
2083
+ ClosureEscapeAnalyzer::ARRAY_EXTRA +
2084
+ ClosureEscapeAnalyzer::HASH_EXTRA +
2085
+ ClosureEscapeAnalyzer::RANGE_EXTRA +
2086
+ ClosureEscapeAnalyzer::INTEGER_EXTRA
2087
+ ).to_set.freeze
2088
+ private_constant :SYNTACTIC_NON_ESCAPING_BLOCK_METHODS
2089
+
2090
+ def syntactically_non_escaping_call?(call_node)
2091
+ SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
2092
+ end
2093
+
2094
+ # `{ name => position }` for the required / optional positional
2095
+ # parameters of a def. Keyword / rest / block parameters are skipped —
2096
+ # the boundary-escape idiom passes a plain positional collection.
2097
+ def positional_parameter_positions(def_node)
2098
+ params = def_node.parameters
2099
+ return {} if params.nil?
2100
+
2101
+ ordered = (params.requireds || []) + (params.optionals || [])
2102
+ positions = {}
2103
+ ordered.each_with_index do |param, index|
2104
+ positions[param.name] = index if param.respond_to?(:name)
2105
+ end
2106
+ positions
2107
+ end
2108
+
2109
+ # True when at least one argument of `node` is a bare local-variable read
2110
+ # (positional or keyword value) bound in the current scope — a cheap
2111
+ # pre-filter so the def resolution / body scan only runs for calls that
2112
+ # could actually floor something.
2113
+ def call_passes_local_argument?(node)
2114
+ args = node.arguments
2115
+ return false unless args.respond_to?(:arguments)
2116
+
2117
+ args.arguments.any? do |arg|
2118
+ case arg
2119
+ when Prism::LocalVariableReadNode
2120
+ scope.locals.key?(arg.name)
2121
+ when Prism::KeywordHashNode
2122
+ arg.elements.any? do |pair|
2123
+ pair.is_a?(Prism::AssocNode) &&
2124
+ pair.value.is_a?(Prism::LocalVariableReadNode) &&
2125
+ scope.locals.key?(pair.value.name)
2126
+ end
2127
+ else
2128
+ false
2129
+ end
2130
+ end
2131
+ end
2132
+
2133
+ def floor_arguments_at_positions(node, positions, base_scope)
2134
+ args = node.arguments
2135
+ return base_scope unless args.respond_to?(:arguments)
2136
+
2137
+ argument_nodes = args.arguments
2138
+ positions.values.uniq.reduce(base_scope) do |acc, index|
2139
+ arg = argument_nodes[index]
2140
+ next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
2141
+
2142
+ floored = content_floor_for(acc.local(arg.name))
2143
+ floored.nil? ? acc : acc.with_local(arg.name, floored)
2144
+ end
2145
+ end
2146
+
2147
+ # Walk the receiver chain of `node` and fold the escaping-content
2148
+ # widening of every block-bearing, escaping receiver call into
2149
+ # `base_scope`. Only receiver calls are walked — `node` itself is handled
2150
+ # by the caller. A `:non_escaping` receiver block is left to slice C's
2151
+ # non-escaping write-back (which the receiver expression evaluation
2152
+ # already drives), so we only floor the escaping / unknown ones here.
2153
+ def widen_escaping_receiver_chain_captures(node, base_scope)
2154
+ receiver = node.receiver
2155
+ acc = base_scope
2156
+ while receiver.is_a?(Prism::CallNode)
2157
+ if receiver.block.is_a?(Prism::BlockNode) &&
2158
+ classify_closure_escape(receiver) != :non_escaping
2159
+ acc = widen_escaping_content_captures(receiver.block, acc)
2160
+ end
2161
+ receiver = receiver.receiver
2162
+ end
2163
+ acc
2164
+ end
2165
+
2166
+ # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
2167
+ # An escaping / unknown block that CONTENT-mutates a captured outer
2168
+ # local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
2169
+ # stored proc) previously left that local's content untouched — only its
2170
+ # narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
2171
+ # survived and its element fold (`options[:format]` -> `"text"`,
2172
+ # `s.empty?` -> `true`) was unsoundly precise.
2173
+ #
2174
+ # An escaping block may run later and any number of times, so joining a
2175
+ # bounded evidence set is not sound (unlike slice C's non-escaping
2176
+ # join): the sound continuation is the bare-collection floor — Array ->
2177
+ # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
2178
+ # `String`. The seed's element/key/value precision is forgotten; only
2179
+ # the carrier survives. Read-only captures and locals the block merely
2180
+ # rebinds (already floored by `drop_captured_narrowing`) are untouched.
2181
+ def widen_escaping_content_captures(block_node, post_scope)
2182
+ body = block_node.body
2183
+ return post_scope if body.nil?
2184
+
2185
+ mutations = collect_content_mutations(body)
2186
+ return post_scope if mutations.empty?
2187
+
2188
+ mutations.keys.reduce(post_scope) do |acc, name|
2189
+ floored = content_floor_for(acc.local(name))
2190
+ floored.nil? ? acc : acc.with_local(name, floored)
2191
+ end
2192
+ end
2193
+
2194
+ # The Dynamic-floor carrier for a content-mutated escaping capture, or
2195
+ # nil when the pre-state is not a recognised mutable collection (leave
2196
+ # it alone — e.g. an already-`Dynamic` binding or an unknown shape).
2197
+ def content_floor_for(type)
2198
+ return nil if type.nil?
2199
+
2200
+ if stringish?(type)
2201
+ Type::Combinator.nominal_of("String")
2202
+ elsif hashish?(type)
2203
+ Type::Combinator.nominal_of("Hash",
2204
+ type_args: [Type::Combinator.untyped,
2205
+ Type::Combinator.untyped])
2206
+ elsif arrayish?(type)
2207
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
2208
+ end
2209
+ end
2210
+
1631
2211
  def classify_closure_escape(call_node)
1632
2212
  receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
1633
2213
  ClosureEscapeAnalyzer.classify(
@@ -1654,6 +2234,22 @@ module Rigor
1654
2234
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
1655
2235
  end
1656
2236
 
2237
+ # Names of outer locals the block body can REBIND, across every
2238
+ # local-write form: plain `=` (`LocalVariableWriteNode`), the
2239
+ # operator / `||=` / `&&=` compound forms, and a multi-assign target
2240
+ # (`x, y = ...` → `LocalVariableTargetNode` under `MultiWriteNode`).
2241
+ # Block-introduced names (parameters, numbered params, `;`-locals) and
2242
+ # names not bound in the outer scope are excluded — a write to either
2243
+ # is not a captured rebind of an outer variable.
2244
+ LOCAL_WRITE_NODES = [
2245
+ Prism::LocalVariableWriteNode,
2246
+ Prism::LocalVariableOperatorWriteNode,
2247
+ Prism::LocalVariableOrWriteNode,
2248
+ Prism::LocalVariableAndWriteNode,
2249
+ Prism::LocalVariableTargetNode
2250
+ ].freeze
2251
+ private_constant :LOCAL_WRITE_NODES
2252
+
1657
2253
  def captured_local_writes(block_node, base_scope)
1658
2254
  body = block_node.body
1659
2255
  return [] if body.nil?
@@ -1661,7 +2257,7 @@ module Rigor
1661
2257
  introduced = block_introduced_locals(block_node)
1662
2258
  outer_writes = []
1663
2259
  Source::NodeWalker.each(body) do |descendant|
1664
- next unless descendant.is_a?(Prism::LocalVariableWriteNode)
2260
+ next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1665
2261
  next if introduced.include?(descendant.name)
1666
2262
  next unless base_scope.locals.key?(descendant.name)
1667
2263
 
@@ -1670,6 +2266,313 @@ module Rigor
1670
2266
  outer_writes.uniq
1671
2267
  end
1672
2268
 
2269
+ # ADR-56 slice A. For a `:non_escaping` block, fold the continuation
2270
+ # binding of every outer local the body can rebind back into
2271
+ # `post_scope`. The binding is a capped fixpoint (cap 3) over the
2272
+ # block body re-evaluated under the running per-name assumption,
2273
+ # joined with the pre-call binding (kept as a constituent so the
2274
+ # 0-iteration path — `[].each { … }` — stays sound), value-pinned-
2275
+ # widened on the final permitted iteration, and floored to
2276
+ # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2277
+ #
2278
+ # Fast path: a block writing no outer local leaves `post_scope`
2279
+ # byte-identical (the overwhelming majority of blocks), so this costs
2280
+ # one extra `captured_local_writes` walk and nothing else.
2281
+ def write_back_block_captures(call_node, post_scope)
2282
+ block = call_node.block
2283
+ return post_scope unless block.is_a?(Prism::BlockNode)
2284
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
2285
+
2286
+ names = captured_local_writes(block, scope)
2287
+ return post_scope if names.empty?
2288
+
2289
+ seed = names.to_h { |name| [name, scope.local(name)] }
2290
+ result = BodyFixpoint.converge(
2291
+ names: names,
2292
+ seed_bindings: seed,
2293
+ widen: Type::Combinator.method(:widen_value_pinned),
2294
+ evaluate_body: ->(bindings) { block_exit_bindings(call_node, block, bindings, names) }
2295
+ )
2296
+
2297
+ result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
2298
+ end
2299
+
2300
+ # ADR-56 slice C — receiver-content element-type join. After the
2301
+ # rebind write-back and `MutationWidening.widen_after_block` (which
2302
+ # forgets a content-mutated collection's literal arity but keeps only
2303
+ # the SEED's element types), join the appended/stored element / key /
2304
+ # value types INTO the continuation collection's parameter, so
2305
+ # `out = [0]; arr.each { |x| out << x }` types `out` as
2306
+ # `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
2307
+ # under-approximation: the runtime array is `[0, 1, 2, 3]`).
2308
+ #
2309
+ # Pre-state is read from `post_scope` so a local that is BOTH rebound
2310
+ # and content-mutated composes: the rebind fixpoint result feeds the
2311
+ # content join. The block body is typed once for argument evidence;
2312
+ # the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
2313
+ # sound empty-seed behaviour). Always sound — only ever widens.
2314
+ def content_writeback_block_captures(call_node, post_scope)
2315
+ block = call_node.block
2316
+ return post_scope unless block.is_a?(Prism::BlockNode)
2317
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
2318
+
2319
+ body = block.body
2320
+ return post_scope if body.nil?
2321
+
2322
+ mutations = collect_content_mutations(body)
2323
+ return post_scope if mutations.empty?
2324
+
2325
+ entry = build_block_entry_scope(call_node, block)
2326
+ mutations.reduce(post_scope) do |acc, (name, calls)|
2327
+ joined = join_content_for_local(name, calls, acc, entry)
2328
+ joined.nil? ? acc : acc.with_local(name, joined)
2329
+ end
2330
+ end
2331
+
2332
+ # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
2333
+ # the return is the memo object after the block has mutated it through
2334
+ # the `acc` alias. Compute the joined memo type the same way captured-
2335
+ # local content mutations are joined: pre-state = the memo argument's
2336
+ # type, added evidence = the content-mutator args on the memo block
2337
+ # param. Returns `call_type` unchanged for any other call, a missing
2338
+ # block, or a memo whose pre-state is not a collection.
2339
+ def each_with_object_return(call_node, call_type)
2340
+ return call_type unless call_node.name == :each_with_object
2341
+
2342
+ block = call_node.block
2343
+ return call_type unless block.is_a?(Prism::BlockNode)
2344
+
2345
+ memo_arg = call_node.arguments&.arguments&.first
2346
+ return call_type if memo_arg.nil?
2347
+
2348
+ memo_param = each_with_object_memo_param(block)
2349
+ return call_type if memo_param.nil?
2350
+
2351
+ body = block.body
2352
+ return call_type if body.nil?
2353
+
2354
+ # The memo alias is a block-local (depth 0) — collect content
2355
+ # mutations on it directly rather than via the captured-local walk.
2356
+ calls = body_content_mutations_on(body, memo_param)
2357
+ return call_type if calls.empty?
2358
+
2359
+ pre_state = scope.type_of(memo_arg, tracer: tracer)
2360
+ entry = build_block_entry_scope(call_node, block)
2361
+ joined = join_content_for_param(calls, pre_state, entry)
2362
+ joined || call_type
2363
+ end
2364
+
2365
+ # The name of the memo block parameter (the SECOND positional param of
2366
+ # an `each_with_object` block), or nil when the block does not bind a
2367
+ # second positional param.
2368
+ def each_with_object_memo_param(block)
2369
+ params_root = block.parameters
2370
+ return nil unless params_root.is_a?(Prism::BlockParametersNode)
2371
+
2372
+ params = params_root.parameters
2373
+ return nil if params.nil?
2374
+
2375
+ requireds = params.requireds
2376
+ return nil if requireds.size < 2
2377
+
2378
+ second = requireds[1]
2379
+ second.respond_to?(:name) ? second.name : nil
2380
+ end
2381
+
2382
+ # Content-mutator calls on a block-local receiver `var_name`
2383
+ # (depth 0) within `body`.
2384
+ def body_content_mutations_on(body, var_name)
2385
+ calls = []
2386
+ Source::NodeWalker.each(body) do |descendant|
2387
+ next unless descendant.is_a?(Prism::CallNode)
2388
+ next unless MutationWidening::CONTENT_ADDERS.include?(descendant.name)
2389
+
2390
+ receiver = descendant.receiver
2391
+ next unless receiver.is_a?(Prism::LocalVariableReadNode)
2392
+ next unless receiver.name == var_name
2393
+
2394
+ calls << descendant
2395
+ end
2396
+ calls
2397
+ end
2398
+
2399
+ # Joins content evidence for a memo / param given its pre-state and a
2400
+ # list of mutator calls, dispatching Array vs Hash by the mutator set.
2401
+ def join_content_for_param(calls, pre_state, block_entry)
2402
+ return nil if pre_state.nil?
2403
+
2404
+ if stringish?(pre_state)
2405
+ # String carries no element parameter; mutating `<<`/`concat`
2406
+ # makes the constant value unsound (`s = "a"; s << x` → runtime
2407
+ # `"a…"`), so widen to the nominal base. Sound — only widens.
2408
+ Type::Combinator.nominal_of("String")
2409
+ elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2410
+ join_hash_param(calls, pre_state, block_entry)
2411
+ else
2412
+ join_array_param(calls, pre_state, block_entry)
2413
+ end
2414
+ end
2415
+
2416
+ def join_hash_param(calls, pre_state, block_entry)
2417
+ pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
2418
+ return nil if pairs.empty? && !hashish?(pre_state)
2419
+
2420
+ MutationWidening.join_hash_content(pre_state, pairs)
2421
+ end
2422
+
2423
+ def join_array_param(calls, pre_state, block_entry)
2424
+ return nil unless arrayish?(pre_state)
2425
+
2426
+ added = calls.flat_map do |c|
2427
+ # Index-write on an array (`a[i] += v`) introduces no new element
2428
+ # evidence we can cheaply attribute — the array-arity forget
2429
+ # already widened the binding; contribute nothing.
2430
+ next [] if index_write?(c)
2431
+
2432
+ MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
2433
+ end
2434
+ MutationWidening.join_array_content(pre_state, added)
2435
+ end
2436
+
2437
+ # Walks the block body for content-mutator calls (`<<`, `push`,
2438
+ # `[]=`, …) whose receiver is a captured outer local (depth >= 1),
2439
+ # returning `{ name => [call_node, ...] }`. Mirrors the
2440
+ # `MutationWidening.widen_after_block` walk (descends into nested
2441
+ # blocks; the depth check keeps nested block-locals out).
2442
+ def collect_content_mutations(body)
2443
+ mutations = Hash.new { |h, k| h[k] = [] }
2444
+ Source::NodeWalker.each(body) do |descendant|
2445
+ name, node = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.positive? }
2446
+ mutations[name] << node unless name.nil?
2447
+ end
2448
+ mutations
2449
+ end
2450
+
2451
+ # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
2452
+ # multi-assign target) that mutate a collection's CONTENT without a
2453
+ # `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
2454
+ # OrWrite even though the appended values land on the nested array —
2455
+ # leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2456
+ INDEX_WRITE_NODES = [
2457
+ Prism::IndexOrWriteNode,
2458
+ Prism::IndexAndWriteNode,
2459
+ Prism::IndexOperatorWriteNode,
2460
+ Prism::IndexTargetNode
2461
+ ].freeze
2462
+ private_constant :INDEX_WRITE_NODES
2463
+
2464
+ # `[receiver_name, node]` when `node` is a content mutation whose
2465
+ # receiver is a local variable satisfying `accept` (depth predicate),
2466
+ # else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
2467
+ # index-write node forms.
2468
+ def content_mutation_target(node)
2469
+ is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2470
+ return [nil, nil] unless is_call_mutator || index_write?(node)
2471
+
2472
+ receiver = node.receiver
2473
+ return [nil, nil] unless receiver.is_a?(Prism::LocalVariableReadNode)
2474
+ return [nil, nil] unless yield(receiver)
2475
+
2476
+ [receiver.name, node]
2477
+ end
2478
+
2479
+ # Computes the joined continuation collection type for one captured
2480
+ # local from its content-mutator calls. Returns `nil` (no overlay)
2481
+ # when the pre-state is neither an Array-ish nor a Hash-ish binding —
2482
+ # e.g. a String accumulator, whose `<<` carries no element parameter
2483
+ # and whose binding already types as `String`.
2484
+ def join_content_for_local(name, calls, post_scope, block_entry)
2485
+ join_content_for_param(calls, post_scope.local(name), block_entry)
2486
+ end
2487
+
2488
+ def hash_mutations?(calls)
2489
+ calls.any? do |c|
2490
+ index_write?(c) || (c.is_a?(Prism::CallNode) && MutationWidening::HASH_CONTENT_ADDERS.include?(c.name))
2491
+ end
2492
+ end
2493
+
2494
+ def index_write?(node)
2495
+ INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
2496
+ end
2497
+
2498
+ def arrayish?(type)
2499
+ case type
2500
+ when Type::Tuple then true
2501
+ when Type::Nominal then type.class_name == "Array"
2502
+ when Type::Union then type.members.any? { |m| arrayish?(m) }
2503
+ else false
2504
+ end
2505
+ end
2506
+
2507
+ def hashish?(type)
2508
+ case type
2509
+ when Type::HashShape then true
2510
+ when Type::Nominal then type.class_name == "Hash"
2511
+ when Type::Union then type.members.any? { |m| hashish?(m) }
2512
+ else false
2513
+ end
2514
+ end
2515
+
2516
+ def stringish?(type)
2517
+ (type.is_a?(Type::Constant) && type.value.is_a?(String)) ||
2518
+ (type.is_a?(Type::Nominal) && type.class_name == "String")
2519
+ end
2520
+
2521
+ # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
2522
+ # an index-write node (`h[k] ||= v`), typed in the block-entry scope.
2523
+ # For an index-write the stored value is opaque (the appended values
2524
+ # often land on a NESTED collection via `h[k] << v`), so the value is
2525
+ # floored to `untyped` — sound: it only ever widens the value param.
2526
+ # Returns `[]` for other forms.
2527
+ def hash_pair_types(node, block_entry)
2528
+ if index_write?(node)
2529
+ key = index_key_type(node, block_entry)
2530
+ return [] if key.nil?
2531
+
2532
+ return [[key, Type::Combinator.untyped]]
2533
+ end
2534
+
2535
+ args = content_arg_types(node, block_entry)
2536
+ return [] if args.size < 2
2537
+
2538
+ [[args.first, args.last]]
2539
+ end
2540
+
2541
+ # Type of the index expression of an index-write node (`h[k] ||= v`).
2542
+ def index_key_type(node, block_entry)
2543
+ args = node.arguments
2544
+ return nil unless args.is_a?(Prism::ArgumentsNode)
2545
+
2546
+ first = args.arguments.first
2547
+ first.nil? ? nil : block_entry.type_of(first, tracer: tracer)
2548
+ rescue StandardError
2549
+ nil
2550
+ end
2551
+
2552
+ # Argument types for a content-mutator call, typed against the
2553
+ # block-entry scope (block params bound). A sub-evaluator over
2554
+ # `block_entry` keeps the argument typing flow-correct for params /
2555
+ # `;`-locals without leaking into the outer scope.
2556
+ def content_arg_types(call_node, block_entry)
2557
+ arguments = call_node.arguments
2558
+ return [] if arguments.nil?
2559
+
2560
+ arguments.arguments.map { |arg| block_entry.type_of(arg, tracer: tracer) }
2561
+ rescue StandardError
2562
+ []
2563
+ end
2564
+
2565
+ # Evaluates `block`'s body once with each written outer local bound to
2566
+ # the supplied `bindings` (block params / `;`-locals re-bound as
2567
+ # usual) and returns the per-name exit binding for `names`. Used as
2568
+ # the `BodyFixpoint` body-evaluator.
2569
+ def block_exit_bindings(call_node, block, bindings, names)
2570
+ entry = build_block_entry_scope(call_node, block)
2571
+ entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
2572
+ _type, exit_scope = sub_eval(block, entry)
2573
+ names.to_h { |name| [name, exit_scope.local(name)] }
2574
+ end
2575
+
1673
2576
  # Names introduced by the block itself (parameters, numbered
1674
2577
  # parameters via `BlockParameterBinder`, plus explicit
1675
2578
  # `;`-prefixed block-locals on `BlockParametersNode`). Writes
@@ -1799,7 +2702,14 @@ module Rigor
1799
2702
  seeded = scope.class_ivars_for(path)
1800
2703
  return body_scope if seeded.empty?
1801
2704
 
1802
- seeded.reduce(body_scope) { |acc, (name, type)| acc.with_ivar(name, type) }
2705
+ # ADR-58 WD1 the class-ivar index unions every `@x = …` write across
2706
+ # the class flow-insensitively, so a ctor `@x = nil` seed makes a read
2707
+ # in a *different* method type `T | nil`. That `nil` is
2708
+ # declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
2709
+ # marks each seeded ivar: `possible-nil-receiver` then declines to fire
2710
+ # on the cross-method invariant unless a method-local write or
2711
+ # narrowing makes the nil flow-live (which drops the mark).
2712
+ seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
1803
2713
  end
1804
2714
 
1805
2715
  # Cvars are visible from BOTH instance and singleton method
@@ -1834,30 +2744,18 @@ module Rigor
1834
2744
  # ScopeIndexer-populated declaration overrides
1835
2745
  # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
1836
2746
  # remain reachable from inside nested bodies.
1837
- def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
1838
- # Single allocation instead of a 13-deep `with_*` chain — this runs
1839
- # per class/method body on the main walk, so the chain's dozen
1840
- # throwaway intermediate Scopes were a top `Scope#rebuild` source.
1841
- # Local-empty by design; every field is a plain inherited reference
1842
- # and the unset fields default to the same empty bindings the chain
1843
- # (from `Scope.empty`) left them at, so the scope is identical (ADR-44).
2747
+ def build_fresh_body_scope
2748
+ # Single allocation instead of a deep `with_*` chain — this runs
2749
+ # per class/method body on the main walk, so the chain's throwaway
2750
+ # intermediate Scopes were a top `Scope#rebuild` source (ADR-44).
2751
+ # Local-empty by design; the discovery index is inherited whole by
2752
+ # reference (ADR-53 Track A), so a table added to the index can no
2753
+ # longer be dropped here by a missed per-field copy.
1844
2754
  Scope.new(
1845
2755
  environment: scope.environment,
1846
2756
  locals: {}.freeze,
1847
2757
  source_path: scope.source_path,
1848
- declared_types: scope.declared_types,
1849
- discovered_classes: scope.discovered_classes,
1850
- in_source_constants: scope.in_source_constants,
1851
- class_ivars: scope.class_ivars,
1852
- class_cvars: scope.class_cvars,
1853
- program_globals: scope.program_globals,
1854
- discovered_methods: scope.discovered_methods,
1855
- discovered_def_nodes: scope.discovered_def_nodes,
1856
- discovered_def_sources: scope.discovered_def_sources,
1857
- discovered_superclasses: scope.discovered_superclasses,
1858
- discovered_includes: scope.discovered_includes,
1859
- discovered_class_sources: scope.discovered_class_sources,
1860
- discovered_method_visibilities: scope.discovered_method_visibilities
2758
+ discovery: scope.discovery
1861
2759
  )
1862
2760
  end
1863
2761
 
@@ -2002,12 +2900,44 @@ module Rigor
2002
2900
 
2003
2901
  # ----- helpers -----
2004
2902
 
2903
+ # Explicit `return value` (including `return` inside a block, which in
2904
+ # Ruby returns from the *enclosing method*). The control-transfer value
2905
+ # is `Bot` — a `return` produces no value at its own position — but the
2906
+ # returned expression's type is recorded into the active return sink so
2907
+ # the method-return inference joins it with the body's tail type.
2908
+ # Returns inside a nested `def`/lambda are barriers: `eval_def` clears
2909
+ # the sink around the nested body, so this handler only ever appends a
2910
+ # return that genuinely exits the method currently being inferred.
2911
+ def eval_return(node)
2912
+ sink = Thread.current[RETURN_SINK_KEY]
2913
+ record_return_value(node, sink) if sink
2914
+ [Type::Combinator.bot, scope]
2915
+ end
2916
+
2917
+ def record_return_value(node, sink)
2918
+ args = node.arguments&.arguments || []
2919
+ # `return` with no argument returns nil; `return a` records the
2920
+ # argument's type; `return a, b, c` packs a Tuple — in Ruby a
2921
+ # multi-value return yields the array `[a, b, c]`, so the inferred
2922
+ # return contributes the corresponding Tuple element-by-element.
2923
+ if args.empty?
2924
+ sink << Type::Combinator.constant_of(nil)
2925
+ elsif args.size == 1
2926
+ type, = sub_eval(args.first, scope)
2927
+ sink << type
2928
+ else
2929
+ element_types = args.map { |arg| sub_eval(arg, scope).first }
2930
+ sink << Type::Combinator.tuple_of(*element_types)
2931
+ end
2932
+ end
2933
+
2005
2934
  def sub_eval(node, with_scope, class_context: @class_context)
2006
2935
  StatementEvaluator.new(
2007
2936
  scope: with_scope,
2008
2937
  tracer: tracer,
2009
2938
  on_enter: @on_enter,
2010
- class_context: class_context
2939
+ class_context: class_context,
2940
+ converged_loop_recording: @converged_loop_recording
2011
2941
  ).evaluate(node)
2012
2942
  end
2013
2943