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,831 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "../ast"
7
+ require_relative "block_parameter_binder"
8
+ require_relative "fallback"
9
+ require_relative "method_dispatcher"
10
+
11
+ module Rigor
12
+ module Inference
13
+ # Translates AST nodes into Rigor::Type values, consulting the surrounding
14
+ # Rigor::Scope for local-variable bindings and the environment registry
15
+ # for nominal-type resolution. Pure: never mutates the receiver scope.
16
+ #
17
+ # Accepts both real Prism nodes and synthetic Rigor::AST::Node
18
+ # instances; the synthetic family lets callers and plugins ask
19
+ # "what would the analyzer infer if a value of type T appeared here?"
20
+ # without building a real Prism expression.
21
+ #
22
+ # Slice 1 recognises literal expressions, local-variable reads/writes,
23
+ # shallow Array literals, and Rigor::AST::TypeNode. Slice 2 adds
24
+ # Prism::CallNode (routed through Rigor::Inference::MethodDispatcher),
25
+ # Prism::ArgumentsNode (a non-value position whose children are typed
26
+ # individually by the CallNode handler), constant references resolved
27
+ # through Rigor::Environment::ClassRegistry, hash and interpolated
28
+ # string/symbol literals, definition expressions (def/class/module),
29
+ # and explicit handlers for parameter, block, splat, instance/class/
30
+ # global-variable, and self positions. Many of those handlers return
31
+ # Dynamic[Top] silently because they are non-value or out-of-scope
32
+ # positions for Slice 2; later slices refine them in place.
33
+ #
34
+ # Slice 4 phase 2b types bare-constant references (`Foo`, `Foo::Bar`)
35
+ # as `Singleton[Foo]` rather than `Nominal[Foo]`, so that method
36
+ # dispatch on the constant correctly looks up *class* methods. The
37
+ # corresponding instance type is reachable through `Foo.new` and the
38
+ # value-lattice projections.
39
+ #
40
+ # Every other node falls back to Dynamic[Top] per the fail-soft
41
+ # policy in docs/internal-spec/inference-engine.md. The optional
42
+ # tracer is a Rigor::Inference::FallbackTracer (or any object
43
+ # answering #record_fallback) that receives a Fallback event for
44
+ # each fallback; the tracer MUST NOT change the return value of
45
+ # type_of.
46
+ # rubocop:disable Metrics/ClassLength
47
+ class ExpressionTyper
48
+ # Hash-based dispatch keeps `type_of` linear and lets future slices add
49
+ # node kinds without growing a single case statement past RuboCop's
50
+ # cyclomatic budget. Anonymous Prism subclasses are not expected.
51
+ PRISM_DISPATCH = {
52
+ # Literals
53
+ Prism::IntegerNode => :type_of_literal_value,
54
+ Prism::FloatNode => :type_of_literal_value,
55
+ Prism::SymbolNode => :symbol_type_for,
56
+ Prism::StringNode => :string_type_for,
57
+ Prism::TrueNode => :type_of_true,
58
+ Prism::FalseNode => :type_of_false,
59
+ Prism::NilNode => :type_of_nil,
60
+ # Locals
61
+ Prism::LocalVariableReadNode => :local_read,
62
+ Prism::LocalVariableWriteNode => :type_of_assignment_write,
63
+ # Containers and pass-throughs
64
+ Prism::ArrayNode => :array_type_for,
65
+ Prism::ParenthesesNode => :parentheses_type_for,
66
+ Prism::StatementsNode => :type_of_statements_node,
67
+ Prism::ProgramNode => :type_of_program,
68
+ # Calls
69
+ Prism::CallNode => :call_type_for,
70
+ Prism::ArgumentsNode => :type_of_non_value,
71
+ # Constants
72
+ Prism::ConstantReadNode => :type_of_constant_read,
73
+ Prism::ConstantPathNode => :type_of_constant_path,
74
+ Prism::ConstantWriteNode => :type_of_assignment_write,
75
+ Prism::ConstantPathWriteNode => :type_of_assignment_write,
76
+ Prism::ConstantOperatorWriteNode => :type_of_assignment_write,
77
+ Prism::ConstantOrWriteNode => :type_of_assignment_write,
78
+ Prism::ConstantAndWriteNode => :type_of_assignment_write,
79
+ Prism::ConstantPathOperatorWriteNode => :type_of_assignment_write,
80
+ Prism::ConstantPathOrWriteNode => :type_of_assignment_write,
81
+ Prism::ConstantPathAndWriteNode => :type_of_assignment_write,
82
+ # Self and instance/class/global variables
83
+ Prism::SelfNode => :type_of_self_node,
84
+ Prism::InstanceVariableReadNode => :type_of_instance_variable_read,
85
+ Prism::InstanceVariableWriteNode => :type_of_assignment_write,
86
+ Prism::InstanceVariableOperatorWriteNode => :type_of_assignment_write,
87
+ Prism::InstanceVariableOrWriteNode => :type_of_assignment_write,
88
+ Prism::InstanceVariableAndWriteNode => :type_of_assignment_write,
89
+ Prism::ClassVariableReadNode => :type_of_class_variable_read,
90
+ Prism::ClassVariableWriteNode => :type_of_assignment_write,
91
+ Prism::ClassVariableOperatorWriteNode => :type_of_assignment_write,
92
+ Prism::ClassVariableOrWriteNode => :type_of_assignment_write,
93
+ Prism::ClassVariableAndWriteNode => :type_of_assignment_write,
94
+ Prism::GlobalVariableReadNode => :type_of_global_variable_read,
95
+ Prism::GlobalVariableWriteNode => :type_of_assignment_write,
96
+ Prism::GlobalVariableOperatorWriteNode => :type_of_assignment_write,
97
+ Prism::GlobalVariableOrWriteNode => :type_of_assignment_write,
98
+ Prism::GlobalVariableAndWriteNode => :type_of_assignment_write,
99
+ # Compound writes that share the .value rvalue protocol
100
+ Prism::LocalVariableOperatorWriteNode => :type_of_assignment_write,
101
+ Prism::LocalVariableOrWriteNode => :type_of_assignment_write,
102
+ Prism::LocalVariableAndWriteNode => :type_of_assignment_write,
103
+ Prism::IndexOperatorWriteNode => :type_of_assignment_write,
104
+ Prism::IndexOrWriteNode => :type_of_assignment_write,
105
+ Prism::IndexAndWriteNode => :type_of_assignment_write,
106
+ Prism::MultiWriteNode => :type_of_assignment_write,
107
+ Prism::LocalVariableTargetNode => :type_of_non_value,
108
+ # Hashes and interpolation
109
+ Prism::HashNode => :type_of_hash,
110
+ Prism::KeywordHashNode => :type_of_hash,
111
+ Prism::AssocNode => :type_of_non_value,
112
+ Prism::AssocSplatNode => :type_of_non_value,
113
+ Prism::InterpolatedStringNode => :type_of_interpolated_string,
114
+ Prism::InterpolatedSymbolNode => :type_of_interpolated_symbol,
115
+ Prism::EmbeddedStatementsNode => :type_of_embedded_statements,
116
+ Prism::EmbeddedVariableNode => :type_of_dynamic_top,
117
+ # Definitions
118
+ Prism::DefNode => :type_of_def,
119
+ Prism::ClassNode => :type_of_class_or_module,
120
+ Prism::ModuleNode => :type_of_class_or_module,
121
+ Prism::SingletonClassNode => :type_of_class_or_module,
122
+ Prism::AliasMethodNode => :type_of_nil_value,
123
+ Prism::AliasGlobalVariableNode => :type_of_nil_value,
124
+ Prism::UndefNode => :type_of_nil_value,
125
+ Prism::ForwardingSuperNode => :type_of_dynamic_top,
126
+ Prism::BlockArgumentNode => :type_of_non_value,
127
+ # Parameters and blocks (non-value positions)
128
+ Prism::ParametersNode => :type_of_non_value,
129
+ Prism::RequiredParameterNode => :type_of_non_value,
130
+ Prism::OptionalParameterNode => :type_of_non_value,
131
+ Prism::RequiredKeywordParameterNode => :type_of_non_value,
132
+ Prism::OptionalKeywordParameterNode => :type_of_non_value,
133
+ Prism::KeywordRestParameterNode => :type_of_non_value,
134
+ Prism::RestParameterNode => :type_of_non_value,
135
+ Prism::BlockParameterNode => :type_of_non_value,
136
+ Prism::BlockParametersNode => :type_of_non_value,
137
+ Prism::ForwardingParameterNode => :type_of_non_value,
138
+ Prism::NoKeywordsParameterNode => :type_of_non_value,
139
+ Prism::ImplicitRestNode => :type_of_non_value,
140
+ Prism::BlockNode => :type_of_dynamic_top,
141
+ Prism::SplatNode => :type_of_non_value,
142
+ # Control flow (Slice 3 phase 1): branch types are unioned, jumps
143
+ # type as Bot, loops type as Constant[nil].
144
+ Prism::IfNode => :type_of_if,
145
+ Prism::UnlessNode => :type_of_unless,
146
+ Prism::ElseNode => :type_of_else,
147
+ Prism::AndNode => :type_of_and_or,
148
+ Prism::OrNode => :type_of_and_or,
149
+ Prism::CaseNode => :type_of_case,
150
+ Prism::CaseMatchNode => :type_of_case,
151
+ Prism::WhenNode => :type_of_when_or_in,
152
+ Prism::InNode => :type_of_when_or_in,
153
+ Prism::BeginNode => :type_of_begin,
154
+ Prism::RescueNode => :type_of_rescue,
155
+ Prism::RescueModifierNode => :type_of_rescue_modifier,
156
+ Prism::EnsureNode => :type_of_ensure,
157
+ Prism::ReturnNode => :type_of_jump,
158
+ Prism::BreakNode => :type_of_jump,
159
+ Prism::NextNode => :type_of_jump,
160
+ Prism::RetryNode => :type_of_jump,
161
+ Prism::RedoNode => :type_of_jump,
162
+ Prism::YieldNode => :type_of_dynamic_top,
163
+ Prism::SuperNode => :type_of_dynamic_top,
164
+ Prism::ForwardingArgumentsNode => :type_of_non_value,
165
+ Prism::WhileNode => :type_of_loop,
166
+ Prism::UntilNode => :type_of_loop,
167
+ Prism::ForNode => :type_of_dynamic_top,
168
+ Prism::DefinedNode => :type_of_dynamic_top,
169
+ Prism::MatchPredicateNode => :type_of_dynamic_top,
170
+ Prism::MatchRequiredNode => :type_of_dynamic_top,
171
+ Prism::MatchWriteNode => :type_of_dynamic_top,
172
+ # Literal containers
173
+ Prism::LambdaNode => :type_of_lambda,
174
+ Prism::RangeNode => :type_of_range,
175
+ Prism::RegularExpressionNode => :type_of_regexp,
176
+ Prism::InterpolatedRegularExpressionNode => :type_of_regexp
177
+ }.freeze
178
+ private_constant :PRISM_DISPATCH
179
+
180
+ def initialize(scope:, tracer: nil)
181
+ @scope = scope
182
+ @tracer = tracer
183
+ end
184
+
185
+ def type_of(node)
186
+ # Slice A-declarations. ScopeIndexer pre-fills
187
+ # `scope.declared_types` for declaration-position nodes
188
+ # (`module Foo` / `class Bar` headers) with the qualified
189
+ # `Singleton` type so the header itself does not fall
190
+ # through to `Dynamic[Top]`. The override is consulted
191
+ # before any other dispatch and bypasses fail-soft
192
+ # tracing on a recognised match.
193
+ declared = scope.declared_types[node]
194
+ return declared if declared
195
+
196
+ return type_of_virtual(node) if node.is_a?(AST::Node)
197
+
198
+ handler = PRISM_DISPATCH[node.class]
199
+ return send(handler, node) if handler
200
+
201
+ fallback_for(node, family: :prism)
202
+ end
203
+
204
+ private
205
+
206
+ attr_reader :scope, :tracer
207
+
208
+ def dynamic_top
209
+ Type::Combinator.untyped
210
+ end
211
+
212
+ def type_of_literal_value(node)
213
+ Type::Combinator.constant_of(node.value)
214
+ end
215
+
216
+ def type_of_true(_node)
217
+ Type::Combinator.constant_of(true)
218
+ end
219
+
220
+ def type_of_false(_node)
221
+ Type::Combinator.constant_of(false)
222
+ end
223
+
224
+ def type_of_nil(_node)
225
+ Type::Combinator.constant_of(nil)
226
+ end
227
+
228
+ # All `*WriteNode` flavours expose a `.value` rvalue child. Their type
229
+ # is the type of that rvalue. Binding the result back into the scope
230
+ # is the responsibility of the statement-level evaluator (Slice 3),
231
+ # never of `type_of` itself.
232
+ def type_of_assignment_write(node)
233
+ type_of(node.value)
234
+ end
235
+
236
+ # Slice 7 phase 1 — instance/class/global variable reads.
237
+ # Each lookup returns the type currently bound in the
238
+ # surrounding scope's per-kind binding map (populated by
239
+ # `StatementEvaluator` write handlers within the same
240
+ # method body), falling through to `Dynamic[Top]` when no
241
+ # binding is recorded. Cross-method ivar/cvar inference is
242
+ # a follow-up slice; the read handlers MUST NOT raise on a
243
+ # missing binding and MUST NOT record a fallback event in
244
+ # either branch — the absence of a binding is a recognised
245
+ # semantic outcome, not a fail-soft compromise.
246
+ def type_of_instance_variable_read(node)
247
+ scope.ivar(node.name) || dynamic_top
248
+ end
249
+
250
+ def type_of_class_variable_read(node)
251
+ scope.cvar(node.name) || dynamic_top
252
+ end
253
+
254
+ def type_of_global_variable_read(node)
255
+ scope.global(node.name) || dynamic_top
256
+ end
257
+
258
+ def type_of_statements_node(node)
259
+ statements_type_for(node)
260
+ end
261
+
262
+ def type_of_program(node)
263
+ statements_type_for(node.statements)
264
+ end
265
+
266
+ # Recognised position that does not produce a value: parameter lists
267
+ # and individual parameter declarations, splats inside argument
268
+ # lists, key-value pairs in hashes, and the implicit-rest token
269
+ # inside destructuring. Returning Dynamic[Top] silently keeps these
270
+ # off the unrecognised list without faking a value type.
271
+ def type_of_non_value(_node)
272
+ dynamic_top
273
+ end
274
+
275
+ # Recognised value-bearing position the Slice 2 engine does not yet
276
+ # narrow: self, instance/class/global variable reads, block bodies.
277
+ # Slice 3+ refines these in place; for now we acknowledge the node
278
+ # class so the coverage scanner stops flagging it without recording
279
+ # a fail-soft event for every occurrence.
280
+ # Slice A-engine. `Prism::SelfNode` resolves to the scope's
281
+ # `self_type` when one has been injected (by
282
+ # `StatementEvaluator` at class-body and method-body
283
+ # boundaries) or `Dynamic[Top]` at the top level. Class-body
284
+ # `self` is `Singleton[<class>]`; instance-method `self` is
285
+ # `Nominal[<class>]`; singleton-method `self` is
286
+ # `Singleton[<class>]`.
287
+ def type_of_self_node(_node)
288
+ scope.self_type || dynamic_top
289
+ end
290
+
291
+ def type_of_dynamic_top(_node)
292
+ dynamic_top
293
+ end
294
+
295
+ # The expression `Foo` evaluates to the *class object* `Foo`, not
296
+ # an instance. From Slice 4 phase 2b on we therefore type a
297
+ # bare-constant reference as `Singleton[Foo]`; method dispatch on
298
+ # that receiver looks up class methods (`Foo.new`, `Foo.bar`, ...).
299
+ #
300
+ # Slice A constant-walk: when the literal name does not resolve,
301
+ # we try a lexical walk based on the surrounding class context
302
+ # exposed through `scope.self_type` so a reference like
303
+ # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo`
304
+ # resolves to `Rigor::Inference::FallbackTracer`.
305
+ def type_of_constant_read(node)
306
+ resolve_constant_name(node.name.to_s) || fallback_for(node, family: :prism)
307
+ end
308
+
309
+ def type_of_constant_path(node)
310
+ full_name = build_constant_path_name(node)
311
+ return fallback_for(node, family: :prism) if full_name.nil?
312
+
313
+ resolve_constant_name(full_name) || fallback_for(node, family: :prism)
314
+ end
315
+
316
+ # Try the literal name first, then walk Ruby's lexical lookup by
317
+ # progressively prefixing the surrounding class path (peeled
318
+ # one `::segment` at a time). For each candidate the lookup
319
+ # consults `Environment#singleton_for_name` (a class object)
320
+ # and then `Environment#constant_for_name` (a non-class
321
+ # constant value such as `BUCKETS: Array[Symbol]`).
322
+ # Returns the matched `Rigor::Type` or nil; the caller decides
323
+ # whether to fall back.
324
+ def resolve_constant_name(name)
325
+ env = scope.environment
326
+ discovered = scope.discovered_classes
327
+ in_source = scope.in_source_constants
328
+ lexical_constant_candidates(name).each do |candidate|
329
+ singleton = env.singleton_for_name(candidate)
330
+ return singleton if singleton
331
+
332
+ in_source_class = discovered[candidate]
333
+ return in_source_class if in_source_class
334
+
335
+ # In-source value-bearing constants take precedence
336
+ # over RBS constant decls because user code is the
337
+ # authoritative source for its own constants.
338
+ in_source_value = in_source[candidate]
339
+ return in_source_value if in_source_value
340
+
341
+ value = env.constant_for_name(candidate)
342
+ return value if value
343
+ end
344
+ nil
345
+ end
346
+
347
+ # The candidate qualified names to try, in Ruby's lexical
348
+ # order: most-qualified first (the surrounding class path
349
+ # joined to `name`), then progressively less-qualified, then
350
+ # the bare `name`. Top-level scopes (no `self_type`) yield
351
+ # only `[name]`, preserving the pre-walk behaviour.
352
+ def lexical_constant_candidates(name)
353
+ prefix = enclosing_class_path
354
+ candidates = []
355
+ while prefix && !prefix.empty?
356
+ candidates << "#{prefix}::#{name}"
357
+ prefix = prefix.rpartition("::").first
358
+ prefix = nil if prefix.empty?
359
+ end
360
+ candidates << name
361
+ candidates
362
+ end
363
+
364
+ # Pulls the enclosing qualified class name out of
365
+ # `scope.self_type` when one is set. `Nominal[T]` and
366
+ # `Singleton[T]` both expose `class_name`. Returns nil when
367
+ # no class context is available (top-level).
368
+ def enclosing_class_path
369
+ st = scope.self_type
370
+ case st
371
+ when Type::Nominal, Type::Singleton then st.class_name
372
+ end
373
+ end
374
+
375
+ # Builds the dotted-colon name for a `Foo`, `Foo::Bar`, or `::Foo`
376
+ # path. Returns nil when an inner segment is not itself a constant
377
+ # reference (for example `expr::Foo`), so the caller can fall back.
378
+ def build_constant_path_name(node)
379
+ case node
380
+ when Prism::ConstantReadNode
381
+ node.name.to_s
382
+ when Prism::ConstantPathNode
383
+ parent = node.parent
384
+ return node.name.to_s if parent.nil?
385
+
386
+ parent_name = build_constant_path_name(parent)
387
+ return nil if parent_name.nil?
388
+
389
+ "#{parent_name}::#{node.name}"
390
+ end
391
+ end
392
+
393
+ # Slice 5 phase 1 upgrades hash literals to `HashShape{...}`
394
+ # when every entry is a static `AssocNode` whose key is a
395
+ # `SymbolNode` or `StringNode` with a known value (covering the
396
+ # `{ a: 1, "b" => 2 }` pattern and falling back to the generic
397
+ # `Hash[K, V]` form otherwise). Splatted entries
398
+ # (`{ **other }`) and dynamic keys widen to the underlying
399
+ # `Hash[K, V]` form by unioning the types each entry exposes;
400
+ # when no concrete pair survives we fall back to the raw `Hash`
401
+ # so callers stay backward compatible.
402
+ def type_of_hash(node)
403
+ elements = node.respond_to?(:elements) ? node.elements : []
404
+ return Type::Combinator.nominal_of(Hash) if elements.empty?
405
+
406
+ shape = static_hash_shape_for(elements)
407
+ return shape if shape
408
+
409
+ keys, values = generic_hash_pairs_for(elements)
410
+ return Type::Combinator.nominal_of(Hash) if keys.empty? || values.empty?
411
+
412
+ Type::Combinator.nominal_of(
413
+ Hash,
414
+ type_args: [Type::Combinator.union(*keys), Type::Combinator.union(*values)]
415
+ )
416
+ end
417
+
418
+ # Builds `HashShape{...}` when every entry is an `AssocNode`
419
+ # whose key is a static Symbol or String literal. Returns nil
420
+ # otherwise so the caller falls back to the generic shape.
421
+ def static_hash_shape_for(elements)
422
+ pairs = {}
423
+ elements.each do |entry|
424
+ return nil unless entry.is_a?(Prism::AssocNode)
425
+
426
+ key = static_hash_key(entry.key)
427
+ return nil if key.nil?
428
+ return nil if pairs.key?(key)
429
+
430
+ pairs[key] = type_of(entry.value)
431
+ end
432
+ return nil if pairs.empty?
433
+
434
+ Type::Combinator.hash_shape_of(pairs)
435
+ end
436
+
437
+ # Returns the static (Symbol|String) literal carried by a hash
438
+ # key node, or nil when the key is dynamic. We only treat
439
+ # SymbolNode#value and StringNode#unescaped as static when they
440
+ # are non-nil (interpolation produces a nil unescaped).
441
+ def static_hash_key(node)
442
+ case node
443
+ when Prism::SymbolNode
444
+ raw = node.value
445
+ raw&.to_sym
446
+ when Prism::StringNode
447
+ node.unescaped
448
+ end
449
+ end
450
+
451
+ def generic_hash_pairs_for(elements)
452
+ keys = []
453
+ values = []
454
+ elements.each do |entry|
455
+ next unless entry.is_a?(Prism::AssocNode)
456
+
457
+ keys << type_of(entry.key)
458
+ values << type_of(entry.value)
459
+ end
460
+ [keys, values]
461
+ end
462
+
463
+ def type_of_interpolated_string(_node)
464
+ Type::Combinator.nominal_of(String)
465
+ end
466
+
467
+ def type_of_interpolated_symbol(_node)
468
+ Type::Combinator.nominal_of(Symbol)
469
+ end
470
+
471
+ def type_of_embedded_statements(node)
472
+ statements_type_for(node.statements)
473
+ end
474
+
475
+ def type_of_def(node)
476
+ Type::Combinator.constant_of(node.name)
477
+ end
478
+
479
+ # `class Foo; body; end`, `module Foo; body; end`, and `class << x;
480
+ # body; end` evaluate to the value of the body's last expression,
481
+ # or `nil` when the body is empty. We do not track class/module
482
+ # scope yet, so the body is typed in the surrounding scope and
483
+ # that result is returned.
484
+ def type_of_class_or_module(node)
485
+ body = node.body
486
+ return Type::Combinator.constant_of(nil) if body.nil?
487
+
488
+ type_of(body)
489
+ end
490
+
491
+ # `alias x y`, `alias $x $y`, and `undef foo` all evaluate to nil at
492
+ # runtime; the constant carrier captures that exactly.
493
+ def type_of_nil_value(_node)
494
+ Type::Combinator.constant_of(nil)
495
+ end
496
+
497
+ # `if c; t; (elsif c2; ...; )* else; e; end`. Prism nests `elsif`
498
+ # branches as `IfNode#subsequent`. Slice 3 phase 1 types both
499
+ # branches in the receiver scope and returns their union; scope
500
+ # rebinding is the StatementEvaluator's job (Slice 3 phase 2).
501
+ # Without an else clause the branch's implicit value is nil, which
502
+ # is included in the union.
503
+ def type_of_if(node)
504
+ then_type = statements_or_nil(node.statements)
505
+ else_type =
506
+ if node.subsequent
507
+ type_of(node.subsequent)
508
+ else
509
+ Type::Combinator.constant_of(nil)
510
+ end
511
+ Type::Combinator.union(then_type, else_type)
512
+ end
513
+
514
+ # `unless c; t; else; e; end`. Prism uses `else_clause` here (no
515
+ # `elsif` chain).
516
+ def type_of_unless(node)
517
+ then_type = statements_or_nil(node.statements)
518
+ else_type =
519
+ if node.else_clause
520
+ type_of(node.else_clause)
521
+ else
522
+ Type::Combinator.constant_of(nil)
523
+ end
524
+ Type::Combinator.union(then_type, else_type)
525
+ end
526
+
527
+ def type_of_else(node)
528
+ statements_or_nil(node.statements)
529
+ end
530
+
531
+ # `a && b` and `a || b` short-circuit. Without a truthy/falsy
532
+ # narrowing model (Slice 6), the result of either side is reachable
533
+ # so the type is the union of the operand types.
534
+ def type_of_and_or(node)
535
+ Type::Combinator.union(type_of(node.left), type_of(node.right))
536
+ end
537
+
538
+ def type_of_case(node)
539
+ branch_types = node.conditions.map { |branch| type_of(branch) }
540
+ else_type =
541
+ if node.else_clause
542
+ type_of(node.else_clause)
543
+ else
544
+ Type::Combinator.constant_of(nil)
545
+ end
546
+ Type::Combinator.union(*branch_types, else_type)
547
+ end
548
+
549
+ # `when` clauses for `case` and `in` clauses for `case ... in` have
550
+ # the same body shape; we reuse one handler for both Prism node
551
+ # classes.
552
+ def type_of_when_or_in(node)
553
+ statements_or_nil(node.statements)
554
+ end
555
+
556
+ # `begin; body; rescue R => e; r1; rescue; r2; else; e; ensure; f; end`.
557
+ # The result is the union of every value-producing branch: the body
558
+ # (or the else-clause when present, since it replaces the body's
559
+ # value when no exception fires), plus each rescue body in the
560
+ # rescue chain. The ensure clause runs but does not contribute to
561
+ # the begin's value.
562
+ def type_of_begin(node)
563
+ rescue_clause = node.rescue_clause
564
+ else_clause = node.else_clause
565
+
566
+ primary_type =
567
+ if else_clause
568
+ type_of(else_clause)
569
+ elsif node.statements
570
+ statements_or_nil(node.statements)
571
+ else
572
+ Type::Combinator.constant_of(nil)
573
+ end
574
+
575
+ rescue_types = rescue_chain_types(rescue_clause)
576
+ Type::Combinator.union(primary_type, *rescue_types)
577
+ end
578
+
579
+ def rescue_chain_types(rescue_node)
580
+ types = []
581
+ current = rescue_node
582
+ while current
583
+ types << statements_or_nil(current.statements)
584
+ current = current.subsequent
585
+ end
586
+ types
587
+ end
588
+
589
+ def type_of_rescue(node)
590
+ statements_or_nil(node.statements)
591
+ end
592
+
593
+ # `expr rescue fallback` is RescueModifierNode in Prism. The result
594
+ # is `expr`'s type when no exception is raised and `fallback`'s
595
+ # type otherwise; both paths are reachable, so the result is their
596
+ # union.
597
+ def type_of_rescue_modifier(node)
598
+ Type::Combinator.union(type_of(node.expression), type_of(node.rescue_expression))
599
+ end
600
+
601
+ def type_of_ensure(node)
602
+ statements_or_nil(node.statements)
603
+ end
604
+
605
+ # `return`, `break`, `next`, `retry`, and `redo` all transfer
606
+ # control instead of producing a value. Their type is Bot, the
607
+ # empty type that absorbs cleanly under union (e.g.
608
+ # `Constant[1] | Bot == Constant[1]`), so the surrounding
609
+ # control-flow handlers collapse correctly when one branch jumps.
610
+ def type_of_jump(_node)
611
+ Type::Combinator.bot
612
+ end
613
+
614
+ # `while` and `until` loops produce nil unless interrupted by
615
+ # `break VALUE`, which Slice 3 phase 1 does not yet model.
616
+ # Returning Constant[nil] is safe and matches Ruby semantics for
617
+ # the common case.
618
+ def type_of_loop(_node)
619
+ Type::Combinator.constant_of(nil)
620
+ end
621
+
622
+ def type_of_lambda(_node)
623
+ Type::Combinator.nominal_of(Proc)
624
+ end
625
+
626
+ def type_of_range(node)
627
+ left_static, left = static_range_endpoint(node.left)
628
+ right_static, right = static_range_endpoint(node.right)
629
+ return Type::Combinator.nominal_of(Range) unless left_static && right_static
630
+
631
+ Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?))
632
+ end
633
+
634
+ def type_of_regexp(_node)
635
+ Type::Combinator.nominal_of(Regexp)
636
+ end
637
+
638
+ def static_range_endpoint(node)
639
+ return [true, nil] if node.nil?
640
+ return [true, node.value] if node.is_a?(Prism::IntegerNode)
641
+
642
+ [false, nil]
643
+ end
644
+
645
+ # Helper for the many control-flow handlers that read a body
646
+ # `Prism::StatementsNode` or treat its absence as nil. Note that
647
+ # Prism uses nil (rather than an empty `StatementsNode`) for
648
+ # missing bodies in many node kinds.
649
+ def statements_or_nil(statements_node)
650
+ return Type::Combinator.constant_of(nil) if statements_node.nil?
651
+
652
+ statements_type_for(statements_node)
653
+ end
654
+
655
+ def type_of_virtual(node)
656
+ case node
657
+ when AST::TypeNode then node.type
658
+ else
659
+ fallback_for(node, family: :virtual)
660
+ end
661
+ end
662
+
663
+ def fallback_for(node, family:)
664
+ inner = dynamic_top
665
+ record_fallback(node, family: family, inner_type: inner)
666
+ inner
667
+ end
668
+
669
+ def record_fallback(node, family:, inner_type:)
670
+ return unless tracer
671
+
672
+ location = node.respond_to?(:location) ? node.location : nil
673
+ event = Fallback.new(
674
+ node_class: node.class,
675
+ location: location,
676
+ family: family,
677
+ inner_type: inner_type
678
+ )
679
+ tracer.record_fallback(event)
680
+ end
681
+
682
+ def symbol_type_for(node)
683
+ raw = node.value
684
+ return Type::Combinator.nominal_of(Symbol) if raw.nil?
685
+
686
+ Type::Combinator.constant_of(raw.to_sym)
687
+ end
688
+
689
+ def string_type_for(node)
690
+ unescaped = node.unescaped
691
+ return Type::Combinator.nominal_of(String) if unescaped.nil?
692
+
693
+ Type::Combinator.constant_of(unescaped)
694
+ end
695
+
696
+ def local_read(node)
697
+ scope.local(node.name) || dynamic_top
698
+ end
699
+
700
+ # Slice 5 phase 1 upgrades array literals to `Tuple[T1..Tn]`
701
+ # when every element is a non-splat value. Splatted entries
702
+ # (`[*xs, 1]`) preserve the Slice 4 phase 2d behavior: we union
703
+ # the contributed element types and emit
704
+ # `Nominal[Array, [union]]`. An empty literal stays as the raw
705
+ # `Array` (no element evidence to lock either an arity or an
706
+ # element type).
707
+ def array_type_for(node)
708
+ elements = node.elements
709
+ return Type::Combinator.nominal_of(Array) if elements.empty?
710
+
711
+ if elements.any?(Prism::SplatNode)
712
+ element_types = elements.map { |e| type_of(e) }
713
+ element_union = Type::Combinator.union(*element_types)
714
+ return Type::Combinator.nominal_of(Array, type_args: [element_union])
715
+ end
716
+
717
+ Type::Combinator.tuple_of(*elements.map { |e| type_of(e) })
718
+ end
719
+
720
+ def parentheses_type_for(node)
721
+ body = node.body
722
+ return Type::Combinator.constant_of(nil) if body.nil?
723
+
724
+ type_of(body)
725
+ end
726
+
727
+ def statements_type_for(statements_node)
728
+ return Type::Combinator.constant_of(nil) if statements_node.nil?
729
+
730
+ body = statements_node.body
731
+ return Type::Combinator.constant_of(nil) if body.empty?
732
+
733
+ type_of(body.last)
734
+ end
735
+
736
+ # Slice 2 routes call expressions through `MethodDispatcher`. The
737
+ # receiver and every argument are typed first, then the dispatcher is
738
+ # asked for a result type. A nil result triggers the fail-soft fallback
739
+ # for the CallNode itself (the inner type_of calls already record
740
+ # their own fallbacks for unrecognised receivers/args, so the tracer
741
+ # captures both the immediate dispatch miss and the deeper cause).
742
+ def call_type_for(node)
743
+ receiver = call_receiver_type_for(node)
744
+ arg_types = call_arg_types(node)
745
+ block_type = block_return_type_for(node, receiver, arg_types)
746
+
747
+ result = MethodDispatcher.dispatch(
748
+ receiver_type: receiver,
749
+ method_name: node.name,
750
+ arg_types: arg_types,
751
+ block_type: block_type,
752
+ environment: scope.environment
753
+ )
754
+ return result if result
755
+
756
+ # Dynamic-origin propagation: when the receiver is Dynamic[T] and
757
+ # no positive rule resolves the call, the result inherits the
758
+ # dynamic origin. Per the value-lattice algebra, this is a
759
+ # recognised semantic outcome, not a fail-soft compromise, so it
760
+ # MUST NOT record a tracer event.
761
+ return dynamic_top if receiver.is_a?(Type::Dynamic)
762
+
763
+ fallback_for(node, family: :prism)
764
+ end
765
+
766
+ # Slice A-engine. Implicit-self calls (no `node.receiver`)
767
+ # adopt the surrounding scope's `self_type` as their receiver
768
+ # so calls like `attr_reader_method_name` or
769
+ # `private_helper(...)` inside an instance method dispatch
770
+ # against the enclosing class. Slice 7 phase 10 — when
771
+ # `self_type` is nil (top-level program), the receiver
772
+ # MUST default to `Nominal[Object]` so Kernel intrinsics
773
+ # like `require`, `require_relative`, `raise`, and `puts`
774
+ # dispatch through Object/Kernel rather than falling through
775
+ # to `Dynamic[Top]`.
776
+ def call_receiver_type_for(node)
777
+ return type_of(node.receiver) if node.receiver
778
+
779
+ scope.self_type || implicit_top_level_self
780
+ end
781
+
782
+ def implicit_top_level_self
783
+ scope.environment.nominal_for_name("Object") || dynamic_top
784
+ end
785
+
786
+ def call_arg_types(node)
787
+ arguments_node = node.arguments
788
+ return [] if arguments_node.nil?
789
+
790
+ arguments_node.arguments.map { |argument| type_of(argument) }
791
+ end
792
+
793
+ # When the call carries a `Prism::BlockNode`, build the block's
794
+ # entry scope (outer locals plus parameter bindings driven by
795
+ # the receiving method's RBS signature), type the block body
796
+ # under that scope, and return the body's value type. The
797
+ # result feeds `MethodDispatcher.dispatch`'s `block_type:` so
798
+ # generic methods like `Array#map[U] { (Elem) -> U } -> Array[U]`
799
+ # resolve `U` to the block's return type. Returns `nil` when
800
+ # the call has no block, when the receiver is unknown, or
801
+ # when typing the body raises (defensive against malformed
802
+ # subtrees); the dispatcher then runs in its no-block-aware
803
+ # path.
804
+ def block_return_type_for(call_node, receiver_type, arg_types)
805
+ block_node = call_node.block
806
+ return nil unless block_node.is_a?(Prism::BlockNode)
807
+ return nil if receiver_type.nil?
808
+
809
+ expected = MethodDispatcher.expected_block_param_types(
810
+ receiver_type: receiver_type,
811
+ method_name: call_node.name,
812
+ arg_types: arg_types,
813
+ environment: scope.environment
814
+ )
815
+ bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
816
+ block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
817
+ type_block_body(block_node, block_scope)
818
+ rescue StandardError
819
+ nil
820
+ end
821
+
822
+ def type_block_body(block_node, block_scope)
823
+ body = block_node.body
824
+ return Type::Combinator.constant_of(nil) if body.nil?
825
+
826
+ block_scope.type_of(body)
827
+ end
828
+ end
829
+ # rubocop:enable Metrics/ClassLength
830
+ end
831
+ end