rigortype 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -6,7 +6,10 @@ require_relative "../reflection"
6
6
  require_relative "../type"
7
7
  require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
+ require_relative "../source/constant_path"
9
10
  require_relative "block_parameter_binder"
11
+ require_relative "body_fixpoint"
12
+ require_relative "struct_fold_safety"
10
13
  require_relative "closure_escape_analyzer"
11
14
  require_relative "indexed_narrowing"
12
15
  require_relative "method_dispatcher"
@@ -98,10 +101,36 @@ module Rigor
98
101
  Prism::SingletonClassNode => :eval_singleton_class,
99
102
  Prism::CallNode => :eval_call,
100
103
  Prism::BlockNode => :eval_block,
104
+ Prism::ReturnNode => :eval_return,
105
+ Prism::BreakNode => :eval_break,
101
106
  Prism::MatchWriteNode => :eval_match_write
102
107
  }.freeze
103
108
  private_constant :HANDLERS
104
109
 
110
+ # Thread-local sink (an Array) collecting the value types of explicit
111
+ # `return value` nodes reached while evaluating a method body, so
112
+ # `ExpressionTyper#infer_user_method_return` can join them into the
113
+ # method's inferred return type. The flow value of a `return` is still
114
+ # `Bot` (it transfers control rather than producing a value); the sink
115
+ # only records what the method *returns* through that edge. nil means
116
+ # "not collecting" — a top-level / DSL-block walk, or inside a nested
117
+ # `def` barrier (whose returns belong to the inner method).
118
+ RETURN_SINK_KEY = :rigor_return_sink
119
+ private_constant :RETURN_SINK_KEY
120
+
121
+ # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the
122
+ # scope at each `break` reached while evaluating a loop body, so
123
+ # `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true;
124
+ # break`) into the loop continuation that the fall-through would
125
+ # otherwise drop. Stacks like the return sink: a nested loop installs its
126
+ # own sink, restored on exit, so an inner loop's break does not leak to
127
+ # the outer one. A `break` inside a block / nested loop targets that
128
+ # inner construct, not the lexical loop — filtered out by the
129
+ # directly-targeting break set, see {#directly_targeting_breaks}.
130
+ # See docs/notes/20260615-loop-break-binding-propagation-design.md.
131
+ BREAK_SINK_KEY = :rigor_break_sink
132
+ private_constant :BREAK_SINK_KEY
133
+
105
134
  # Lexical class frame: the `name:` field is the qualified class
106
135
  # name as it would render in Ruby (e.g., `"Foo::Bar"`); the
107
136
  # `singleton:` field is `true` for `class << self` frames so
@@ -121,11 +150,40 @@ module Rigor
121
150
  # by {#eval_def} to look up the method's RBS signature. Each
122
151
  # `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
123
152
  # over `self` flips the innermost frame to singleton mode.
124
- def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze)
153
+ # @param converged_loop_recording [Boolean] when true (and an
154
+ # `on_enter` recorder is installed), {#eval_loop} re-evaluates a
155
+ # fixpoint-tracked loop body ONE extra time from the CONVERGED
156
+ # bindings so the last-visit-wins per-node scope index reflects
157
+ # the post-writeback state instead of the cap-N intermediate
158
+ # assumption (`result *= i` annotating `1 | 2` rather than
159
+ # `Integer`). Display-path only — `rigor check` leaves it off,
160
+ # keeping its diagnostics and wall-clock unchanged.
161
+ def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
162
+ converged_loop_recording: false)
125
163
  @scope = scope
126
164
  @tracer = tracer
127
165
  @on_enter = on_enter
128
166
  @class_context = class_context.freeze
167
+ @converged_loop_recording = converged_loop_recording
168
+ end
169
+
170
+ # Runs `block` with a fresh return sink installed, then yields the
171
+ # collected explicit-`return` value types to the caller. The sink is
172
+ # an array of `Rigor::Type`. Nested invocations stack: the previous
173
+ # sink is restored on exit so a `def` evaluated inside another method's
174
+ # body (which itself installed a sink) does not corrupt the outer one.
175
+ # Used by `ExpressionTyper#infer_user_method_return` to join the
176
+ # explicit returns into the inferred method-return type.
177
+ def self.with_return_sink
178
+ previous = Thread.current[RETURN_SINK_KEY]
179
+ sink = []
180
+ Thread.current[RETURN_SINK_KEY] = sink
181
+ begin
182
+ result = yield
183
+ ensure
184
+ Thread.current[RETURN_SINK_KEY] = previous
185
+ end
186
+ [result, sink]
129
187
  end
130
188
 
131
189
  # Evaluate `node` under the receiver scope. Returns `[type, scope']`
@@ -178,9 +236,28 @@ module Rigor
178
236
  # default branch in {#evaluate}.
179
237
  def eval_local_write(node)
180
238
  rhs_type, post_rhs = sub_eval(node.value, scope)
239
+ # ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely
240
+ # declaration-sourced makes `r` declaration-sourced too (the survey's
241
+ # exact rotation/traversal shape `r = @right; r.key`). The mark is
242
+ # computed on the RHS *value*'s provenance — a pure ivar read of a
243
+ # currently declaration-sourced ivar — so it survives the local copy.
244
+ # Any other RHS (a call result, a method-local-nil-bearing value)
245
+ # leaves the local flow-live and the diagnostic fires as before.
246
+ if declaration_sourced_ivar_read?(node.value, post_rhs)
247
+ return [rhs_type, post_rhs.with_declaration_sourced_local(node.name, rhs_type)]
248
+ end
249
+
181
250
  [rhs_type, post_rhs.with_local(node.name, rhs_type)]
182
251
  end
183
252
 
253
+ # True when `value_node` is a bare instance-variable read whose binding
254
+ # in `scope_at_read` is currently marked declaration-sourced.
255
+ def declaration_sourced_ivar_read?(value_node, scope_at_read)
256
+ return false unless value_node.is_a?(Prism::InstanceVariableReadNode)
257
+
258
+ scope_at_read.declaration_sourced?(:ivar, value_node.name)
259
+ end
260
+
184
261
  # Slice 7 phase 1 — instance/class/global variable
185
262
  # writes. Each handler evaluates the rvalue under the
186
263
  # entry scope and binds the named variable into the
@@ -304,9 +381,7 @@ module Rigor
304
381
  end
305
382
 
306
383
  # `receiver[key] ||= default` — the Redmine `Query#as_params`
307
- # idiom (ROADMAP § Future cycles / Type-language / engine
308
- # "Indexed-collection narrowing through `Hash[k] ||= default`").
309
- # After the `||=`, the next read at `receiver[key]` is known
384
+ # idiom. After the `||=`, the next read at `receiver[key]` is known
310
385
  # non-nil; the next `<<` / `[]=` / other mutator runs against
311
386
  # a Tuple / Hash carrier instead of the `Constant[nil]` an
312
387
  # empty `HashShape{}` lookup would otherwise fold to.
@@ -419,10 +494,24 @@ module Rigor
419
494
  # then-branch unconditionally exits (return / next /
420
495
  # break / raise) and there is no else, the post-scope
421
496
  # is the falsey edge of the predicate (subsequent
422
- # statements observe the predicate-was-false world).
497
+ # statements observe the predicate-was-false world). The
498
+ # then-body is the *skipped* path, so the bare narrowing
499
+ # (no body assignments) is the correct continuation.
423
500
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
424
501
  if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
425
- return [Type::Combinator.union(then_type, else_type), truthy_scope] \
502
+ # Symmetric case: the else / elsif-chain (`node.subsequent`)
503
+ # unconditionally exits, so the only surviving path is the
504
+ # then-branch that RAN. The continuation must therefore carry
505
+ # `then_scope` — the predicate-truthy narrowing PLUS the
506
+ # then-body's assignments — not the bare `truthy_scope`.
507
+ # Returning `truthy_scope` drops every local the then-body
508
+ # bound, leaving it unbound for any enclosing merge to
509
+ # spuriously nil-inject: e.g. the inner `elsif … else raise`
510
+ # of `if a then x=… elsif b then x=… else raise end` would
511
+ # return with `x` unbound, and the outer if's join would then
512
+ # read `x` as `… | nil` and fire a false `possible-nil-receiver`
513
+ # (liquid v5.x sweep, Event 3).
514
+ return [Type::Combinator.union(then_type, else_type), then_scope] \
426
515
  if branch_terminates?(node.subsequent, else_type) && node.statements
427
516
 
