rigortype 0.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,1017 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "../analysis/fact_store"
7
+ require_relative "../source/node_walker"
8
+ require_relative "block_parameter_binder"
9
+ require_relative "closure_escape_analyzer"
10
+ require_relative "method_dispatcher"
11
+ require_relative "method_parameter_binder"
12
+ require_relative "multi_target_binder"
13
+ require_relative "narrowing"
14
+
15
+ module Rigor
16
+ module Inference
17
+ # Statement-level evaluator that complements `Rigor::Inference::ExpressionTyper`
18
+ # by threading an immutable {Rigor::Scope} through control-flow constructs.
19
+ # The output is the pair `[Rigor::Type, Rigor::Scope]`: the type that the
20
+ # evaluated node produces, and the scope that callers should observe
21
+ # AFTER the node has run.
22
+ #
23
+ # Slice 3 phase 2 ships the evaluator surface and the scope-threading
24
+ # rules for the canonical statement-y nodes:
25
+ #
26
+ # - sequential evaluation across `Prism::StatementsNode`/`ProgramNode`,
27
+ # - local-variable assignment (`Prism::LocalVariableWriteNode`) binding
28
+ # the rvalue's type into the post-scope,
29
+ # - branching constructs (`IfNode`, `UnlessNode`, `CaseNode`,
30
+ # `CaseMatchNode`, `BeginNode`/`RescueNode`/`EnsureNode`,
31
+ # `WhileNode`/`UntilNode`, `AndNode`/`OrNode`) that evaluate each
32
+ # branch under a forked scope and merge the results with
33
+ # nil-injection on half-bound names,
34
+ # - pass-through helpers for `ParenthesesNode`, `ElseNode`,
35
+ # `WhenNode`/`InNode`, and `RescueNode`.
36
+ #
37
+ # Anything outside the catalogue defers to `Rigor::Scope#type_of` and
38
+ # returns the receiver scope unchanged. This matches the Slice 1
39
+ # fail-soft policy: an unrecognised statement-level node MUST NOT
40
+ # raise and MUST keep the scope intact.
41
+ #
42
+ # The class is stateful (`@scope`, `@tracer`) but every public call
43
+ # returns fresh values; the receiver scope MUST never be mutated.
44
+ # Recursive evaluation always allocates a new instance with the
45
+ # forked scope so different branches stay isolated.
46
+ #
47
+ # See docs/internal-spec/inference-engine.md for the public contract
48
+ # and docs/adr/4-type-inference-engine.md for the slice rationale.
49
+ # rubocop:disable Metrics/ClassLength
50
+ class StatementEvaluator
51
+ # Hash-based dispatch keeps `evaluate` linear and lets future slices
52
+ # add control-flow node kinds without growing a single case
53
+ # statement past RuboCop's cyclomatic budget. Anonymous Prism
54
+ # subclasses are not expected.
55
+ HANDLERS = {
56
+ Prism::StatementsNode => :eval_statements,
57
+ Prism::ProgramNode => :eval_program,
58
+ Prism::LocalVariableWriteNode => :eval_local_write,
59
+ Prism::LocalVariableOrWriteNode => :eval_local_or_write,
60
+ Prism::LocalVariableAndWriteNode => :eval_local_and_write,
61
+ Prism::LocalVariableOperatorWriteNode => :eval_local_operator_write,
62
+ Prism::InstanceVariableWriteNode => :eval_ivar_write,
63
+ Prism::InstanceVariableOrWriteNode => :eval_ivar_or_write,
64
+ Prism::InstanceVariableAndWriteNode => :eval_ivar_and_write,
65
+ Prism::InstanceVariableOperatorWriteNode => :eval_ivar_operator_write,
66
+ Prism::ClassVariableWriteNode => :eval_cvar_write,
67
+ Prism::ClassVariableOrWriteNode => :eval_cvar_or_write,
68
+ Prism::ClassVariableAndWriteNode => :eval_cvar_and_write,
69
+ Prism::ClassVariableOperatorWriteNode => :eval_cvar_operator_write,
70
+ Prism::GlobalVariableWriteNode => :eval_global_write,
71
+ Prism::GlobalVariableOrWriteNode => :eval_global_or_write,
72
+ Prism::GlobalVariableAndWriteNode => :eval_global_and_write,
73
+ Prism::GlobalVariableOperatorWriteNode => :eval_global_operator_write,
74
+ Prism::MultiWriteNode => :eval_multi_write,
75
+ Prism::IfNode => :eval_if,
76
+ Prism::UnlessNode => :eval_unless,
77
+ Prism::ElseNode => :eval_else,
78
+ Prism::CaseNode => :eval_case,
79
+ Prism::CaseMatchNode => :eval_case,
80
+ Prism::WhenNode => :eval_when_or_in,
81
+ Prism::InNode => :eval_when_or_in,
82
+ Prism::BeginNode => :eval_begin,
83
+ Prism::RescueNode => :eval_rescue,
84
+ Prism::EnsureNode => :eval_ensure,
85
+ Prism::WhileNode => :eval_loop,
86
+ Prism::UntilNode => :eval_loop,
87
+ Prism::AndNode => :eval_and_or,
88
+ Prism::OrNode => :eval_and_or,
89
+ Prism::ParenthesesNode => :eval_parentheses,
90
+ Prism::DefNode => :eval_def,
91
+ Prism::ClassNode => :eval_class_or_module,
92
+ Prism::ModuleNode => :eval_class_or_module,
93
+ Prism::SingletonClassNode => :eval_singleton_class,
94
+ Prism::CallNode => :eval_call,
95
+ Prism::BlockNode => :eval_block
96
+ }.freeze
97
+ private_constant :HANDLERS
98
+
99
+ # Lexical class frame: the `name:` field is the qualified class
100
+ # name as it would render in Ruby (e.g., `"Foo::Bar"`); the
101
+ # `singleton:` field is `true` for `class << self` frames so
102
+ # nested defs resolve to singleton-method RBS lookups.
103
+ ClassFrame = Data.define(:name, :singleton)
104
+
105
+ # @param scope [Rigor::Scope]
106
+ # @param tracer [Rigor::Inference::FallbackTracer, nil]
107
+ # @param on_enter [#call, nil] optional `(node, scope) ->` callable
108
+ # invoked once at the start of every {#evaluate} call (the node
109
+ # itself, *before* its handler runs). Threaded through every
110
+ # recursive `sub_eval` so the tooling that builds a per-node
111
+ # scope index (`Rigor::Inference::ScopeIndexer`) can record the
112
+ # entry scope for every Prism node the evaluator visits without
113
+ # the StatementEvaluator carrying any additional state itself.
114
+ # @param class_context [Array<ClassFrame>] lexical class scope used
115
+ # by {#eval_def} to look up the method's RBS signature. Each
116
+ # `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
117
+ # over `self` flips the innermost frame to singleton mode.
118
+ def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze)
119
+ @scope = scope
120
+ @tracer = tracer
121
+ @on_enter = on_enter
122
+ @class_context = class_context.freeze
123
+ end
124
+
125
+ # Evaluate `node` under the receiver scope. Returns `[type, scope']`
126
+ # where `type` is the value the node produces and `scope'` is the
127
+ # scope observable after the node has run. The receiver scope is
128
+ # never mutated.
129
+ #
130
+ # @param node [Prism::Node]
131
+ # @return [Array(Rigor::Type, Rigor::Scope)]
132
+ def evaluate(node)
133
+ @on_enter&.call(node, @scope)
134
+
135
+ handler = HANDLERS[node.class]
136
+ return send(handler, node) if handler
137
+
138
+ # Default: the node is treated as a pure expression. Type it
139
+ # through the existing expression typer (which observes the
140
+ # current scope's locals) and leave the scope unchanged.
141
+ [@scope.type_of(node, tracer: @tracer), @scope]
142
+ end
143
+
144
+ private
145
+
146
+ attr_reader :scope, :tracer
147
+
148
+ # Thread the scope through every child statement in declaration
149
+ # order. The body's value is the type of the last statement (or
150
+ # `Constant[nil]` for an empty body); intermediate statements'
151
+ # types are discarded, but their scope effects are preserved.
152
+ def eval_statements(node)
153
+ result_type = Type::Combinator.constant_of(nil)
154
+ current = scope
155
+ node.body.each do |stmt|
156
+ result_type, current = sub_eval(stmt, current)
157
+ end
158
+ [result_type, current]
159
+ end
160
+
161
+ def eval_program(node)
162
+ return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
163
+
164
+ sub_eval(node.statements, scope)
165
+ end
166
+
167
+ # `name = rvalue` evaluates the rvalue under the entry scope (so
168
+ # earlier assignments in a chained `a = b = expr` propagate
169
+ # left-to-right) and binds `name` to the result type. Compound
170
+ # assignment forms (`+=` etc.) are deferred to a follow-up; for
171
+ # now they degrade to "type the rhs, do not rebind" via the
172
+ # default branch in {#evaluate}.
173
+ def eval_local_write(node)
174
+ rhs_type, post_rhs = sub_eval(node.value, scope)
175
+ [rhs_type, post_rhs.with_local(node.name, rhs_type)]
176
+ end
177
+
178
+ # Slice 7 phase 1 — instance/class/global variable
179
+ # writes. Each handler evaluates the rvalue under the
180
+ # entry scope and binds the named variable into the
181
+ # post-scope's per-kind binding map. The expression value
182
+ # is the rvalue type, matching Ruby's semantics. Bindings
183
+ # are method-local: a fresh scope is built at every `def`
184
+ # entry through `build_method_entry_scope`, so writes do
185
+ # not leak across method boundaries until cross-method
186
+ # ivar/cvar tracking lands.
187
+ def eval_ivar_write(node)
188
+ rhs_type, post_rhs = sub_eval(node.value, scope)
189
+ [rhs_type, post_rhs.with_ivar(node.name, rhs_type)]
190
+ end
191
+
192
+ def eval_cvar_write(node)
193
+ rhs_type, post_rhs = sub_eval(node.value, scope)
194
+ [rhs_type, post_rhs.with_cvar(node.name, rhs_type)]
195
+ end
196
+
197
+ def eval_global_write(node)
198
+ rhs_type, post_rhs = sub_eval(node.value, scope)
199
+ [rhs_type, post_rhs.with_global(node.name, rhs_type)]
200
+ end
201
+
202
+ # Slice 7 phase 3 — compound writes (||=, &&=, +=/-=/...)
203
+ # for every variable kind. Each handler:
204
+ # 1. Reads the current type from the appropriate scope
205
+ # binding map (or `Dynamic[Top]` when unbound).
206
+ # 2. Evaluates the rvalue under the entry scope and
207
+ # threads any scope effects (rare for compound RHS,
208
+ # but matches Ruby evaluation order).
209
+ # 3. Computes the result type via `compound_result_type`:
210
+ # `||=` → `union(narrow_truthy(current), rhs)`;
211
+ # `&&=` → `union(narrow_falsey(current), rhs)`;
212
+ # operator forms (`+=`, `-=`, `*=`, ...) dispatch
213
+ # `current.send(op, rhs)` through `MethodDispatcher`,
214
+ # falling back to `Dynamic[Top]` on a miss.
215
+ # 4. Rebinds the variable into the post-scope through
216
+ # the same `with_*` builder used by the plain write
217
+ # handler, so subsequent reads observe the result.
218
+ def eval_local_or_write(node)
219
+ compound_eval(node, kind: :local, op: :or)
220
+ end
221
+
222
+ def eval_local_and_write(node)
223
+ compound_eval(node, kind: :local, op: :and)
224
+ end
225
+
226
+ def eval_local_operator_write(node)
227
+ compound_eval(node, kind: :local, op: node.binary_operator)
228
+ end
229
+
230
+ def eval_ivar_or_write(node)
231
+ compound_eval(node, kind: :ivar, op: :or)
232
+ end
233
+
234
+ def eval_ivar_and_write(node)
235
+ compound_eval(node, kind: :ivar, op: :and)
236
+ end
237
+
238
+ def eval_ivar_operator_write(node)
239
+ compound_eval(node, kind: :ivar, op: node.binary_operator)
240
+ end
241
+
242
+ def eval_cvar_or_write(node)
243
+ compound_eval(node, kind: :cvar, op: :or)
244
+ end
245
+
246
+ def eval_cvar_and_write(node)
247
+ compound_eval(node, kind: :cvar, op: :and)
248
+ end
249
+
250
+ def eval_cvar_operator_write(node)
251
+ compound_eval(node, kind: :cvar, op: node.binary_operator)
252
+ end
253
+
254
+ def eval_global_or_write(node)
255
+ compound_eval(node, kind: :global, op: :or)
256
+ end
257
+
258
+ def eval_global_and_write(node)
259
+ compound_eval(node, kind: :global, op: :and)
260
+ end
261
+
262
+ def eval_global_operator_write(node)
263
+ compound_eval(node, kind: :global, op: node.binary_operator)
264
+ end
265
+
266
+ def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
267
+ current_type = current_type_for(kind, node.name)
268
+ rhs_type, post_rhs = sub_eval(node.value, scope)
269
+ result_type = compound_result_type(current_type, rhs_type, op)
270
+ [result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
271
+ end
272
+
273
+ VAR_KIND_GETTERS = {
274
+ local: :local, ivar: :ivar, cvar: :cvar, global: :global
275
+ }.freeze
276
+ VAR_KIND_BUILDERS = {
277
+ local: :with_local, ivar: :with_ivar, cvar: :with_cvar, global: :with_global
278
+ }.freeze
279
+ private_constant :VAR_KIND_GETTERS, :VAR_KIND_BUILDERS
280
+
281
+ def current_type_for(kind, name)
282
+ scope.public_send(VAR_KIND_GETTERS.fetch(kind), name) || Type::Combinator.untyped
283
+ end
284
+
285
+ def rebind_variable(target_scope, kind, name, type)
286
+ target_scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, type)
287
+ end
288
+
289
+ def compound_result_type(current, rhs, operator)
290
+ case operator
291
+ when :or
292
+ Type::Combinator.union(Narrowing.narrow_truthy(current), rhs)
293
+ when :and
294
+ Type::Combinator.union(Narrowing.narrow_falsey(current), rhs)
295
+ else
296
+ dispatch_operator(current, rhs, operator)
297
+ end
298
+ end
299
+
300
+ def dispatch_operator(current, rhs, operator)
301
+ result = MethodDispatcher.dispatch(
302
+ receiver_type: current,
303
+ method_name: operator.to_sym,
304
+ arg_types: [rhs],
305
+ environment: scope.environment
306
+ )
307
+ result || Type::Combinator.untyped
308
+ end
309
+
310
+ # `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring.
311
+ # Evaluates the right-hand side under the entry scope, then
312
+ # decomposes its type against the multi-write target tree
313
+ # (Prism::MultiWriteNode#lefts/rest/rights, including nested
314
+ # Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped
315
+ # right-hand sides produce per-slot types element-wise; other
316
+ # carriers fall back to `Dynamic[Top]` per slot. The expression
317
+ # value is the right-hand side type (matching Ruby's semantics:
318
+ # `(a, b = [1, 2])` evaluates to `[1, 2]`).
319
+ def eval_multi_write(node)
320
+ rhs_type, post_rhs = sub_eval(node.value, scope)
321
+ bindings = MultiTargetBinder.bind(node, rhs_type)
322
+ post = bindings.reduce(post_rhs) { |acc, (name, type)| acc.with_local(name, type) }
323
+ [rhs_type, post]
324
+ end
325
+
326
+ # `if pred; t; (elsif/else)?` runs the predicate first (its
327
+ # post-scope is shared by both branches), then asks
328
+ # `Rigor::Inference::Narrowing` for the truthy and falsey edge
329
+ # scopes derived from the predicate. Slice 6 phase 1 narrows
330
+ # local-variable bindings on truthiness, `nil?`, `!`, and `&&`/
331
+ # `||` predicate composition; predicates the analyser does not
332
+ # specialise return the post-predicate scope unchanged on both
333
+ # edges, preserving the Slice 3 phase 2 behaviour. The branches'
334
+ # result types are unioned; their post-scopes are joined with
335
+ # nil-injection on half-bound names so a name set in one branch
336
+ # but not the other is observable as `T | nil` after the if.
337
+ def eval_if(node)
338
+ _pred_type, post_pred = sub_eval(node.predicate, scope)
339
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
340
+ then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
341
+ else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
342
+ # Slice 7 phase 14 — early-return narrowing. When the
343
+ # then-branch unconditionally exits (return / next /
344
+ # break / raise) and there is no else, the post-scope
345
+ # is the falsey edge of the predicate (subsequent
346
+ # statements observe the predicate-was-false world).
347
+ return [Type::Combinator.union(then_type, else_type), falsey_scope] \
348
+ if branch_unconditionally_exits?(node.statements) && node.subsequent.nil?
349
+ return [Type::Combinator.union(then_type, else_type), truthy_scope] \
350
+ if branch_unconditionally_exits?(node.subsequent) && node.statements
351
+
352
+ [
353
+ Type::Combinator.union(then_type, else_type),
354
+ join_with_nil_injection(then_scope, else_scope)
355
+ ]
356
+ end
357
+
358
+ # `unless pred; t; else; e; end`. Same shape as `if`, but Prism
359
+ # exposes the else-branch as `else_clause` (no elsif chain). The
360
+ # narrower's truthy/falsey edges are routed in swapped form
361
+ # because `unless` runs its body when the predicate is falsey.
362
+ def eval_unless(node)
363
+ _pred_type, post_pred = sub_eval(node.predicate, scope)
364
+ truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
365
+ then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
366
+ else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
367
+ # Slice 7 phase 14 — same early-return narrowing as
368
+ # `if`: when the body unconditionally exits and there
369
+ # is no else, the post-scope is the truthy edge.
370
+ return [Type::Combinator.union(then_type, else_type), truthy_scope] \
371
+ if branch_unconditionally_exits?(node.statements) && node.else_clause.nil?
372
+ return [Type::Combinator.union(then_type, else_type), falsey_scope] \
373
+ if branch_unconditionally_exits?(node.else_clause) && node.statements
374
+
375
+ [
376
+ Type::Combinator.union(then_type, else_type),
377
+ join_with_nil_injection(then_scope, else_scope)
378
+ ]
379
+ end
380
+
381
+ def eval_else(node)
382
+ return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
383
+
384
+ sub_eval(node.statements, scope)
385
+ end
386
+
387
+ # `case pred; when ...; when ...; else; end` and the pattern-
388
+ # matching variant. The predicate's post-scope is shared with
389
+ # every branch (including the else); branches are evaluated
390
+ # independently and merged with nil-injection so half-bound
391
+ # names degrade to `T | nil`.
392
+ def eval_case(node)
393
+ post_pred = node.predicate ? sub_eval(node.predicate, scope).last : scope
394
+ branch_results, falsey_scope = eval_case_when_branches(node.predicate, node.conditions, post_pred)
395
+ else_result = eval_case_else(node.else_clause, falsey_scope)
396
+
397
+ all_results = [*branch_results, else_result]
398
+ [
399
+ Type::Combinator.union(*all_results.map(&:first)),
400
+ reduce_scopes_with_nil_injection(all_results.map(&:last))
401
+ ]
402
+ end
403
+
404
+ def eval_case_when_branches(subject, conditions, entry_scope)
405
+ results = []
406
+ falsey_scope = entry_scope
407
+ conditions.each do |branch|
408
+ when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
409
+ body_scope, falsey_scope = Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
410
+ results << sub_eval(branch, body_scope)
411
+ end
412
+ [results, falsey_scope]
413
+ end
414
+
415
+ def eval_case_else(else_clause, falsey_scope)
416
+ return sub_eval(else_clause, falsey_scope) if else_clause
417
+
418
+ [Type::Combinator.constant_of(nil), falsey_scope]
419
+ end
420
+
421
+ def eval_when_or_in(node)
422
+ return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
423
+
424
+ sub_eval(node.statements, scope)
425
+ end
426
+
427
+ # `begin; body; rescue ...; else; ensure; end`. The body and the
428
+ # rescue chain are alternative exit paths whose scopes are joined
429
+ # with nil-injection. The else-clause replaces the body's value
430
+ # when present (matching Ruby semantics: else runs only if the
431
+ # body raises no exception). The ensure-clause runs but does not
432
+ # contribute to the value; its scope effects are layered on the
433
+ # joined exit scope so locals bound exclusively in `ensure` stay
434
+ # observable.
435
+ def eval_begin(node)
436
+ primary_type, primary_scope = eval_begin_primary(node)
437
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, scope)
438
+
439
+ if rescue_chain.empty?
440
+ exit_type = primary_type
441
+ exit_scope = primary_scope
442
+ else
443
+ exit_type = Type::Combinator.union(primary_type, *rescue_chain.map(&:first))
444
+ exit_scope = reduce_scopes_with_nil_injection([primary_scope, *rescue_chain.map(&:last)])
445
+ end
446
+
447
+ if node.ensure_clause
448
+ _ensure_type, ensure_scope = sub_eval(node.ensure_clause, exit_scope)
449
+ exit_scope = ensure_scope
450
+ end
451
+
452
+ [exit_type, exit_scope]
453
+ end
454
+
455
+ # `BeginNode#statements` is the primary body; when an else-clause
456
+ # is present, its value replaces the body's per Ruby semantics
457
+ # (the else runs only when no exception was raised), but the
458
+ # body's scope effects still apply because the body did run
459
+ # before the else.
460
+ def eval_begin_primary(node)
461
+ body_type, body_scope =
462
+ if node.statements
463
+ sub_eval(node.statements, scope)
464
+ else
465
+ [Type::Combinator.constant_of(nil), scope]
466
+ end
467
+
468
+ if node.else_clause
469
+ else_type, else_scope = sub_eval(node.else_clause, body_scope)
470
+ [else_type, else_scope]
471
+ else
472
+ [body_type, body_scope]
473
+ end
474
+ end
475
+
476
+ def collect_rescue_chain_results(rescue_node, entry_scope)
477
+ results = []
478
+ current = rescue_node
479
+ while current
480
+ results << eval_branch_or_nil(current.statements, entry_scope)
481
+ current = current.subsequent
482
+ end
483
+ results
484
+ end
485
+
486
+ def eval_rescue(node)
487
+ eval_branch_or_nil(node.statements, scope)
488
+ end
489
+
490
+ def eval_ensure(node)
491
+ eval_branch_or_nil(node.statements, scope)
492
+ end
493
+
494
+ # `while pred; body; end` / `until pred; body; end`. The body
495
+ # might run zero or more times, so half-bound names degrade to
496
+ # `T | nil` in the post-loop scope. The loop expression itself
497
+ # types as `Constant[nil]` (Slice 3 phase 1), reflecting the
498
+ # common case where no `break VALUE` is observed.
499
+ def eval_loop(node)
500
+ _pred_type, post_pred = sub_eval(node.predicate, scope)
501
+ return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
502
+
503
+ _body_type, body_scope = sub_eval(node.statements, post_pred)
504
+ [
505
+ Type::Combinator.constant_of(nil),
506
+ join_with_nil_injection(post_pred, body_scope)
507
+ ]
508
+ end
509
+
510
+ # `a && b` / `a || b`. The LHS always runs, the RHS only
511
+ # sometimes runs. Slice 6 phase 1 narrows the RHS evaluation:
512
+ # `a && b` evaluates `b` under the truthy edge of `a`, and
513
+ # `a || b` evaluates `b` under the falsey edge of `a`. The
514
+ # narrowed RHS post-scope is joined with the LHS post-scope
515
+ # (RHS skipped) using nil-injection so half-bound names from
516
+ # the RHS still degrade to `T | nil`. The result type is
517
+ # edge-aware: `a && b` can only produce the falsey fragment of
518
+ # `a` when the RHS is skipped, while `a || b` can only produce
519
+ # the truthy fragment of `a` when the RHS is skipped.
520
+ def eval_and_or(node)
521
+ left_type, left_scope = sub_eval(node.left, scope)
522
+ truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
523
+ rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
524
+ right_type, right_scope = sub_eval(node.right, rhs_entry)
525
+ skipped_type =
526
+ if node.is_a?(Prism::AndNode)
527
+ Narrowing.narrow_falsey(left_type)
528
+ else
529
+ Narrowing.narrow_truthy(left_type)
530
+ end
531
+ [
532
+ Type::Combinator.union(skipped_type, right_type),
533
+ join_with_nil_injection(left_scope, right_scope)
534
+ ]
535
+ end
536
+
537
+ # `(body)`. Threads scope through the inner expression so
538
+ # `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
539
+ def eval_parentheses(node)
540
+ return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
541
+
542
+ sub_eval(node.body, scope)
543
+ end
544
+
545
+ # `class Foo; body; end` and `module Foo; body; end`. The class
546
+ # body runs in a fresh scope (Ruby's class scope does not see
547
+ # the outer locals), and the StatementEvaluator pushes a new
548
+ # `ClassFrame` so nested `def`s know their lexical owner. The
549
+ # outer scope is unchanged on exit because Ruby's class
550
+ # definition does not bind any local in the enclosing scope.
551
+ # The class body's value is the value of its last statement
552
+ # (`Constant[nil]` for an empty body); we discard the body's
553
+ # post-scope.
554
+ def eval_class_or_module(node)
555
+ name = qualified_name_for(node.constant_path)
556
+ new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
557
+ body_type, _body_scope = eval_class_body(node, new_context)
558
+ [body_type, scope]
559
+ end
560
+
561
+ # `class << expr; body; end`. When `expr` is `self`, the body
562
+ # defines class methods on the immediate enclosing class — the
563
+ # innermost frame flips to `singleton: true` so a nested
564
+ # `def foo` resolves through `singleton_method` rather than
565
+ # `instance_method`. For non-`self` expressions we cannot
566
+ # statically resolve the receiver, so we keep the existing
567
+ # context and accept that nested defs degrade to the
568
+ # `Dynamic[Top]` default.
569
+ def eval_singleton_class(node)
570
+ new_context = singleton_context_for(node)
571
+ body_type, _body_scope = eval_class_body(node, new_context)
572
+ [body_type, scope]
573
+ end
574
+
575
+ # `def name(params); body; end`. Builds the method-entry scope
576
+ # by binding the parameter list (RBS-driven where available, or
577
+ # `Dynamic[Top]` for the slice 3 phase 2 fallback) into a fresh
578
+ # scope, then evaluates the body under that scope. The outer
579
+ # scope is left unchanged: a `def` does not introduce a binding
580
+ # in its enclosing scope. Ruby evaluates `def` to the method's
581
+ # name as a Symbol, so the produced type is `Constant[:name]`.
582
+ def eval_def(node)
583
+ body_scope = build_method_entry_scope(node)
584
+ sub_eval(node.body, body_scope, class_context: @class_context) if node.body
585
+ [Type::Combinator.constant_of(node.name), scope]
586
+ end
587
+
588
+ # `recv.foo(args) { |params| body }` and friends. The call
589
+ # type comes from `Scope#type_of` (which routes through
590
+ # `ExpressionTyper#call_type_for` and is itself block-aware
591
+ # since Slice 6 phase C sub-phase 2: it builds the block-entry
592
+ # scope from the receiving method's RBS signature, types the
593
+ # block body, and threads the body's type into
594
+ # `MethodDispatcher.dispatch`'s `block_type:` so generic
595
+ # methods like `Array#map { |n| n.to_s }` resolve to
596
+ # `Array[String]`).
597
+ #
598
+ # The handler still re-evaluates the block under its entry
599
+ # scope so the per-node scope index sees the bindings on the
600
+ # `on_enter` callback path. Block effects do NOT leak into the
601
+ # post-call scope: a block-local write is observed only
602
+ # inside the block body. The receiver and arguments still
603
+ # observe the outer scope, matching Ruby evaluation order.
604
+ def eval_call(node)
605
+ call_type = scope.type_of(node, tracer: tracer)
606
+ evaluate_block_if_present(node)
607
+ post_scope = record_closure_escape_if_any(node)
608
+ [call_type, post_scope]
609
+ end
610
+
611
+ def evaluate_block_if_present(node)
612
+ block = node.block
613
+ return unless block.is_a?(Prism::BlockNode)
614
+
615
+ block_entry = build_block_entry_scope(node, block)
616
+ sub_eval(block, block_entry)
617
+ end
618
+
619
+ # Slice 6 phase C sub-phase 3b/3c. When the call carries a
620
+ # block whose receiving method is NOT proven non-escaping:
621
+ #
622
+ # - 3b: attach a `dynamic_origin` `closure_escape` fact to the
623
+ # post-call scope so consumers can see that the closure may
624
+ # have been retained past the call.
625
+ # - 3c: drop the narrowed type of every captured outer local
626
+ # that the block body can rebind, replacing it with
627
+ # `Dynamic[Top]` through `Scope#with_local` (which also
628
+ # invalidates the local's `local_binding` facts). Locals
629
+ # shadowed by a block parameter or a `;`-prefixed
630
+ # block-local declaration are untouched. Locals the block
631
+ # only reads (without writing) are also untouched: read-only
632
+ # captures cannot rebind the outer variable.
633
+ #
634
+ # A `:non_escaping` classification (or any block-less call)
635
+ # leaves the post-call scope unchanged.
636
+ def record_closure_escape_if_any(node)
637
+ return scope unless node.block.is_a?(Prism::BlockNode)
638
+
639
+ classification = classify_closure_escape(node)
640
+ return scope if classification == :non_escaping
641
+
642
+ post_scope = drop_captured_narrowing(node.block, scope)
643
+ post_scope.with_fact(
644
+ Analysis::FactStore::Fact.new(
645
+ bucket: :dynamic_origin,
646
+ target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
647
+ predicate: :closure_escape,
648
+ payload: { method_name: node.name.to_sym, classification: classification },
649
+ stability: :unstable
650
+ )
651
+ )
652
+ end
653
+
654
+ def classify_closure_escape(call_node)
655
+ receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
656
+ ClosureEscapeAnalyzer.classify(
657
+ receiver_type: receiver_type,
658
+ method_name: call_node.name,
659
+ environment: scope.environment
660
+ )
661
+ rescue StandardError
662
+ :unknown
663
+ end
664
+
665
+ # Sub-phase 3c. Replace the outer-local types that the block
666
+ # body can rebind with `Dynamic[Top]`. The conservative drop
667
+ # matches the spec line "facts about locals it can write
668
+ # become unstable after the escape point": rather than
669
+ # synthesise the union of the block's write types (which the
670
+ # current pass does not yet expose), we discard the narrowed
671
+ # binding altogether. A future sub-phase MAY refine this to
672
+ # the union of the block's actual writes.
673
+ def drop_captured_narrowing(block_node, base_scope)
674
+ names = captured_local_writes(block_node, base_scope)
675
+ return base_scope if names.empty?
676
+
677
+ names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
678
+ end
679
+
680
+ def captured_local_writes(block_node, base_scope)
681
+ body = block_node.body
682
+ return [] if body.nil?
683
+
684
+ introduced = block_introduced_locals(block_node)
685
+ outer_writes = []
686
+ Source::NodeWalker.each(body) do |descendant|
687
+ next unless descendant.is_a?(Prism::LocalVariableWriteNode)
688
+ next if introduced.include?(descendant.name)
689
+ next unless base_scope.locals.key?(descendant.name)
690
+
691
+ outer_writes << descendant.name
692
+ end
693
+ outer_writes.uniq
694
+ end
695
+
696
+ # Names introduced by the block itself (parameters, numbered
697
+ # parameters via `BlockParameterBinder`, plus explicit
698
+ # `;`-prefixed block-locals on `BlockParametersNode`). Writes
699
+ # to these names are local to the block and MUST NOT be
700
+ # treated as captured rebinds of an outer local.
701
+ def block_introduced_locals(block_node)
702
+ introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
703
+ params_root = block_node.parameters
704
+ params_root.locals.each { |loc| introduced << loc.name } if params_root.is_a?(Prism::BlockParametersNode)
705
+ introduced
706
+ end
707
+
708
+ # `Prism::BlockNode` is reached through {#eval_call}; the
709
+ # handler runs the body under `scope`, which the caller has
710
+ # already augmented with the block's parameter bindings. Effects
711
+ # do not leak past the block (the outer eval_call returns the
712
+ # caller's scope unchanged), but the body's local writes are
713
+ # threaded through subsequent statements *inside* the block so
714
+ # `each { |x| sum = x; sum.succ }` types `sum.succ` under the
715
+ # `sum: x` binding.
716
+ def eval_block(node)
717
+ return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
718
+
719
+ sub_eval(node.body, scope)
720
+ end
721
+
722
+ # Builds the entry scope for a block body. The block sees the
723
+ # outer scope's locals (Ruby's lexical scoping rule) and adds
724
+ # bindings for every named block parameter on top. Parameter
725
+ # types come from the receiving method's RBS signature when
726
+ # one is available; the rest default to `Dynamic[Top]`.
727
+ def build_block_entry_scope(call_node, block_node)
728
+ expected = expected_block_param_types_for(call_node)
729
+ bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
730
+ bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
731
+ end
732
+
733
+ def expected_block_param_types_for(call_node)
734
+ receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
735
+ return [] if receiver_type.nil?
736
+
737
+ arg_types = call_arg_types_for(call_node)
738
+ MethodDispatcher.expected_block_param_types(
739
+ receiver_type: receiver_type,
740
+ method_name: call_node.name,
741
+ arg_types: arg_types,
742
+ environment: scope.environment
743
+ )
744
+ rescue StandardError
745
+ []
746
+ end
747
+
748
+ def call_arg_types_for(call_node)
749
+ arguments = call_node.arguments
750
+ return [] if arguments.nil?
751
+
752
+ arguments.arguments.map { |arg| scope.type_of(arg, tracer: tracer) }
753
+ end
754
+
755
+ # ----- def/class helpers -----
756
+
757
+ def eval_class_body(node, new_context)
758
+ return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
759
+
760
+ # Class/module bodies run in a fresh scope: the outer scope's
761
+ # locals are NOT visible inside `class Foo; ... end`. We keep
762
+ # the same Environment so RBS lookups continue to work, and
763
+ # simply drop the locals. Slice A-engine: `self` inside a
764
+ # class body is the class object itself, so we set
765
+ # `self_type` to `Singleton[<qualified>]`.
766
+ fresh = build_fresh_body_scope
767
+ body_self = self_type_for_class_body(new_context)
768
+ fresh = fresh.with_self_type(body_self) if body_self
769
+ sub_eval(node.body, fresh, class_context: new_context)
770
+ end
771
+
772
+ def build_method_entry_scope(def_node)
773
+ singleton = singleton_def?(def_node)
774
+ binder = MethodParameterBinder.new(
775
+ environment: scope.environment,
776
+ class_path: current_class_path,
777
+ singleton: singleton
778
+ )
779
+ bindings = binder.bind(def_node)
780
+
781
+ # Method bodies do NOT see the outer scope's locals. They start
782
+ # from a fresh scope with the same environment, then receive
783
+ # the parameter bindings. Slice 7 phase 2: instance defs ALSO
784
+ # seed their `ivars` map from the class-level accumulator so
785
+ # `def get; @x; end` reads the type that a sibling
786
+ # `def init; @x = 1; end` wrote.
787
+ fresh = build_fresh_body_scope
788
+ body_self = self_type_for_method_body(singleton: singleton)
789
+ fresh = fresh.with_self_type(body_self) if body_self
790
+ fresh = seed_instance_ivars(fresh, singleton: singleton)
791
+ fresh = seed_class_cvars(fresh)
792
+ fresh = seed_program_globals(fresh)
793
+ bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
794
+ end
795
+
796
+ def seed_instance_ivars(body_scope, singleton:)
797
+ return body_scope if singleton
798
+
799
+ path = current_class_path
800
+ return body_scope if path.nil?
801
+
802
+ seeded = scope.class_ivars_for(path)
803
+ return body_scope if seeded.empty?
804
+
805
+ seeded.reduce(body_scope) { |acc, (name, type)| acc.with_ivar(name, type) }
806
+ end
807
+
808
+ # Cvars are visible from BOTH instance and singleton method
809
+ # bodies of the enclosing class, so this seed is unconditional
810
+ # (no `singleton:` gate). At the top-level (no class context)
811
+ # the accumulator is empty and the seed is a no-op.
812
+ def seed_class_cvars(body_scope)
813
+ path = current_class_path
814
+ return body_scope if path.nil?
815
+
816
+ seeded = scope.class_cvars_for(path)
817
+ return body_scope if seeded.empty?
818
+
819
+ seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
820
+ end
821
+
822
+ # Globals are process-wide. The body scope already inherited
823
+ # the program-globals accumulator through `with_program_globals`;
824
+ # seeding here just materialises each entry into the body's
825
+ # `globals` map so reads observe a precise type without
826
+ # consulting the accumulator on every lookup.
827
+ def seed_program_globals(body_scope)
828
+ seeded = scope.program_globals
829
+ return body_scope if seeded.empty?
830
+
831
+ seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
832
+ end
833
+
834
+ # Slice A-declarations. Class- and method-bodies start from a
835
+ # fresh local-empty scope, but they MUST keep the
836
+ # `declared_types` table visible at the outer scope so the
837
+ # ScopeIndexer-populated declaration overrides
838
+ # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
839
+ # remain reachable from inside nested bodies.
840
+ def build_fresh_body_scope
841
+ Scope.empty(environment: scope.environment)
842
+ .with_declared_types(scope.declared_types)
843
+ .with_discovered_classes(scope.discovered_classes)
844
+ .with_in_source_constants(scope.in_source_constants)
845
+ .with_class_ivars(scope.class_ivars)
846
+ .with_class_cvars(scope.class_cvars)
847
+ .with_program_globals(scope.program_globals)
848
+ end
849
+
850
+ def singleton_def?(def_node)
851
+ def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
852
+ end
853
+
854
+ # Slice A-engine. Inside a class body `class Foo; ...; end`,
855
+ # `self` is the class object — `Singleton[Foo]`. Returns nil
856
+ # at the top level (no enclosing class).
857
+ def self_type_for_class_body(class_context)
858
+ return nil if class_context.empty?
859
+
860
+ Type::Combinator.singleton_of(class_context.map(&:name).join("::"))
861
+ end
862
+
863
+ # Slice A-engine. Inside a method body, `self` depends on
864
+ # whether the def is on the singleton or instance side of the
865
+ # surrounding class:
866
+ #
867
+ # - `def self.foo` or any def inside `class << self`: self is
868
+ # the class object → `Singleton[Foo]`.
869
+ # - ordinary instance `def foo`: self is an instance →
870
+ # `Nominal[Foo]`.
871
+ #
872
+ # Returns nil for top-level defs that have no enclosing class.
873
+ def self_type_for_method_body(singleton:)
874
+ path = current_class_path
875
+ return nil if path.nil?
876
+
877
+ if singleton
878
+ Type::Combinator.singleton_of(path)
879
+ else
880
+ Type::Combinator.nominal_of(path)
881
+ end
882
+ end
883
+
884
+ def qualified_name_for(constant_path_node)
885
+ case constant_path_node
886
+ when Prism::ConstantReadNode
887
+ constant_path_node.name.to_s
888
+ when Prism::ConstantPathNode
889
+ render_constant_path(constant_path_node)
890
+ end
891
+ end
892
+
893
+ def render_constant_path(node)
894
+ prefix =
895
+ case node.parent
896
+ when Prism::ConstantReadNode then "#{node.parent.name}::"
897
+ when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
898
+ else ""
899
+ end
900
+ "#{prefix}#{node.name}"
901
+ end
902
+
903
+ def singleton_context_for(node)
904
+ return @class_context unless node.expression.is_a?(Prism::SelfNode)
905
+ return @class_context if @class_context.empty?
906
+
907
+ outer = @class_context[0..-2]
908
+ last = @class_context.last
909
+ outer + [ClassFrame.new(name: last.name, singleton: true)]
910
+ end
911
+
912
+ # The qualified name of the immediately-enclosing class (joining
913
+ # every nested `ClassFrame` with `::`). Returns `nil` for a
914
+ # top-level def with no enclosing class, which routes the
915
+ # parameter binder past RBS lookup.
916
+ def current_class_path
917
+ return nil if @class_context.empty?
918
+
919
+ @class_context.map(&:name).join("::")
920
+ end
921
+
922
+ def current_frame_singleton?
923
+ @class_context.last&.singleton == true
924
+ end
925
+
926
+ # ----- helpers -----
927
+
928
+ def sub_eval(node, with_scope, class_context: @class_context)
929
+ StatementEvaluator.new(
930
+ scope: with_scope,
931
+ tracer: tracer,
932
+ on_enter: @on_enter,
933
+ class_context: class_context
934
+ ).evaluate(node)
935
+ end
936
+
937
+ # Slice 7 phase 14 — branch exit detection. Returns true
938
+ # when the branch's body unconditionally exits the
939
+ # surrounding control flow through a `return`, `next`,
940
+ # `break`, or `raise`. Used by `eval_if` / `eval_unless`
941
+ # to narrow the post-scope: when one branch exits, the
942
+ # surrounding scope can carry the OTHER branch's edge
943
+ # forward without nil-injection.
944
+ #
945
+ # The detection is intentionally conservative — it
946
+ # recognises only the most common patterns:
947
+ # - A `Prism::ReturnNode`, `NextNode`, `BreakNode`.
948
+ # - A `Prism::CallNode` whose name is `:raise` or `:throw`.
949
+ # - A `Prism::StatementsNode`, `Prism::ParenthesesNode`, or
950
+ # `Prism::IfNode`/`UnlessNode` whose final / both
951
+ # branches recursively exit.
952
+ EXIT_CALL_NAMES = %i[raise throw exit abort fail].freeze
953
+ private_constant :EXIT_CALL_NAMES
954
+
955
+ def branch_unconditionally_exits?(node) # rubocop:disable Metrics/CyclomaticComplexity
956
+ return false if node.nil?
957
+
958
+ case node
959
+ when Prism::ReturnNode, Prism::NextNode, Prism::BreakNode
960
+ true
961
+ when Prism::CallNode
962
+ node.receiver.nil? && EXIT_CALL_NAMES.include?(node.name)
963
+ when Prism::StatementsNode
964
+ last = node.body.last
965
+ branch_unconditionally_exits?(last)
966
+ when Prism::ParenthesesNode
967
+ branch_unconditionally_exits?(node.body)
968
+ when Prism::IfNode, Prism::UnlessNode
969
+ branch_unconditionally_exits?(node.statements) &&
970
+ branch_unconditionally_exits?(node_else_branch(node))
971
+ else
972
+ false
973
+ end
974
+ end
975
+
976
+ def node_else_branch(node)
977
+ case node
978
+ when Prism::IfNode then node.subsequent
979
+ when Prism::UnlessNode then node.else_clause
980
+ end
981
+ end
982
+
983
+ def eval_branch_or_nil(branch_node, branch_scope)
984
+ return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
985
+
986
+ sub_eval(branch_node, branch_scope)
987
+ end
988
+
989
+ # Joins two branch scopes at a control-flow merge point. Names
990
+ # bound in only one branch are nil-injected into the other side
991
+ # so the joined scope sees them as `T | nil` rather than dropping
992
+ # them outright. This implements the contract the Slice 3 phase 1
993
+ # `Scope#join` documentation defers to the statement-level
994
+ # evaluator.
995
+ def join_with_nil_injection(scope_a, scope_b)
996
+ nil_const = Type::Combinator.constant_of(nil)
997
+ a_keys = scope_a.locals.keys
998
+ b_keys = scope_b.locals.keys
999
+ a_only = a_keys - b_keys
1000
+ b_only = b_keys - a_keys
1001
+
1002
+ aug_a = b_only.reduce(scope_a) { |acc, name| acc.with_local(name, nil_const) }
1003
+ aug_b = a_only.reduce(scope_b) { |acc, name| acc.with_local(name, nil_const) }
1004
+ aug_a.join(aug_b)
1005
+ end
1006
+
1007
+ # Generalises {#join_with_nil_injection} to N branches (case/when,
1008
+ # begin/rescue chain). The reduce order does not affect the
1009
+ # result because nil-injection commutes with union under
1010
+ # `Scope#join`.
1011
+ def reduce_scopes_with_nil_injection(scopes)
1012
+ scopes.reduce { |a, b| join_with_nil_injection(a, b) }
1013
+ end
1014
+ end
1015
+ # rubocop:enable Metrics/ClassLength
1016
+ end
1017
+ end