428
517
  [
@@ -457,10 +546,17 @@ module Rigor
457
546
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
458
547
  # Slice 7 phase 14 — same early-return narrowing as
459
548
  # `if`: when the body unconditionally exits and there
460
- # is no else, the post-scope is the truthy edge.
549
+ # is no else, the post-scope is the truthy edge (the body
550
+ # is the skipped path, so the bare narrowing is correct).
461
551
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
462
552
  if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
463
- return [Type::Combinator.union(then_type, else_type), falsey_scope] \
553
+ # Symmetric to the `if` else-exits fix: when the else-clause
554
+ # exits, the surviving path is the unless-body that RAN, so the
555
+ # continuation carries `then_scope` (the predicate-falsey
556
+ # narrowing PLUS the body's assignments), not the bare
557
+ # `falsey_scope` — otherwise body-bound locals are dropped and
558
+ # an enclosing merge nil-injects them.
559
+ return [Type::Combinator.union(then_type, else_type), then_scope] \
464
560
  if branch_terminates?(node.else_clause, else_type) && node.statements
465
561
 
466
562
  [
@@ -506,12 +602,33 @@ module Rigor
506
602
  else_result = eval_case_else(node.else_clause, falsey_scope)
507
603
 
508
604
  all_results = [*branch_results, else_result]
605
+ branch_nodes = [*node.conditions, node.else_clause]
509
606
  [
510
607
  Type::Combinator.union(*all_results.map(&:first)),
511
- reduce_scopes_with_nil_injection(all_results.map(&:last))
608
+ join_case_branch_scopes(all_results, branch_nodes)
512
609
  ]
513
610
  end
514
611
 
612
+ # Joins the post-scopes of every `when`/`in`/`else` branch, dropping
613
+ # the scope of any branch that terminates (raises / returns / throws /
614
+ # types to `Bot`) before the merge — control never falls through such
615
+ # a branch, so its half-bound locals must not nil-inject the names a
616
+ # live sibling branch assigned. Mirrors the `branch_terminates?` rule
617
+ # `eval_if`/`eval_unless` already apply to the if/else merge: e.g.
618
+ # `case x; when 1 then v="a"; when 2 then v="b"; else raise; end`
619
+ # keeps `v: "a" | "b"` instead of `... | nil`. When every branch
620
+ # terminates the merge is itself unreachable; fall back to the full
621
+ # join so the continuation scope stays well-formed.
622
+ def join_case_branch_scopes(results, nodes)
623
+ live = []
624
+ results.each_with_index do |(type, branch_scope), i|
625
+ live << branch_scope unless branch_terminates?(nodes[i], type)
626
+ end
627
+
628
+ live = results.map(&:last) if live.empty?
629
+ reduce_scopes_with_nil_injection(live)
630
+ end
631
+
515
632
  def eval_case_when_branches(subject, conditions, entry_scope)
516
633
  results = []
517
634
  falsey_scope = entry_scope
@@ -810,13 +927,294 @@ module Rigor
810
927
  # common case where no `break VALUE` is observed.
811
928
  def eval_loop(node)
812
929
  _pred_type, post_pred = sub_eval(node.predicate, scope)
813
- return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
930
+ return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
931
+
932
+ # The historical single body pass joined with the pre-loop scope.
933
+ # This continues to carry everything the fixpoint does NOT track:
934
+ # receiver-mutation widening of non-rebound locals (`buf.push(i)`
935
+ # widens `buf`'s Tuple), body-introduced locals' nil-injection, and
936
+ # the loop value itself. The fixpoint then OVERLAYS only the
937
+ # rebound-local bindings it corrects.
938
+ #
939
+ # The pass runs under a break sink so a `break`-path binding
940
+ # (`flag = true; break`) the fall-through `body_scope` drops is
941
+ # collected for the continuation join below.
942
+ break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
943
+ base_scope = join_with_nil_injection(post_pred, body_scope)
944
+
945
+ rebound, body_first = loop_body_local_writes(node.statements, post_pred)
946
+ names = rebound + body_first
947
+
948
+ # Fast path: a loop whose body rebinds no local skips the rebind
949
+ # fixpoint, but still needs the slice-C content writeback (a loop may
950
+ # content-mutate a collection without rebinding any local — `acc <<
951
+ # x`), so apply it to the single-pass join before returning.
952
+ if names.empty?
953
+ fast = loop_content_writeback(node.statements, base_scope)
954
+ return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
955
+ end
814
956
 
815
- _body_type, body_scope = sub_eval(node.statements, post_pred)
816
- [
817
- Type::Combinator.constant_of(nil),
818
- join_with_nil_injection(post_pred, body_scope)
819
- ]
957
+ post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
958
+ # Recover `break`-path bindings the fall-through dropped (`flag = true;
959
+ # break` -> `flag` is `false | true`, not the stale `false`).
960
+ post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
961
+ post_loop = narrow_loop_exit_edge(node, post_loop)
962
+ [Type::Combinator.constant_of(nil), post_loop]
963
+ end
964
+
965
+ # The continuation scope for a loop whose body rebinds locals: the
966
+ # ADR-56 slice-B rebind fixpoint overlaid on `base_scope`, then the
967
+ # slice-C receiver-content writeback.
968
+ def converged_loop_scope(node, post_pred, base_scope, names, body_first)
969
+ # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
970
+ # may compound (`d *= 2`), so the historical single body pass joined
971
+ # with the pre-loop scope kept stale folded constants
972
+ # (`d = 1; while …; d *= 2; end` → `1 | 2`, never reaching `4, 8`).
973
+ # Fold each body-written local's continuation binding through the same
974
+ # capped fixpoint slice A uses for non-escaping block captures. Seed:
975
+ # a pre-existing local seeds with its post-predicate binding; a local
976
+ # FIRST assigned inside the body seeds with `nil` so the 0-iteration
977
+ # path degrades it to `T | nil`, matching the nil-injection treatment.
978
+ result = loop_rebind_fixpoint(node, post_pred, names, body_first)
979
+ # Display-path re-record: the fixpoint's body re-evaluations fire
980
+ # `on_enter` with the cap-N INTERMEDIATE assumptions, so the
981
+ # last-visit-wins scope index would annotate loop-body lines with
982
+ # stale pre-convergence constants. One extra pass from the converged
983
+ # bindings (result discarded) re-records the body's entry scopes.
984
+ record_converged_loop_body(node, post_pred, result, names, body_first)
985
+ post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
986
+ # ADR-56 slice C — loop-body receiver-content element-type join. A loop
987
+ # that content-mutates a collection (`acc << n`) keeps only the seed's
988
+ # element types after the single-pass widen; join the appended/stored
989
+ # types into the continuation collection (pre-state read from
990
+ # `post_loop` so a local both rebound and content-mutated composes).
991
+ loop_content_writeback(node.statements, post_loop)
992
+ end
993
+
994
+ # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
995
+ # loop exits PRECISELY on the predicate's exit edge: `while` exits when
996
+ # `pred` is falsey, `until` when `pred` is truthy. So after the loop the
997
+ # predicate-assignment target carries the exit polarity — `until line =
998
+ # io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
999
+ # until `gets` returned a truthy (non-nil) line. Apply the exit edge of
1000
+ # `Narrowing.predicate_scopes` to the continuation scope.
1001
+ #
1002
+ # Guarded against `break`: a `break` exits the loop WITHOUT the predicate
1003
+ # ever going false (`while line = gets; break if done; end` can leave
1004
+ # `line` truthy on a `while`, or exit before the `until` predicate fires),
1005
+ # so the exit-edge proof does not hold and the loop is left un-narrowed.
1006
+ # `break` inside a NESTED loop/block does not target this loop, but a
1007
+ # nested-loop `break` is rare in predicate-assignment loops and the
1008
+ # conservative bail only costs precision, never soundness.
1009
+ def narrow_loop_exit_edge(node, post_loop)
1010
+ return post_loop if loop_body_breaks?(node.statements)
1011
+
1012
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_loop)
1013
+ node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
1014
+ end
1015
+
1016
+ # True when the loop body can `break` out of THIS loop. Conservatively
1017
+ # treats any `BreakNode` under the body as a break for this loop (a
1018
+ # break inside a nested loop/block actually targets the inner construct,
1019
+ # but bailing is precision-only).
1020
+ def loop_body_breaks?(statements)
1021
+ return false if statements.nil?
1022
+
1023
+ found = false
1024
+ Source::NodeWalker.each(statements) do |descendant|
1025
+ found = true if descendant.is_a?(Prism::BreakNode)
1026
+ end
1027
+ found
1028
+ end
1029
+
1030
+ # A `break` inside one of these nested constructs targets the inner
1031
+ # construct (an inner loop, a block's method, a nested def), NOT the
1032
+ # lexical loop — so the directly-targeting break scan does not descend
1033
+ # into them.
1034
+ BREAK_BOUNDARY_NODES = [
1035
+ Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
1036
+ Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
1037
+ Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
1038
+ ].freeze
1039
+ private_constant :BREAK_BOUNDARY_NODES
1040
+
1041
+ # The `BreakNode`s that lexically target THIS loop — reachable from the
1042
+ # body without crossing a nested loop / block / def boundary. An
1043
+ # identity-keyed Hash used as a membership set to filter the collected
1044
+ # break scopes (the thread-local sink also collects breaks from nested
1045
+ # blocks that did not install their own sink).
1046
+ def directly_targeting_breaks(statements)
1047
+ found = {}.compare_by_identity
1048
+ collect_direct_breaks(statements, found)
1049
+ found
1050
+ end
1051
+
1052
+ def collect_direct_breaks(node, found)
1053
+ return if node.nil?
1054
+
1055
+ found[node] = true if node.is_a?(Prism::BreakNode)
1056
+ node.compact_child_nodes.each do |child|
1057
+ next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
1058
+
1059
+ collect_direct_breaks(child, found)
1060
+ end
1061
+ end
1062
+
1063
+ # Installs a fresh thread-local break sink around `yield` (a loop-body
1064
+ # evaluation), returning `[collected, yield_result]`. Stacks: the
1065
+ # previous sink is restored on exit so a nested loop's breaks do not
1066
+ # leak to the enclosing loop.
1067
+ def collect_break_scopes
1068
+ previous = Thread.current[BREAK_SINK_KEY]
1069
+ sink = []
1070
+ Thread.current[BREAK_SINK_KEY] = sink
1071
+ begin
1072
+ result = yield
1073
+ ensure
1074
+ Thread.current[BREAK_SINK_KEY] = previous
1075
+ end
1076
+ [sink, result]
1077
+ end
1078
+
1079
+ # Runs a loop body's single pass under a break sink. Returns the
1080
+ # directly-targeting break set, the collected break scopes, and the
1081
+ # fall-through body scope — the three inputs the continuation's
1082
+ # {#join_break_scopes} needs. Shared by `eval_loop` and `eval_for`.
1083
+ def capture_loop_body_breaks(statements, entry)
1084
+ targets = directly_targeting_breaks(statements)
1085
+ sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
1086
+ [targets, sink, body_scope]
1087
+ end
1088
+
1089
+ # Joins each directly-targeting break's body-written local bindings into
1090
+ # the loop continuation, so a `break`-path binding the fall-through
1091
+ # dropped is recovered (`flag = true; break` -> `flag` becomes `false |
1092
+ # true`). Only loop-body-written names are joined — an unchanged local
1093
+ # unions to itself; a break-only-written local is already present via the
1094
+ # fixpoint / nil-injection seed, so the union reflects its break value.
1095
+ def join_break_scopes(continuation, sink, targeting, names)
1096
+ return continuation if sink.empty? || names.empty?
1097
+
1098
+ breaks = sink.select { |(node, _scope)| targeting.key?(node) }
1099
+ breaks.reduce(continuation) do |cont, (_node, break_scope)|
1100
+ names.reduce(cont) do |acc, name|
1101
+ break_value = break_scope.local(name)
1102
+ next acc if break_value.nil?
1103
+
1104
+ current = acc.local(name)
1105
+ joined = current ? Type::Combinator.union(current, break_value) : break_value
1106
+ acc.with_local(name, joined)
1107
+ end
1108
+ end
1109
+ end
1110
+
1111
+ # Joins loop-body content mutations into the continuation collection
1112
+ # bindings. The mutator arguments are typed against `post_loop`, whose
1113
+ # locals already carry the loop-body fixpoint widening (so an
1114
+ # appended `n` that the loop decrements types `Integer`, not its
1115
+ # entry `Constant[3]` — otherwise only the first iteration's value
1116
+ # would be captured, an unsound under-approximation). Pre-state is
1117
+ # read from `post_loop` too. A loop body shares the surrounding scope,
1118
+ # so the receiver is any `LocalVariableReadNode` (no depth filter).
1119
+ def loop_content_writeback(statements, post_loop)
1120
+ return post_loop if statements.nil?
1121
+
1122
+ mutations = Hash.new { |h, k| h[k] = [] }
1123
+ Source::NodeWalker.each(statements) do |descendant|
1124
+ name, node = content_mutation_target(descendant) { |_r| true }
1125
+ mutations[name] << node unless name.nil?
1126
+ end
1127
+ return post_loop if mutations.empty?
1128
+
1129
+ mutations.reduce(post_loop) do |acc, (name, calls)|
1130
+ joined = join_content_for_local(name, calls, acc, post_loop)
1131
+ joined.nil? ? acc : acc.with_local(name, joined)
1132
+ end
1133
+ end
1134
+
1135
+ # Re-evaluates the loop body once from the converged fixpoint
1136
+ # bindings, solely for the `on_enter` side effect of re-recording
1137
+ # the body's per-node entry scopes. Gated behind the
1138
+ # display-path-only `converged_loop_recording` flag so the check
1139
+ # path neither pays the extra body evaluation nor risks any
1140
+ # diagnostic drift.
1141
+ def record_converged_loop_body(node, post_pred, bindings, names, body_first)
1142
+ return unless @converged_loop_recording && @on_enter
1143
+
1144
+ loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1145
+ nil
1146
+ end
1147
+
1148
+ # Runs the slice-B loop-body rebind fixpoint, returning the per-name
1149
+ # continuation binding. Seed: a pre-existing local seeds with its
1150
+ # post-predicate binding; a local FIRST assigned inside the body seeds
1151
+ # with `nil` so the 0-iteration path (the body may never run) degrades
1152
+ # it to `T | nil`, matching the historical nil-injection treatment.
1153
+ def loop_rebind_fixpoint(node, post_pred, names, body_first)
1154
+ nil_const = Type::Combinator.constant_of(nil)
1155
+ seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
1156
+ BodyFixpoint.converge(
1157
+ names: names,
1158
+ seed_bindings: seed,
1159
+ widen: Type::Combinator.method(:widen_value_pinned),
1160
+ evaluate_body: ->(bindings) { loop_body_exit_bindings(node, post_pred, bindings, names, body_first) }
1161
+ )
1162
+ end
1163
+
1164
+ # Names of locals the loop body can rebind, partitioned into those
1165
+ # already bound in `base_scope` (their pre-loop binding seeds the
1166
+ # fixpoint) and those FIRST assigned inside the body (no pre-state, so
1167
+ # they seed with `nil` for 0-iteration soundness). A loop body
1168
+ # introduces no new binding scope — every write leaks to the
1169
+ # surrounding scope — so unlike a block there is no introduced-name
1170
+ # filter; every local-write form under the body node counts.
1171
+ def loop_body_local_writes(statements, base_scope)
1172
+ pre_existing = []
1173
+ body_first = []
1174
+ Source::NodeWalker.each(statements) do |descendant|
1175
+ next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1176
+
1177
+ name = descendant.name
1178
+ if base_scope.locals.key?(name)
1179
+ pre_existing << name
1180
+ else
1181
+ body_first << name
1182
+ end
1183
+ end
1184
+ [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
1185
+ end
1186
+
1187
+ # Evaluates the loop body once with each fixpoint-tracked local bound
1188
+ # to the supplied running assumption and returns the per-name exit
1189
+ # binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
1190
+ #
1191
+ # The body runs from `post_pred` overlaid with the assumptions, then
1192
+ # narrowed by the predicate's loop-entry edge: a `while` body only
1193
+ # runs when the predicate is TRUTHY, an `until` body only when it is
1194
+ # FALSEY. Re-applying that narrowing per iteration keeps loop-carried
1195
+ # narrowing sound — without it, an accumulator whose rebind can
1196
+ # introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
1197
+ # `while prefix && …`) would re-enter the body with `nil` un-narrowed
1198
+ # and false-fire `possible nil receiver` on the guarded re-read. The
1199
+ # historical single body pass (which seeds these locals from their
1200
+ # never-nil pre-loop binding) did not need this; the fixpoint, which
1201
+ # feeds the widened assumption back in, does.
1202
+ #
1203
+ # A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
1204
+ # into the body-entry scope: when the body runs it assigns the local
1205
+ # before any use, exactly as the historical single body pass saw it.
1206
+ # Its `nil` seed exists only to model the 0-iteration path and is kept
1207
+ # as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
1208
+ # back into the body re-evaluation would leak it past a condition-form
1209
+ # assignment the engine does not thread into the branch (`if exps.size
1210
+ # > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
1211
+ def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
1212
+ overlaid = bindings.except(*body_first)
1213
+ entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
1214
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
1215
+ body_entry = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
1216
+ _type, exit_scope = sub_eval(node.statements, body_entry)
1217
+ names.to_h { |name| [name, exit_scope.local(name)] }
820
1218
  end
821
1219
 
822
1220
  # `for index in collection; body; end`. Unlike `each {}` blocks,
@@ -834,11 +1232,19 @@ module Rigor
834
1232
  element_type = for_iteration_element_type(coll_type)
835
1233
  body_entry = bind_for_index(node.index, element_type, post_coll)
836
1234
 
837
- body_scope = node.statements ? sub_eval(node.statements, body_entry).last : body_entry
838
- [
839
- Type::Combinator.constant_of(nil),
840
- join_with_nil_injection(post_coll, body_scope)
841
- ]
1235
+ if node.statements.nil?
1236
+ return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
1237
+ end
1238
+
1239
+ # Run the body pass under a break sink so a `break`-path binding the
1240
+ # fall-through drops is recovered into the continuation (the `for`
1241
+ # sibling of `eval_loop`'s break join; `for` has no fixpoint, so the
1242
+ # single-pass join is the only continuation).
1243
+ break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
1244
+ continuation = join_with_nil_injection(post_coll, body_scope)
1245
+ pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
1246
+ continuation = join_break_scopes(continuation, break_sink, break_targets, pre_existing + body_first)
1247
+ [Type::Combinator.constant_of(nil), continuation]
842
1248
  end
843
1249
 
844
1250
  # `for x in coll` is semantically `coll.each { |x| ... }`. We
@@ -1005,7 +1411,7 @@ module Rigor
1005
1411
  # (`Constant[nil]` for an empty body); we discard the body's
1006
1412
  # post-scope.
1007
1413
  def eval_class_or_module(node)
1008
- name = qualified_name_for(node.constant_path)
1414
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1009
1415
  new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
1010
1416
  body_type, _body_scope = eval_class_body(node, new_context)
1011
1417
  [body_type, scope]
@@ -1046,8 +1452,18 @@ module Rigor
1046
1452
  # like a singleton-side call. Observed surfacing 915 false
1047
1453
  # positives in `prism-1.9.0`'s auto-generated `copy`
1048
1454
  # methods alone.
1049
- sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
1050
- sub_eval(node.body, body_scope, class_context: @class_context) if node.body
1455
+ # A nested `def` is a return barrier: its body's `return`s belong to
1456
+ # the inner method, not the one currently being inferred. Suspend the
1457
+ # return sink across the nested body so `eval_return` does not record
1458
+ # them into the outer method's return type.
1459
+ outer_sink = Thread.current[RETURN_SINK_KEY]
1460
+ Thread.current[RETURN_SINK_KEY] = nil
1461
+ begin
1462
+ sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
1463
+ sub_eval(node.body, body_scope, class_context: @class_context) if node.body
1464
+ ensure
1465
+ Thread.current[RETURN_SINK_KEY] = outer_sink
1466
+ end
1051
1467
  [Type::Combinator.constant_of(node.name), scope]
1052
1468
  end
1053
1469
 
@@ -1069,6 +1485,11 @@ module Rigor
1069
1485
  # observe the outer scope, matching Ruby evaluation order.
1070
1486
  def eval_call(node)
1071
1487
  call_type = scope.type_of(node, tracer: tracer)
1488
+ # ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
1489
+ # returns the memo; the engine otherwise types the call `Dynamic[top]`.
1490
+ # Compute the joined memo type from the block's content mutations of
1491
+ # the memo block-param and adopt it as the call's return type.
1492
+ call_type = each_with_object_return(node, call_type)
1072
1493
  evaluate_block_if_present(node)
1073
1494
  # `ruby2_keywords def foo(...)` (and similar wrappers like
1074
1495
  # `private def`, `public def`, `module_function def`) parse
@@ -1086,6 +1507,14 @@ module Rigor
1086
1507
  # `self_type` for the def's body.
1087
1508
  evaluate_def_arguments(node)
1088
1509
  post_scope = record_closure_escape_if_any(node)
1510
+ # ADR-56 slice A — non-escaping block captured-local write-back.
1511
+ # A `:non_escaping` block (each / times / upto / map …) that
1512
+ # rebinds an outer local must not leave that local's pre-call
1513
+ # binding unmodified in the continuation scope; the spec MUST in
1514
+ # § "Fact stability and mutation" names captured locals a
1515
+ # first-class invalidation category. (The escaping / unknown path
1516
+ # already widened to Dynamic[top] via `record_closure_escape_if_any`.)
1517
+ post_scope = write_back_block_captures(node, post_scope)
1089
1518
  post_scope = apply_rbs_extended_assertions(node, post_scope)
1090
1519
  post_scope = apply_plugin_assertions(node, post_scope)
1091
1520
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
@@ -1096,14 +1525,28 @@ module Rigor
1096
1525
  # justification when the value is mutated. Always-safe
1097
1526
  # (loses precision, never invents facts).
1098
1527
  post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1099
- # And the same widening for outer-scope locals / ivars
1100
- # mutated inside the block body (`items.each { |x| arr << x }`):
1101
- # the block lives in a child scope so without an explicit
1102
- # propagation step the outer `arr` keeps its pre-mutation
1103
- # binding. Sound for the same reason only ever LOSES
1104
- # precision so blindly applying is safe regardless of
1105
- # whether the block actually runs.
1528
+ # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
1529
+ # self-call resolves to a user method that CONTENT-mutates one of its
1530
+ # parameters inside an escaping block (the `build_option_parser(opts)`
1531
+ # idiom the callee returns an `OptionParser` whose
1532
+ # `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
1533
+ # the matching caller-argument local. The callee's escape is invisible
1534
+ # across the boundary, so without this the caller's `options` keeps its
1535
+ # seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
1536
+ # fires only when the resolved callee actually escape-mutates that
1537
+ # parameter (not for every self-call), and sound — only loses
1538
+ # precision on the floored argument.
1539
+ post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1540
+ # Same always-safe rationale as `widen_after_call` above —
1541
+ # propagates outer-scope local / ivar widening from block body
1542
+ # mutations (`items.each { |x| arr << x }`).
1106
1543
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1544
+ # ADR-56 slice C — receiver-content element-type join. Joins
1545
+ # appended / stored element / key / value types into the
1546
+ # continuation collection so `out = [0]; arr.each { |x| out << x }`
1547
+ # types `Array[0 | Integer]`, not `Array[0]`. Same always-safe
1548
+ # rationale (only widens).
1549
+ post_scope = content_writeback_block_captures(node, post_scope)
1107
1550
  # Indexed-collection narrowing — drop any
1108
1551
  # `receiver[key] ||= default` narrowing the analyzer
1109
1552
  # recorded earlier when an intervening `[]=` writes the
@@ -1130,9 +1573,47 @@ module Rigor
1130
1573
  # new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
1131
1574
  # § "Flow-folding" — G2 intervening-call case.
1132
1575
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
1576
+ # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
1577
+ # narrowed to non-nil on a successful-match edge; a later call
1578
+ # that itself runs a regex match rebinds them, so the narrowed
1579
+ # facts must be dropped. We forget them only when the call is
1580
+ # match-CAPABLE (a regex-matching method, or an implicit-self /
1581
+ # unknown-receiver call whose body we cannot prove match-free).
1582
+ # A call provably match-free on a known receiver — `$3.to_i`,
1583
+ # `year < 50` — does NOT clobber, so the multi-statement
1584
+ # `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
1585
+ # while a genuinely interposed match still invalidates.
1586
+ post_scope = post_scope.forget_match_globals if match_capable_call?(node)
1133
1587
  [call_type, post_scope]
1134
1588
  end
1135
1589
 
1590
+ # Method names that (may) run a regex match and therefore rebind
1591
+ # the `$~` family. Conservative over-approximation — a few set
1592
+ # globals only with a Regexp argument, but we do not inspect args.
1593
+ MATCH_CAPABLE_METHODS = %i[
1594
+ =~ match match? gsub gsub! sub sub! scan split slice slice!
1595
+ [] partition rpartition index rindex === grep grep_v
1596
+ ].freeze
1597
+ private_constant :MATCH_CAPABLE_METHODS
1598
+
1599
+ # True when `node` could rebind the regex match-data globals:
1600
+ # a known regex-matching method by name, or an implicit-self /
1601
+ # self-receiver call whose body we cannot inspect for an internal
1602
+ # match. An explicit-receiver call to a non-matching method
1603
+ # (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
1604
+ # the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
1605
+ # narrowed globals. The over-approximation is one-directional: a
1606
+ # user method that secretly matches on an explicit receiver is the
1607
+ # only escape, and re-narrowing on the next real guard recovers —
1608
+ # weighed against the false-positive cost, precision wins here.
1609
+ def match_capable_call?(node)
1610
+ return true unless node.is_a?(Prism::CallNode)
1611
+ return true if MATCH_CAPABLE_METHODS.include?(node.name)
1612
+
1613
+ receiver = node.receiver
1614
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
1615
+ end
1616
+
1136
1617
  # Returns a scope with each ivar's narrowed local binding
1137
1618
  # widened back to its class-ivar seed value when the call
1138
1619
  # is one that could plausibly mutate ivars on the enclosing
@@ -1255,7 +1736,7 @@ module Rigor
1255
1736
  args = matcher.arguments&.arguments || []
1256
1737
  return nil unless args.size == 1
1257
1738
 
1258
- class_name = constant_node_name(args.first)
1739
+ class_name = Source::ConstantPath.qualified_name_or_nil(args.first)
1259
1740
  return nil if class_name.nil?
1260
1741
 
1261
1742
  { local: local_name, kind: :class, class_name: class_name, exact: exact }
@@ -1311,35 +1792,6 @@ module Rigor
1311
1792
  matcher.arguments.nil? || matcher.arguments.arguments.empty?
1312
1793
  end
1313
1794
 
1314
- # Decodes a `Prism::ConstantReadNode` /
1315
- # `Prism::ConstantPathNode` into a colon-joined class
1316
- # name string, or returns nil for any other node
1317
- # shape. Mirrors the conservative envelope used by the
1318
- # `is_a?` / `kind_of?` predicate narrower.
1319
- def constant_node_name(node)
1320
- case node
1321
- when Prism::ConstantReadNode
1322
- node.name.to_s
1323
- when Prism::ConstantPathNode
1324
- flatten_constant_path(node)
1325
- end
1326
- end
1327
-
1328
- def flatten_constant_path(node)
1329
- parts = []
1330
- cursor = node
1331
- while cursor.is_a?(Prism::ConstantPathNode)
1332
- parts.unshift(cursor.name.to_s)
1333
- cursor = cursor.parent
1334
- end
1335
- case cursor
1336
- when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
1337
- when nil then nil # ::Foo absolute root — preserve as-is
1338
- else return nil
1339
- end
1340
- parts.join("::")
1341
- end
1342
-
1343
1795
  # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the
1344
1796
  # post-return facts the merger produces for an
1345
1797
  # `RBS::Extended`-annotated call. Reads through
@@ -1348,9 +1800,8 @@ module Rigor
1348
1800
  # rows for `:always` assert directives (the slice-4a
1349
1801
  # routing places conditional asserts on `truthy_facts` /
1350
1802
  # `falsey_facts`, which `Narrowing.predicate_scopes`
1351
- # consumes). Future plugin contributions that add
1352
- # `:always` assertions at the same call site flow through
1353
- # the same merger and land here.
1803
+ # consumes). Plugin `:always` assertions are handled by
1804
+ # the sibling `apply_plugin_assertions`, not this path.
1354
1805
  def apply_rbs_extended_assertions(call_node, current_scope)
1355
1806
  method_def = resolve_call_method(call_node, current_scope)
1356
1807
  return current_scope if method_def.nil?
@@ -1416,16 +1867,9 @@ module Rigor
1416
1867
  EMPTY_CONTRIBUTIONS = [].freeze
1417
1868
  private_constant :EMPTY_CONTRIBUTIONS
1418
1869
 
1419
- # Per-dispatch collection of plugin narrowing contributions. Mirrors
1420
- # `MethodDispatcher#collect_plugin_contributions`: visit only the
1421
- # registry-ordered subset of plugins that implement a per-call path
1422
- # (`for_statement` = declares a `type_specifier`), gate each path
1423
- # by membership AND by the ADR-52 WD1 method-name gates (every
1424
- # `type_specifier` rule is `methods:`-gated, so the common
1425
- # no-candidate case is a single Set probe; a pruned
1426
- # consultation could only have returned `[]`), and accumulate
1427
- # lazily (shared frozen empty array otherwise). Same contributions in
1428
- # the same order as visiting every plugin; the caller is read-only.
1870
+ # Fast-exit guard: skip if no plugin declares a `type_specifier`, or if
1871
+ # no registered method-name gate matches the call. See
1872
+ # `collect_gated_statement_contributions` for the full consultation.
1429
1873
  def collect_plugin_contributions(registry, call_node, current_scope)
1430
1874
  index = registry.contribution_index
1431
1875
  relevant = index.for_statement
@@ -1437,8 +1881,10 @@ module Rigor
1437
1881
  collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1438
1882
  end
1439
1883
 
1440
- # The post-gate walk, in registry order — the same order the
1441
- # ungated walk used.
1884
+ # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
1885
+ # Visits only plugins in `for_statement` (declare a `type_specifier`),
1886
+ # further gated by the method-name Set probe so the common no-candidate
1887
+ # case is a single lookup. Accumulates lazily; caller is read-only.
1442
1888
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1443
1889
  result = nil
1444
1890
  relevant.each do |plugin|
@@ -1606,12 +2052,25 @@ module Rigor
1606
2052
  # A `:non_escaping` classification (or any block-less call)
1607
2053
  # leaves the post-call scope unchanged.
1608
2054
  def record_closure_escape_if_any(node)
1609
- return scope unless node.block.is_a?(Prism::BlockNode)
2055
+ # ADR-57 slice 3 work-item 1: an escaping block can also be attached
2056
+ # to a RECEIVER call in a chain rather than to `node` itself — the
2057
+ # canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
2058
+ # .parse!(argv)` idiom, where the content-mutating block hangs off
2059
+ # `OptionParser.new` but the statement-level call node is the chained
2060
+ # `.parse!`. A receiver call is evaluated as an expression, never as a
2061
+ # statement, so its block never reaches this escape handler on its own.
2062
+ # Fold each escaping receiver-chain block's content widening into the
2063
+ # continuation here so the captured collection is floored regardless of
2064
+ # how deep in the receiver chain its mutating block lives.
2065
+ post_scope = widen_escaping_receiver_chain_captures(node, scope)
2066
+
2067
+ return post_scope unless node.block.is_a?(Prism::BlockNode)
1610
2068
 
1611
2069
  classification = classify_closure_escape(node)
1612
- return scope if classification == :non_escaping
2070
+ return post_scope if classification == :non_escaping
1613
2071
 
1614
- post_scope = drop_captured_narrowing(node.block, scope)
2072
+ post_scope = drop_captured_narrowing(node.block, post_scope)
2073
+ post_scope = widen_escaping_content_captures(node.block, post_scope)
1615
2074
  post_scope.with_fact(
1616
2075
  Analysis::FactStore::Fact.new(
1617
2076
  bucket: :dynamic_origin,
@@ -1623,6 +2082,224 @@ module Rigor
1623
2082
  )
1624
2083
  end
1625
2084
 
2085
+ # Floor each caller-argument local whose matching parameter the resolved
2086
+ # callee escape-mutates (see the call-site comment). Only self-dispatch
2087
+ # calls resolving to a discovered user def are considered; the per-def
2088
+ # "which parameters escape-mutate" set is memoised on the def node.
2089
+ def widen_callee_escaped_argument_captures(node, base_scope)
2090
+ # Apply to the statement call AND every call in its receiver chain: the
2091
+ # `build_option_parser(options).parse!(argv)` idiom puts the escape-
2092
+ # mutating helper call in the RECEIVER position, where its argument is
2093
+ # never the statement node's own argument.
2094
+ acc = floor_callee_escaped_args_for_call(node, base_scope)
2095
+ receiver = node.receiver
2096
+ while receiver.is_a?(Prism::CallNode)
2097
+ acc = floor_callee_escaped_args_for_call(receiver, acc)
2098
+ receiver = receiver.receiver
2099
+ end
2100
+ acc
2101
+ end
2102
+
2103
+ def floor_callee_escaped_args_for_call(node, base_scope)
2104
+ return base_scope unless self_dispatch_call?(node)
2105
+ # Fast path — the floor only ever touches a local passed as an
2106
+ # argument, so a call with no arguments cannot floor anything. Skip the
2107
+ # def resolution + body scan entirely (the overwhelming common case).
2108
+ return base_scope unless call_passes_local_argument?(node)
2109
+
2110
+ def_node = resolve_self_callee_def(node)
2111
+ return base_scope if def_node.nil?
2112
+
2113
+ escaped = escaped_content_parameters(def_node)
2114
+ return base_scope if escaped.empty?
2115
+
2116
+ floor_arguments_at_positions(node, escaped, base_scope)
2117
+ end
2118
+
2119
+ # The user def a self-dispatch `node` resolves to in the enclosing class,
2120
+ # or nil. Reuses the discovery index `Scope#user_def_for` reads; no
2121
+ # ancestor walk (the boundary-escape idiom is same-class), keeping this
2122
+ # off the hot path for the overwhelming majority of self-calls that
2123
+ # resolve to nothing escaping.
2124
+ def resolve_self_callee_def(node)
2125
+ class_name = enclosing_class_name_for(scope.self_type)
2126
+ return scope.top_level_def_for(node.name) if class_name.nil?
2127
+
2128
+ scope.user_def_for(class_name, node.name)
2129
+ end
2130
+
2131
+ def self_dispatch_call?(node)
2132
+ return false unless node.is_a?(Prism::CallNode)
2133
+
2134
+ node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
2135
+ end
2136
+
2137
+ # The set of `[name, position]` parameters of `def_node` whose content a
2138
+ # block in the body escape-mutates. Memoised per def node (the body walk
2139
+ # is otherwise repeated at every call site). A parameter is "escape-
2140
+ # mutated" when a `param[k] = v` / `param << x` mutation on it appears
2141
+ # inside a block whose receiving call is not proven non-escaping.
2142
+ def escaped_content_parameters(def_node)
2143
+ cache = (@escaped_param_cache ||= {}.compare_by_identity)
2144
+ cache[def_node] ||= compute_escaped_content_parameters(def_node)
2145
+ end
2146
+
2147
+ def compute_escaped_content_parameters(def_node)
2148
+ positions = positional_parameter_positions(def_node)
2149
+ return {} if positions.empty?
2150
+
2151
+ mutated = Set.new
2152
+ Source::NodeWalker.each(def_node.body) do |descendant|
2153
+ next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
2154
+ next if syntactically_non_escaping_call?(descendant)
2155
+
2156
+ collect_content_mutations(descendant.block.body).each_key do |name|
2157
+ mutated << name if positions.key?(name)
2158
+ end
2159
+ end
2160
+ positions.slice(*mutated)
2161
+ end
2162
+
2163
+ # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
2164
+ # non-escaping verdict, used when scanning a callee body where the block-
2165
+ # owning call's receiver TYPE is not available. A call whose method name
2166
+ # is a known structural iterator (`each` / `map` / `tap` / …) runs its
2167
+ # block synchronously and does not retain it, so its captured mutations
2168
+ # are not a cross-boundary escape. Any other name (`on`, `subscribe`,
2169
+ # `define_method`, an unknown DSL hook) is treated as escaping — sound,
2170
+ # since mis-classifying a truly-non-escaping call only floors an argument
2171
+ # that was about to be precise.
2172
+ SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
2173
+ ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
2174
+ ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
2175
+ ClosureEscapeAnalyzer::ARRAY_EXTRA +
2176
+ ClosureEscapeAnalyzer::HASH_EXTRA +
2177
+ ClosureEscapeAnalyzer::RANGE_EXTRA +
2178
+ ClosureEscapeAnalyzer::INTEGER_EXTRA
2179
+ ).to_set.freeze
2180
+ private_constant :SYNTACTIC_NON_ESCAPING_BLOCK_METHODS
2181
+
2182
+ def syntactically_non_escaping_call?(call_node)
2183
+ SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
2184
+ end
2185
+
2186
+ # `{ name => position }` for the required / optional positional
2187
+ # parameters of a def. Keyword / rest / block parameters are skipped —
2188
+ # the boundary-escape idiom passes a plain positional collection.
2189
+ def positional_parameter_positions(def_node)
2190
+ params = def_node.parameters
2191
+ return {} if params.nil?
2192
+
2193
+ ordered = (params.requireds || []) + (params.optionals || [])
2194
+ positions = {}
2195
+ ordered.each_with_index do |param, index|
2196
+ positions[param.name] = index if param.respond_to?(:name)
2197
+ end
2198
+ positions
2199
+ end
2200
+
2201
+ # True when at least one argument of `node` is a bare local-variable read
2202
+ # (positional or keyword value) bound in the current scope — a cheap
2203
+ # pre-filter so the def resolution / body scan only runs for calls that
2204
+ # could actually floor something.
2205
+ def call_passes_local_argument?(node)
2206
+ args = node.arguments
2207
+ return false unless args.respond_to?(:arguments)
2208
+
2209
+ args.arguments.any? do |arg|
2210
+ case arg
2211
+ when Prism::LocalVariableReadNode
2212
+ scope.locals.key?(arg.name)
2213
+ when Prism::KeywordHashNode
2214
+ arg.elements.any? do |pair|
2215
+ pair.is_a?(Prism::AssocNode) &&
2216
+ pair.value.is_a?(Prism::LocalVariableReadNode) &&
2217
+ scope.locals.key?(pair.value.name)
2218
+ end
2219
+ else
2220
+ false
2221
+ end
2222
+ end
2223
+ end
2224
+
2225
+ def floor_arguments_at_positions(node, positions, base_scope)
2226
+ args = node.arguments
2227
+ return base_scope unless args.respond_to?(:arguments)
2228
+
2229
+ argument_nodes = args.arguments
2230
+ positions.values.uniq.reduce(base_scope) do |acc, index|
2231
+ arg = argument_nodes[index]
2232
+ next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
2233
+
2234
+ floored = content_floor_for(acc.local(arg.name))
2235
+ floored.nil? ? acc : acc.with_local(arg.name, floored)
2236
+ end
2237
+ end
2238
+
2239
+ # Walk the receiver chain of `node` and fold the escaping-content
2240
+ # widening of every block-bearing, escaping receiver call into
2241
+ # `base_scope`. Only receiver calls are walked — `node` itself is handled
2242
+ # by the caller. A `:non_escaping` receiver block is left to slice C's
2243
+ # non-escaping write-back (which the receiver expression evaluation
2244
+ # already drives), so we only floor the escaping / unknown ones here.
2245
+ def widen_escaping_receiver_chain_captures(node, base_scope)
2246
+ receiver = node.receiver
2247
+ acc = base_scope
2248
+ while receiver.is_a?(Prism::CallNode)
2249
+ if receiver.block.is_a?(Prism::BlockNode) &&
2250
+ classify_closure_escape(receiver) != :non_escaping
2251
+ acc = widen_escaping_content_captures(receiver.block, acc)
2252
+ end
2253
+ receiver = receiver.receiver
2254
+ end
2255
+ acc
2256
+ end
2257
+
2258
+ # ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
2259
+ # An escaping / unknown block that CONTENT-mutates a captured outer
2260
+ # local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
2261
+ # stored proc) previously left that local's content untouched — only its
2262
+ # narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
2263
+ # survived and its element fold (`options[:format]` -> `"text"`,
2264
+ # `s.empty?` -> `true`) was unsoundly precise.
2265
+ #
2266
+ # An escaping block may run later and any number of times, so joining a
2267
+ # bounded evidence set is not sound (unlike slice C's non-escaping
2268
+ # join): the sound continuation is the bare-collection floor — Array ->
2269
+ # `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
2270
+ # `String`. The seed's element/key/value precision is forgotten; only
2271
+ # the carrier survives. Read-only captures and locals the block merely
2272
+ # rebinds (already floored by `drop_captured_narrowing`) are untouched.
2273
+ def widen_escaping_content_captures(block_node, post_scope)
2274
+ body = block_node.body
2275
+ return post_scope if body.nil?
2276
+
2277
+ mutations = collect_content_mutations(body)
2278
+ return post_scope if mutations.empty?
2279
+
2280
+ mutations.keys.reduce(post_scope) do |acc, name|
2281
+ floored = content_floor_for(acc.local(name))
2282
+ floored.nil? ? acc : acc.with_local(name, floored)
2283
+ end
2284
+ end
2285
+
2286
+ # The Dynamic-floor carrier for a content-mutated escaping capture, or
2287
+ # nil when the pre-state is not a recognised mutable collection (leave
2288
+ # it alone — e.g. an already-`Dynamic` binding or an unknown shape).
2289
+ def content_floor_for(type)
2290
+ return nil if type.nil?
2291
+
2292
+ if stringish?(type)
2293
+ Type::Combinator.nominal_of("String")
2294
+ elsif hashish?(type)
2295
+ Type::Combinator.nominal_of("Hash",
2296
+ type_args: [Type::Combinator.untyped,
2297
+ Type::Combinator.untyped])
2298
+ elsif arrayish?(type)
2299
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
2300
+ end
2301
+ end
2302
+
1626
2303
  def classify_closure_escape(call_node)
1627
2304
  receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
1628
2305
  ClosureEscapeAnalyzer.classify(
@@ -1649,6 +2326,22 @@ module Rigor
1649
2326
  names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
1650
2327
  end
1651
2328
 
2329
+ # Names of outer locals the block body can REBIND, across every
2330
+ # local-write form: plain `=` (`LocalVariableWriteNode`), the
2331
+ # operator / `||=` / `&&=` compound forms, and a multi-assign target
2332
+ # (`x, y = ...` → `LocalVariableTargetNode` under `MultiWriteNode`).
2333
+ # Block-introduced names (parameters, numbered params, `;`-locals) and
2334
+ # names not bound in the outer scope are excluded — a write to either
2335
+ # is not a captured rebind of an outer variable.
2336
+ LOCAL_WRITE_NODES = [
2337
+ Prism::LocalVariableWriteNode,
2338
+ Prism::LocalVariableOperatorWriteNode,
2339
+ Prism::LocalVariableOrWriteNode,
2340
+ Prism::LocalVariableAndWriteNode,
2341
+ Prism::LocalVariableTargetNode
2342
+ ].freeze
2343
+ private_constant :LOCAL_WRITE_NODES
2344
+
1652
2345
  def captured_local_writes(block_node, base_scope)
1653
2346
  body = block_node.body
1654
2347
  return [] if body.nil?
@@ -1656,7 +2349,7 @@ module Rigor
1656
2349
  introduced = block_introduced_locals(block_node)
1657
2350
  outer_writes = []
1658
2351
  Source::NodeWalker.each(body) do |descendant|
1659
- next unless descendant.is_a?(Prism::LocalVariableWriteNode)
2352
+ next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
1660
2353
  next if introduced.include?(descendant.name)
1661
2354
  next unless base_scope.locals.key?(descendant.name)
1662
2355
 
@@ -1665,6 +2358,313 @@ module Rigor
1665
2358
  outer_writes.uniq
1666
2359
  end
1667
2360
 
2361
+ # ADR-56 slice A. For a `:non_escaping` block, fold the continuation
2362
+ # binding of every outer local the body can rebind back into
2363
+ # `post_scope`. The binding is a capped fixpoint (cap 3) over the
2364
+ # block body re-evaluated under the running per-name assumption,
2365
+ # joined with the pre-call binding (kept as a constituent so the
2366
+ # 0-iteration path — `[].each { … }` — stays sound), value-pinned-
2367
+ # widened on the final permitted iteration, and floored to
2368
+ # `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
2369
+ #
2370
+ # Fast path: a block writing no outer local leaves `post_scope`
2371
+ # byte-identical (the overwhelming majority of blocks), so this costs
2372
+ # one extra `captured_local_writes` walk and nothing else.
2373
+ def write_back_block_captures(call_node, post_scope)
2374
+ block = call_node.block
2375
+ return post_scope unless block.is_a?(Prism::BlockNode)
2376
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
2377
+
2378
+ names = captured_local_writes(block, scope)
2379
+ return post_scope if names.empty?
2380
+
2381
+ seed = names.to_h { |name| [name, scope.local(name)] }
2382
+ result = BodyFixpoint.converge(
2383
+ names: names,
2384
+ seed_bindings: seed,
2385
+ widen: Type::Combinator.method(:widen_value_pinned),
2386
+ evaluate_body: ->(bindings) { block_exit_bindings(call_node, block, bindings, names) }
2387
+ )
2388
+
2389
+ result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
2390
+ end
2391
+
2392
+ # ADR-56 slice C — receiver-content element-type join. After the
2393
+ # rebind write-back and `MutationWidening.widen_after_block` (which
2394
+ # forgets a content-mutated collection's literal arity but keeps only
2395
+ # the SEED's element types), join the appended/stored element / key /
2396
+ # value types INTO the continuation collection's parameter, so
2397
+ # `out = [0]; arr.each { |x| out << x }` types `out` as
2398
+ # `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
2399
+ # under-approximation: the runtime array is `[0, 1, 2, 3]`).
2400
+ #
2401
+ # Pre-state is read from `post_scope` so a local that is BOTH rebound
2402
+ # and content-mutated composes: the rebind fixpoint result feeds the
2403
+ # content join. The block body is typed once for argument evidence;
2404
+ # the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
2405
+ # sound empty-seed behaviour). Always sound — only ever widens.
2406
+ def content_writeback_block_captures(call_node, post_scope)
2407
+ block = call_node.block
2408
+ return post_scope unless block.is_a?(Prism::BlockNode)
2409
+ return post_scope unless classify_closure_escape(call_node) == :non_escaping
2410
+
2411
+ body = block.body
2412
+ return post_scope if body.nil?
2413
+
2414
+ mutations = collect_content_mutations(body)
2415
+ return post_scope if mutations.empty?
2416
+
2417
+ entry = build_block_entry_scope(call_node, block)
2418
+ mutations.reduce(post_scope) do |acc, (name, calls)|
2419
+ joined = join_content_for_local(name, calls, acc, entry)
2420
+ joined.nil? ? acc : acc.with_local(name, joined)
2421
+ end
2422
+ end
2423
+
2424
+ # ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
2425
+ # the return is the memo object after the block has mutated it through
2426
+ # the `acc` alias. Compute the joined memo type the same way captured-
2427
+ # local content mutations are joined: pre-state = the memo argument's
2428
+ # type, added evidence = the content-mutator args on the memo block
2429
+ # param. Returns `call_type` unchanged for any other call, a missing
2430
+ # block, or a memo whose pre-state is not a collection.
2431
+ def each_with_object_return(call_node, call_type)
2432
+ return call_type unless call_node.name == :each_with_object
2433
+
2434
+ block = call_node.block
2435
+ return call_type unless block.is_a?(Prism::BlockNode)
2436
+
2437
+ memo_arg = call_node.arguments&.arguments&.first
2438
+ return call_type if memo_arg.nil?
2439
+
2440
+ memo_param = each_with_object_memo_param(block)
2441
+ return call_type if memo_param.nil?
2442
+
2443
+ body = block.body
2444
+ return call_type if body.nil?
2445
+
2446
+ # The memo alias is a block-local (depth 0) — collect content
2447
+ # mutations on it directly rather than via the captured-local walk.
2448
+ calls = body_content_mutations_on(body, memo_param)
2449
+ return call_type if calls.empty?
2450
+
2451
+ pre_state = scope.type_of(memo_arg, tracer: tracer)
2452
+ entry = build_block_entry_scope(call_node, block)
2453
+ joined = join_content_for_param(calls, pre_state, entry)
2454
+ joined || call_type
2455
+ end
2456
+
2457
+ # The name of the memo block parameter (the SECOND positional param of
2458
+ # an `each_with_object` block), or nil when the block does not bind a
2459
+ # second positional param.
2460
+ def each_with_object_memo_param(block)
2461
+ params_root = block.parameters
2462
+ return nil unless params_root.is_a?(Prism::BlockParametersNode)
2463
+
2464
+ params = params_root.parameters
2465
+ return nil if params.nil?
2466
+
2467
+ requireds = params.requireds
2468
+ return nil if requireds.size < 2
2469
+
2470
+ second = requireds[1]
2471
+ second.respond_to?(:name) ? second.name : nil
2472
+ end
2473
+
2474
+ # Content-mutator calls on a block-local receiver `var_name`
2475
+ # (depth 0) within `body`.
2476
+ def body_content_mutations_on(body, var_name)
2477
+ calls = []
2478
+ Source::NodeWalker.each(body) do |descendant|
2479
+ next unless descendant.is_a?(Prism::CallNode)
2480
+ next unless MutationWidening::CONTENT_ADDERS.include?(descendant.name)
2481
+
2482
+ receiver = descendant.receiver
2483
+ next unless receiver.is_a?(Prism::LocalVariableReadNode)
2484
+ next unless receiver.name == var_name
2485
+
2486
+ calls << descendant
2487
+ end
2488
+ calls
2489
+ end
2490
+
2491
+ # Joins content evidence for a memo / param given its pre-state and a
2492
+ # list of mutator calls, dispatching Array vs Hash by the mutator set.
2493
+ def join_content_for_param(calls, pre_state, block_entry)
2494
+ return nil if pre_state.nil?
2495
+
2496
+ if stringish?(pre_state)
2497
+ # String carries no element parameter; mutating `<<`/`concat`
2498
+ # makes the constant value unsound (`s = "a"; s << x` → runtime
2499
+ # `"a…"`), so widen to the nominal base. Sound — only widens.
2500
+ Type::Combinator.nominal_of("String")
2501
+ elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
2502
+ join_hash_param(calls, pre_state, block_entry)
2503
+ else
2504
+ join_array_param(calls, pre_state, block_entry)
2505
+ end
2506
+ end
2507
+
2508
+ def join_hash_param(calls, pre_state, block_entry)
2509
+ pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
2510
+ return nil if pairs.empty? && !hashish?(pre_state)
2511
+
2512
+ MutationWidening.join_hash_content(pre_state, pairs)
2513
+ end
2514
+
2515
+ def join_array_param(calls, pre_state, block_entry)
2516
+ return nil unless arrayish?(pre_state)
2517
+
2518
+ added = calls.flat_map do |c|
2519
+ # Index-write on an array (`a[i] += v`) introduces no new element
2520
+ # evidence we can cheaply attribute — the array-arity forget
2521
+ # already widened the binding; contribute nothing.
2522
+ next [] if index_write?(c)
2523
+
2524
+ MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
2525
+ end
2526
+ MutationWidening.join_array_content(pre_state, added)
2527
+ end
2528
+
2529
+ # Walks the block body for content-mutator calls (`<<`, `push`,
2530
+ # `[]=`, …) whose receiver is a captured outer local (depth >= 1),
2531
+ # returning `{ name => [call_node, ...] }`. Mirrors the
2532
+ # `MutationWidening.widen_after_block` walk (descends into nested
2533
+ # blocks; the depth check keeps nested block-locals out).
2534
+ def collect_content_mutations(body)
2535
+ mutations = Hash.new { |h, k| h[k] = [] }
2536
+ Source::NodeWalker.each(body) do |descendant|
2537
+ name, node = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.positive? }
2538
+ mutations[name] << node unless name.nil?
2539
+ end
2540
+ mutations
2541
+ end
2542
+
2543
+ # Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
2544
+ # multi-assign target) that mutate a collection's CONTENT without a
2545
+ # `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
2546
+ # OrWrite even though the appended values land on the nested array —
2547
+ # leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
2548
+ INDEX_WRITE_NODES = [
2549
+ Prism::IndexOrWriteNode,
2550
+ Prism::IndexAndWriteNode,
2551
+ Prism::IndexOperatorWriteNode,
2552
+ Prism::IndexTargetNode
2553
+ ].freeze
2554
+ private_constant :INDEX_WRITE_NODES
2555
+
2556
+ # `[receiver_name, node]` when `node` is a content mutation whose
2557
+ # receiver is a local variable satisfying `accept` (depth predicate),
2558
+ # else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
2559
+ # index-write node forms.
2560
+ def content_mutation_target(node)
2561
+ is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
2562
+ return [nil, nil] unless is_call_mutator || index_write?(node)
2563
+
2564
+ receiver = node.receiver
2565
+ return [nil, nil] unless receiver.is_a?(Prism::LocalVariableReadNode)
2566
+ return [nil, nil] unless yield(receiver)
2567
+
2568
+ [receiver.name, node]
2569
+ end
2570
+
2571
+ # Computes the joined continuation collection type for one captured
2572
+ # local from its content-mutator calls. Returns `nil` (no overlay)
2573
+ # when the pre-state is neither an Array-ish nor a Hash-ish binding —
2574
+ # e.g. a String accumulator, whose `<<` carries no element parameter
2575
+ # and whose binding already types as `String`.
2576
+ def join_content_for_local(name, calls, post_scope, block_entry)
2577
+ join_content_for_param(calls, post_scope.local(name), block_entry)
2578
+ end
2579
+
2580
+ def hash_mutations?(calls)
2581
+ calls.any? do |c|
2582
+ index_write?(c) || (c.is_a?(Prism::CallNode) && MutationWidening::HASH_CONTENT_ADDERS.include?(c.name))
2583
+ end
2584
+ end
2585
+
2586
+ def index_write?(node)
2587
+ INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
2588
+ end
2589
+
2590
+ def arrayish?(type)
2591
+ case type
2592
+ when Type::Tuple then true
2593
+ when Type::Nominal then type.class_name == "Array"
2594
+ when Type::Union then type.members.any? { |m| arrayish?(m) }
2595
+ else false
2596
+ end
2597
+ end
2598
+
2599
+ def hashish?(type)
2600
+ case type
2601
+ when Type::HashShape then true
2602
+ when Type::Nominal then type.class_name == "Hash"
2603
+ when Type::Union then type.members.any? { |m| hashish?(m) }
2604
+ else false
2605
+ end
2606
+ end
2607
+
2608
+ def stringish?(type)
2609
+ (type.is_a?(Type::Constant) && type.value.is_a?(String)) ||
2610
+ (type.is_a?(Type::Nominal) && type.class_name == "String")
2611
+ end
2612
+
2613
+ # `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
2614
+ # an index-write node (`h[k] ||= v`), typed in the block-entry scope.
2615
+ # For an index-write the stored value is opaque (the appended values
2616
+ # often land on a NESTED collection via `h[k] << v`), so the value is
2617
+ # floored to `untyped` — sound: it only ever widens the value param.
2618
+ # Returns `[]` for other forms.
2619
+ def hash_pair_types(node, block_entry)
2620
+ if index_write?(node)
2621
+ key = index_key_type(node, block_entry)
2622
+ return [] if key.nil?
2623
+
2624
+ return [[key, Type::Combinator.untyped]]
2625
+ end
2626
+
2627
+ args = content_arg_types(node, block_entry)
2628
+ return [] if args.size < 2
2629
+
2630
+ [[args.first, args.last]]
2631
+ end
2632
+
2633
+ # Type of the index expression of an index-write node (`h[k] ||= v`).
2634
+ def index_key_type(node, block_entry)
2635
+ args = node.arguments
2636
+ return nil unless args.is_a?(Prism::ArgumentsNode)
2637
+
2638
+ first = args.arguments.first
2639
+ first.nil? ? nil : block_entry.type_of(first, tracer: tracer)
2640
+ rescue StandardError
2641
+ nil
2642
+ end
2643
+
2644
+ # Argument types for a content-mutator call, typed against the
2645
+ # block-entry scope (block params bound). A sub-evaluator over
2646
+ # `block_entry` keeps the argument typing flow-correct for params /
2647
+ # `;`-locals without leaking into the outer scope.
2648
+ def content_arg_types(call_node, block_entry)
2649
+ arguments = call_node.arguments
2650
+ return [] if arguments.nil?
2651
+
2652
+ arguments.arguments.map { |arg| block_entry.type_of(arg, tracer: tracer) }
2653
+ rescue StandardError
2654
+ []
2655
+ end
2656
+
2657
+ # Evaluates `block`'s body once with each written outer local bound to
2658
+ # the supplied `bindings` (block params / `;`-locals re-bound as
2659
+ # usual) and returns the per-name exit binding for `names`. Used as
2660
+ # the `BodyFixpoint` body-evaluator.
2661
+ def block_exit_bindings(call_node, block, bindings, names)
2662
+ entry = build_block_entry_scope(call_node, block)
2663
+ entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
2664
+ _type, exit_scope = sub_eval(block, entry)
2665
+ names.to_h { |name| [name, exit_scope.local(name)] }
2666
+ end
2667
+
1668
2668
  # Names introduced by the block itself (parameters, numbered
1669
2669
  # parameters via `BlockParameterBinder`, plus explicit
1670
2670
  # `;`-prefixed block-locals on `BlockParametersNode`). Writes
@@ -1769,6 +2769,12 @@ module Rigor
1769
2769
  source_path: scope.source_path
1770
2770
  )
1771
2771
  bindings = binder.bind(def_node)
2772
+ # ADR-67 WD3 — override an undeclared parameter with its call-site
2773
+ # inferred type (precision-additive; an RBS-declared parameter wins,
2774
+ # the table is empty on a normal `check` run). The inferred type lives
2775
+ # only as a body local, never as an RBS contract, so it cannot fire a
2776
+ # parameter-boundary diagnostic (WD1, satisfied by construction).
2777
+ bindings = seed_inferred_param_types(bindings, def_node, singleton)
1772
2778
 
1773
2779
  # Method bodies do NOT see the outer scope's locals. They start
1774
2780
  # from a fresh scope with the same environment, then receive
@@ -1782,9 +2788,47 @@ module Rigor
1782
2788
  fresh = seed_instance_ivars(fresh, singleton: singleton)
1783
2789
  fresh = seed_class_cvars(fresh)
1784
2790
  fresh = seed_program_globals(fresh)
2791
+ # ADR-48 Struct slice 3 — install the method body's fold-safe-local set
2792
+ # so a member read off a mutation-free local folds during the in-body
2793
+ # walk (the call-return inference path is seeded separately).
2794
+ fresh = fresh.with_struct_fold_safe(
2795
+ StructFoldSafety.fold_safe_locals(
2796
+ def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2797
+ )
2798
+ )
1785
2799
  bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
1786
2800
  end
1787
2801
 
2802
+ # ADR-67 WD3 — consult the call-site parameter-inference table for this
2803
+ # `def` and replace each undeclared (untyped) parameter binding with its
2804
+ # inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2805
+ # from the lexical class path — the same triple
2806
+ # {Inference::ParameterInferenceCollector} records. An RBS-declared
2807
+ # parameter (a non-untyped binding) always wins. No-op when the table is
2808
+ # empty (the normal `check` path), so the seed is byte-identical there.
2809
+ def seed_inferred_param_types(bindings, def_node, singleton)
2810
+ inferred = scope.param_inferred_types
2811
+ return bindings if inferred.empty?
2812
+
2813
+ path = current_class_path
2814
+ return bindings if path.nil?
2815
+
2816
+ table = inferred[[path, def_node.name, singleton ? :singleton : :instance]]
2817
+ return bindings if table.nil? || table.empty?
2818
+
2819
+ merged = bindings.dup
2820
+ table.each do |name, type|
2821
+ merged[name] = type if merged.key?(name) && untyped_binding?(merged[name])
2822
+ end
2823
+ merged
2824
+ end
2825
+
2826
+ # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
2827
+ # undeclared parameter — the only bindings ADR-67 WD3 overrides.
2828
+ def untyped_binding?(type)
2829
+ type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
2830
+ end
2831
+
1788
2832
  def seed_instance_ivars(body_scope, singleton:)
1789
2833
  return body_scope if singleton
1790
2834
 
@@ -1794,7 +2838,14 @@ module Rigor
1794
2838
  seeded = scope.class_ivars_for(path)
1795
2839
  return body_scope if seeded.empty?
1796
2840
 
1797
- seeded.reduce(body_scope) { |acc, (name, type)| acc.with_ivar(name, type) }
2841
+ # ADR-58 WD1 the class-ivar index unions every `@x = …` write across
2842
+ # the class flow-insensitively, so a ctor `@x = nil` seed makes a read
2843
+ # in a *different* method type `T | nil`. That `nil` is
2844
+ # declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
2845
+ # marks each seeded ivar: `possible-nil-receiver` then declines to fire
2846
+ # on the cross-method invariant unless a method-local write or
2847
+ # narrowing makes the nil flow-live (which drops the mark).
2848
+ seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
1798
2849
  end
1799
2850
 
1800
2851
  # Cvars are visible from BOTH instance and singleton method
@@ -1868,7 +2919,7 @@ module Rigor
1868
2919
  when Prism::ConstantReadNode
1869
2920
  receiver.name.to_s == prefix.last
1870
2921
  when Prism::ConstantPathNode
1871
- rendered = render_constant_path(receiver)
2922
+ rendered = Source::ConstantPath.render(receiver)
1872
2923
  return false unless rendered
1873
2924
 
1874
2925
  path = rendered.split("::")
@@ -1908,25 +2959,6 @@ module Rigor
1908
2959
  end
1909
2960
  end
1910
2961
 
1911
- def qualified_name_for(constant_path_node)
1912
- case constant_path_node
1913
- when Prism::ConstantReadNode
1914
- constant_path_node.name.to_s
1915
- when Prism::ConstantPathNode
1916
- render_constant_path(constant_path_node)
1917
- end
1918
- end
1919
-
1920
- def render_constant_path(node)
1921
- prefix =
1922
- case node.parent
1923
- when Prism::ConstantReadNode then "#{node.parent.name}::"
1924
- when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
1925
- else ""
1926
- end
1927
- "#{prefix}#{node.name}"
1928
- end
1929
-
1930
2962
  def singleton_context_for(node)
1931
2963
  case node.expression
1932
2964
  when Prism::SelfNode
@@ -1965,7 +2997,7 @@ module Rigor
1965
2997
  when Prism::ConstantReadNode
1966
2998
  expression.name.to_s
1967
2999
  when Prism::ConstantPathNode
1968
- render_constant_path(expression)
3000
+ Source::ConstantPath.render(expression)
1969
3001
  end
1970
3002
  end
1971
3003
 
@@ -1985,12 +3017,57 @@ module Rigor
1985
3017
 
1986
3018
  # ----- helpers -----
1987
3019
 
3020
+ # Explicit `return value` (including `return` inside a block, which in
3021
+ # Ruby returns from the *enclosing method*). The control-transfer value
3022
+ # is `Bot` — a `return` produces no value at its own position — but the
3023
+ # returned expression's type is recorded into the active return sink so
3024
+ # the method-return inference joins it with the body's tail type.
3025
+ # Returns inside a nested `def`/lambda are barriers: `eval_def` clears
3026
+ # the sink around the nested body, so this handler only ever appends a
3027
+ # return that genuinely exits the method currently being inferred.
3028
+ def eval_return(node)
3029
+ sink = Thread.current[RETURN_SINK_KEY]
3030
+ record_return_value(node, sink) if sink
3031
+ [Type::Combinator.bot, scope]
3032
+ end
3033
+
3034
+ # A `break` transfers control to the loop exit (its flow value is `Bot`,
3035
+ # like `return`). It records the current scope into the active loop's
3036
+ # break sink so the loop join can recover a `break`-path binding the
3037
+ # fall-through would drop (`flag = true; break` -> `flag` is `false |
3038
+ # true` after the loop). nil sink = a `break` not inside an inferred
3039
+ # loop body (a block targeting a method, or top-level) — left to the
3040
+ # existing escaping-block / no-op handling.
3041
+ def eval_break(node)
3042
+ sink = Thread.current[BREAK_SINK_KEY]
3043
+ sink << [node, scope] if sink
3044
+ [Type::Combinator.bot, scope]
3045
+ end
3046
+
3047
+ def record_return_value(node, sink)
3048
+ args = node.arguments&.arguments || []
3049
+ # `return` with no argument returns nil; `return a` records the
3050
+ # argument's type; `return a, b, c` packs a Tuple — in Ruby a
3051
+ # multi-value return yields the array `[a, b, c]`, so the inferred
3052
+ # return contributes the corresponding Tuple element-by-element.
3053
+ if args.empty?
3054
+ sink << Type::Combinator.constant_of(nil)
3055
+ elsif args.size == 1
3056
+ type, = sub_eval(args.first, scope)
3057
+ sink << type
3058
+ else
3059
+ element_types = args.map { |arg| sub_eval(arg, scope).first }
3060
+ sink << Type::Combinator.tuple_of(*element_types)
3061
+ end
3062
+ end
3063
+
1988
3064
  def sub_eval(node, with_scope, class_context: @class_context)
1989
3065
  StatementEvaluator.new(
1990
3066
  scope: with_scope,
1991
3067
  tracer: tracer,
1992
3068
  on_enter: @on_enter,
1993
- class_context: class_context
3069
+ class_context: class_context,
3070
+ converged_loop_recording: @converged_loop_recording
1994
3071
  ).evaluate(node)
1995
3072
  end
1996
3073