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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Translates `RBS::Types::*` instances into `Rigor::Type` values.
10
+ #
11
+ # Slice 4 phase 2d adds two pieces of generic plumbing:
12
+ # - `RBS::Types::ClassInstance` arguments are translated recursively
13
+ # so `Array[Integer]` becomes `Nominal["Array", [Nominal["Integer"]]]`
14
+ # (and `Hash[Symbol, Integer]` becomes `Nominal["Hash", [...]]`).
15
+ # - `RBS::Types::Variable` consults a caller-supplied substitution
16
+ # map (`type_vars:`) keyed by the variable's RBS name. When the
17
+ # variable is bound, the bound `Rigor::Type` is returned unchanged;
18
+ # when it is not bound, the variable degrades to `Dynamic[Top]` so
19
+ # uninstantiated generics keep their fail-soft behavior.
20
+ #
21
+ # Slice 5 phase 1 maps tuples and records to their dedicated shape
22
+ # carriers:
23
+ # - `RBS::Types::Tuple` becomes `Rigor::Type::Tuple[...]` so the
24
+ # arity and per-position element types survive the boundary.
25
+ # - `RBS::Types::Record` becomes an exact closed
26
+ # `Rigor::Type::HashShape{...}`, carrying required and optional
27
+ # fields intact.
28
+ # Element and value types are translated recursively under the
29
+ # caller's `self_type` / `instance_type` / `type_vars` context.
30
+ #
31
+ # Interface and intersection types still degrade to `Dynamic[Top]`;
32
+ # they are bound to acceptance and dispatch rules that Slice 5+
33
+ # will replace.
34
+ #
35
+ # The optional `self_type:` and `instance_type:` arguments are the
36
+ # Rigor counterparts of RBS's `self` and `instance` tokens:
37
+ # - `self_type` substitutes for `Bases::Self`. Inside an instance
38
+ # method body it is `Nominal[C]`; inside a singleton method body
39
+ # it is `Singleton[C]`.
40
+ # - `instance_type` substitutes for `Bases::Instance` and is always
41
+ # `Nominal[C]` regardless of which method body we are in.
42
+ # When either argument is omitted, the corresponding token degrades
43
+ # to Dynamic[Top].
44
+ # rubocop:disable Metrics/ModuleLength
45
+ module RbsTypeTranslator
46
+ # Hash-based dispatch keeps `translate` linear and dodges the
47
+ # bookkeeping costs of a 20-arm `case` (RuboCop AbcSize/CCN/Length
48
+ # all spike on that shape). Anonymous RBS-type subclasses are not
49
+ # expected; the table only maps the concrete leaf classes shipped
50
+ # by the `rbs` gem.
51
+ TRANSLATORS = {
52
+ RBS::Types::Bases::Top => :translate_top,
53
+ RBS::Types::Bases::Bottom => :translate_bot,
54
+ RBS::Types::Bases::Any => :translate_untyped,
55
+ RBS::Types::Bases::Nil => :translate_nil,
56
+ RBS::Types::Bases::Bool => :translate_bool,
57
+ RBS::Types::Bases::Self => :translate_self,
58
+ RBS::Types::Bases::Instance => :translate_instance,
59
+ RBS::Types::Bases::Class => :translate_untyped,
60
+ RBS::Types::Bases::Void => :translate_untyped,
61
+ RBS::Types::Optional => :translate_optional,
62
+ RBS::Types::Union => :translate_union,
63
+ RBS::Types::Literal => :translate_literal,
64
+ RBS::Types::ClassInstance => :translate_class_instance,
65
+ RBS::Types::Tuple => :translate_tuple,
66
+ RBS::Types::Record => :translate_record,
67
+ RBS::Types::Proc => :translate_proc_nominal,
68
+ RBS::Types::ClassSingleton => :translate_class_singleton,
69
+ RBS::Types::Alias => :translate_untyped,
70
+ RBS::Types::Intersection => :translate_untyped,
71
+ RBS::Types::Variable => :translate_variable,
72
+ RBS::Types::Interface => :translate_untyped
73
+ }.freeze
74
+ private_constant :TRANSLATORS
75
+
76
+ EMPTY_TYPE_VARS = {}.freeze
77
+ private_constant :EMPTY_TYPE_VARS
78
+
79
+ class << self
80
+ # @param rbs_type [RBS::Types::Bases::Base, RBS::Types::ClassInstance, ...]
81
+ # @param self_type [Rigor::Type, nil] substitute for `Bases::Self`.
82
+ # @param instance_type [Rigor::Type, nil] substitute for
83
+ # `Bases::Instance`. Defaults to `nil`, which degrades to
84
+ # Dynamic[Top].
85
+ # @param type_vars [Hash{Symbol => Rigor::Type}] substitution map
86
+ # for `Bases::Variable`. Keys are the RBS variable names (e.g.,
87
+ # `:Elem`); values are Rigor types that replace the variable.
88
+ # Variables that are not bound in the map degrade to Dynamic[Top].
89
+ # @return [Rigor::Type]
90
+ def translate(rbs_type, self_type: nil, instance_type: nil, type_vars: EMPTY_TYPE_VARS)
91
+ handler = TRANSLATORS[rbs_type.class]
92
+ return send(handler, rbs_type, self_type, instance_type, type_vars) if handler
93
+
94
+ Type::Combinator.untyped
95
+ end
96
+
97
+ private
98
+
99
+ def translate_top(_rbs_type, _self_type, _instance_type, _type_vars)
100
+ Type::Combinator.top
101
+ end
102
+
103
+ def translate_bot(_rbs_type, _self_type, _instance_type, _type_vars)
104
+ Type::Combinator.bot
105
+ end
106
+
107
+ def translate_untyped(_rbs_type, _self_type, _instance_type, _type_vars)
108
+ Type::Combinator.untyped
109
+ end
110
+
111
+ def translate_nil(_rbs_type, _self_type, _instance_type, _type_vars)
112
+ Type::Combinator.constant_of(nil)
113
+ end
114
+
115
+ # `bool` in RBS denotes `true | false`. We fold it to that union
116
+ # eagerly so downstream comparisons (e.g., `result == Constant[true]`)
117
+ # remain structural. Memoized at the module level because the
118
+ # union is value-equal across calls.
119
+ def translate_bool(_rbs_type, _self_type, _instance_type, _type_vars)
120
+ BOOL_UNION
121
+ end
122
+
123
+ BOOL_UNION = Type::Combinator.union(
124
+ Type::Combinator.constant_of(true),
125
+ Type::Combinator.constant_of(false)
126
+ ).freeze
127
+ private_constant :BOOL_UNION
128
+
129
+ def translate_self(_rbs_type, self_type, _instance_type, _type_vars)
130
+ self_type || Type::Combinator.untyped
131
+ end
132
+
133
+ def translate_instance(_rbs_type, _self_type, instance_type, _type_vars)
134
+ instance_type || Type::Combinator.untyped
135
+ end
136
+
137
+ def translate_optional(rbs_type, self_type, instance_type, type_vars)
138
+ inner = translate(rbs_type.type, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
139
+ Type::Combinator.union(inner, Type::Combinator.constant_of(nil))
140
+ end
141
+
142
+ def translate_union(rbs_type, self_type, instance_type, type_vars)
143
+ members = rbs_type.types.map do |t|
144
+ translate(t, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
145
+ end
146
+ Type::Combinator.union(*members)
147
+ end
148
+
149
+ def translate_literal(rbs_type, _self_type, _instance_type, _type_vars)
150
+ Type::Combinator.constant_of(rbs_type.literal)
151
+ end
152
+
153
+ # Slice 4 phase 2d translates the type arguments recursively so
154
+ # `Array[Integer]` round-trips into `Nominal["Array", [Nominal["Integer"]]]`.
155
+ # Variables inside the args participate in substitution through
156
+ # the same `type_vars:` map.
157
+ def translate_class_instance(rbs_type, self_type, instance_type, type_vars)
158
+ name = rbs_type.name.relative!.to_s
159
+ translated_args = rbs_type.args.map do |arg|
160
+ translate(arg, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
161
+ end
162
+ Type::Combinator.nominal_of(name, type_args: translated_args)
163
+ end
164
+
165
+ # Slice 5 phase 1: preserve tuple precision through the
166
+ # boundary. Each positional element type is translated
167
+ # recursively under the caller's substitution context, and the
168
+ # resulting list is wrapped in a `Rigor::Type::Tuple`.
169
+ def translate_tuple(rbs_type, self_type, instance_type, type_vars)
170
+ elements = rbs_type.types.map do |t|
171
+ translate(t, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
172
+ end
173
+ Type::Combinator.tuple_of(*elements)
174
+ end
175
+
176
+ # Slice 5: preserve hash-record precision through the boundary.
177
+ # RBS records use Symbol keys; the translator keeps them as
178
+ # Symbol keys on the resulting exact closed HashShape so
179
+ # erasure can round-trip back to `{ a: T, ?b: U }` syntax.
180
+ def translate_record(rbs_type, self_type, instance_type, type_vars)
181
+ pairs = rbs_type.fields.each_with_object({}) do |(key, value), acc|
182
+ acc[key] = translate(value, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
183
+ end
184
+ optional_pairs = rbs_type.optional_fields.each_with_object({}) do |(key, value), acc|
185
+ acc[key] = translate(value, self_type: self_type, instance_type: instance_type, type_vars: type_vars)
186
+ end
187
+ Type::Combinator.hash_shape_of(
188
+ pairs.merge(optional_pairs),
189
+ required_keys: pairs.keys,
190
+ optional_keys: optional_pairs.keys,
191
+ extra_keys: :closed
192
+ )
193
+ end
194
+
195
+ def translate_proc_nominal(_rbs_type, _self_type, _instance_type, _type_vars)
196
+ Type::Combinator.nominal_of(Proc)
197
+ end
198
+
199
+ # `singleton(Foo)` is the type of the constant `Foo` itself
200
+ # (the class object). With the dedicated Singleton type added in
201
+ # Slice 4 phase 2b, we map directly to `Singleton[Foo]`.
202
+ def translate_class_singleton(rbs_type, _self_type, _instance_type, _type_vars)
203
+ name = rbs_type.name.relative!.to_s
204
+ Type::Combinator.singleton_of(name)
205
+ end
206
+
207
+ # Slice 4 phase 2d. Looks up the variable's RBS name in the
208
+ # substitution map; bound variables are replaced inline, free
209
+ # variables degrade to Dynamic[Top]. We use `fetch` with a
210
+ # default rather than `[]` so a deliberate `nil` binding (a
211
+ # caller mistake) is never silently consumed.
212
+ def translate_variable(rbs_type, _self_type, _instance_type, type_vars)
213
+ type_vars.fetch(rbs_type.name) { Type::Combinator.untyped }
214
+ end
215
+ end
216
+ end
217
+ # rubocop:enable Metrics/ModuleLength
218
+ end
219
+ end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../scope"
6
+ require_relative "../type"
7
+ require_relative "statement_evaluator"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # Builds a per-node scope index for a Prism program by running
12
+ # `Rigor::Inference::StatementEvaluator` over the root and recording
13
+ # the entry scope visible at every node. Expression-interior nodes
14
+ # the evaluator does not specialise (call receivers, arguments,
15
+ # array/hash elements, ...) inherit their nearest statement-y
16
+ # ancestor's recorded scope, so a downstream caller that looks up
17
+ # the scope for any Prism node in the tree always gets the scope
18
+ # that was effectively visible at that point.
19
+ #
20
+ # The CLI commands `rigor type-of` and `rigor type-scan` consume
21
+ # the index so that local-variable bindings established earlier in
22
+ # the program are visible to the typer when probing later nodes.
23
+ # Without the index, both commands would type every node under an
24
+ # empty scope and miss the constant-folding / dispatch precision
25
+ # that Slice 3 phase 2's StatementEvaluator unlocks.
26
+ #
27
+ # The returned object is an identity-comparing Hash:
28
+ #
29
+ # ```ruby
30
+ # index = Rigor::Inference::ScopeIndexer.index(program, default_scope: Scope.empty)
31
+ # index[some_prism_node] #=> the Rigor::Scope visible at that node
32
+ # ```
33
+ #
34
+ # Nodes that are not part of the program subtree (e.g. synthesised
35
+ # virtual nodes that the caller looks up after the fact) yield the
36
+ # `default_scope`. The returned Hash is mutable in principle but
37
+ # callers MUST treat it as read-only; the indexer itself never
38
+ # exposes a way to update it past construction.
39
+ # rubocop:disable Metrics/ModuleLength
40
+ module ScopeIndexer
41
+ module_function
42
+
43
+ # Build the scope index for a Prism program subtree.
44
+ #
45
+ # @param root [Prism::Node] usually a `Prism::ProgramNode`, but any
46
+ # subtree the caller wants the indexer to walk works.
47
+ # @param default_scope [Rigor::Scope] the scope used for the root,
48
+ # and the fallback returned for any Prism node not contained in
49
+ # `root`'s subtree.
50
+ # @return [Hash{Prism::Node => Rigor::Scope}] identity-comparing
51
+ # table whose default value is `default_scope`.
52
+ def index(root, default_scope:) # rubocop:disable Metrics/AbcSize
53
+ # Slice A-declarations. Build the declaration overrides
54
+ # first so every scope handed to the StatementEvaluator
55
+ # already carries the table; structural sharing through
56
+ # `Scope#with_local` / `#with_fact` / `#with_self_type`
57
+ # propagates it across every derived scope.
58
+ declared_types, discovered_classes = build_declaration_artifacts(root)
59
+ seeded_scope = default_scope
60
+ .with_declared_types(declared_types)
61
+ .with_discovered_classes(discovered_classes)
62
+
63
+ # Slice 7 phase 2. Pre-pass over every class/module body
64
+ # to collect the per-class ivar accumulator. Seeded after
65
+ # declared_types so the rvalue typer in the pre-pass can
66
+ # see declaration overrides.
67
+ class_ivars = build_class_ivar_index(root, seeded_scope)
68
+ seeded_scope = seeded_scope.with_class_ivars(class_ivars)
69
+
70
+ # Slice 7 phase 6. Same pre-pass shape for cvars (per
71
+ # class) and globals (program-wide). Globals are also
72
+ # materialised into the top-level scope's `globals` map
73
+ # so reads at the top level (and in CLI probes that do
74
+ # not enter a method body) observe the precise type
75
+ # without consulting the accumulator on every lookup.
76
+ class_cvars = build_class_cvar_index(root, seeded_scope)
77
+ seeded_scope = seeded_scope.with_class_cvars(class_cvars)
78
+ program_globals = build_program_global_index(root, seeded_scope)
79
+ seeded_scope = seeded_scope.with_program_globals(program_globals)
80
+ program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }
81
+
82
+ # Slice 7 phase 9. In-source constant value tracking.
83
+ # Walks every ConstantWriteNode/ConstantPathWriteNode in
84
+ # the program and types its rvalue under a scope that
85
+ # carries the surrounding qualified prefix as
86
+ # `self_type`, so the rvalue typer sees in-class
87
+ # references resolve correctly. Multiple writes to the
88
+ # same qualified name union via `Type::Combinator.union`.
89
+ in_source_constants = build_in_source_constants(root, seeded_scope)
90
+ seeded_scope = seeded_scope.with_in_source_constants(in_source_constants)
91
+
92
+ # Slice 7 phase 12. In-source method discovery. Walks
93
+ # every class/module body for `Prism::DefNode` and
94
+ # recognised `define_method` calls and records the
95
+ # introduced method names. `rigor check` consults the
96
+ # table to suppress false positives for methods the
97
+ # user has defined but no RBS sig describes.
98
+ discovered_methods = build_discovered_methods(root)
99
+ seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
100
+
101
+ table = {}.compare_by_identity
102
+ table.default = seeded_scope
103
+
104
+ on_enter = ->(node, scope) { table[node] = scope unless table.key?(node) }
105
+ StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)
106
+
107
+ propagate(root, table, seeded_scope)
108
+ table
109
+ end
110
+
111
+ # Slice 7 phase 2. Builds the class-level ivar accumulator
112
+ # by walking every `Prism::ClassNode` / `Prism::ModuleNode`
113
+ # body, descending into each nested `Prism::DefNode`, and
114
+ # typing every `Prism::InstanceVariableWriteNode` rvalue
115
+ # under a scope that carries the appropriate `self_type`
116
+ # for that def (singleton vs instance). The rvalue is
117
+ # typed with NO local bindings — the pre-pass lacks
118
+ # statement-level threading — so `@x = 1` records
119
+ # `Constant[1]` but `@x = some_local + 1` records
120
+ # `Dynamic[Top]` (since `some_local` is unbound at
121
+ # pre-pass time). Multiple writes to the same ivar union
122
+ # via `Type::Combinator.union`.
123
+ def build_class_ivar_index(root, default_scope)
124
+ accumulator = {}
125
+ walk_class_ivars(root, [], default_scope, accumulator)
126
+ accumulator.transform_values(&:freeze).freeze
127
+ end
128
+
129
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator)
130
+ return unless node.is_a?(Prism::Node)
131
+
132
+ case node
133
+ when Prism::ClassNode, Prism::ModuleNode
134
+ name = qualified_name_for(node.constant_path)
135
+ if name
136
+ child_prefix = qualified_prefix + [name]
137
+ walk_class_ivars(node.body, child_prefix, default_scope, accumulator) if node.body
138
+ return
139
+ end
140
+ when Prism::DefNode
141
+ collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator)
142
+ return
143
+ end
144
+
145
+ node.compact_child_nodes.each do |child|
146
+ walk_class_ivars(child, qualified_prefix, default_scope, accumulator)
147
+ end
148
+ end
149
+
150
+ def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator)
151
+ return if def_node.body.nil? || qualified_prefix.empty?
152
+
153
+ class_name = qualified_prefix.join("::")
154
+ self_type =
155
+ if def_node.receiver.is_a?(Prism::SelfNode)
156
+ Type::Combinator.singleton_of(class_name)
157
+ else
158
+ Type::Combinator.nominal_of(class_name)
159
+ end
160
+ body_scope = default_scope.with_self_type(self_type)
161
+
162
+ gather_ivar_writes(def_node.body, body_scope, class_name, accumulator)
163
+ end
164
+
165
+ IVAR_BARRIER_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze
166
+ private_constant :IVAR_BARRIER_NODES
167
+
168
+ def gather_ivar_writes(node, scope, class_name, accumulator)
169
+ return unless node.is_a?(Prism::Node)
170
+
171
+ record_ivar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::InstanceVariableWriteNode)
172
+
173
+ # Don't recurse into nested defs, classes, or modules; their
174
+ # ivars belong to their own enclosing class.
175
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
176
+
177
+ node.compact_child_nodes.each { |c| gather_ivar_writes(c, scope, class_name, accumulator) }
178
+ end
179
+
180
+ def record_ivar_write(node, scope, class_name, accumulator)
181
+ rvalue_type = scope.type_of(node.value)
182
+ accumulator[class_name] ||= {}
183
+ existing = accumulator[class_name][node.name]
184
+ accumulator[class_name][node.name] =
185
+ existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
186
+ end
187
+
188
+ # Slice 7 phase 6 — class-cvar pre-pass. Same shape as the
189
+ # ivar pre-pass but collects `Prism::ClassVariableWriteNode`
190
+ # writes inside ANY def body (instance or singleton) of the
191
+ # enclosing class, because Ruby cvars are shared across both
192
+ # facets. The resulting table is seeded into both instance
193
+ # and singleton method bodies through
194
+ # `Scope#class_cvars_for`.
195
+ def build_class_cvar_index(root, default_scope)
196
+ accumulator = {}
197
+ walk_class_cvars(root, [], default_scope, accumulator)
198
+ accumulator.transform_values(&:freeze).freeze
199
+ end
200
+
201
+ def walk_class_cvars(node, qualified_prefix, default_scope, accumulator)
202
+ return unless node.is_a?(Prism::Node)
203
+
204
+ case node
205
+ when Prism::ClassNode, Prism::ModuleNode
206
+ name = qualified_name_for(node.constant_path)
207
+ if name
208
+ child_prefix = qualified_prefix + [name]
209
+ walk_class_cvars(node.body, child_prefix, default_scope, accumulator) if node.body
210
+ return
211
+ end
212
+ when Prism::DefNode
213
+ collect_def_cvar_writes(node, qualified_prefix, default_scope, accumulator)
214
+ return
215
+ end
216
+
217
+ node.compact_child_nodes.each do |child|
218
+ walk_class_cvars(child, qualified_prefix, default_scope, accumulator)
219
+ end
220
+ end
221
+
222
+ def collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator)
223
+ return if def_node.body.nil? || qualified_prefix.empty?
224
+
225
+ class_name = qualified_prefix.join("::")
226
+ body_scope = default_scope.with_self_type(Type::Combinator.nominal_of(class_name))
227
+ gather_cvar_writes(def_node.body, body_scope, class_name, accumulator)
228
+ end
229
+
230
+ def gather_cvar_writes(node, scope, class_name, accumulator)
231
+ return unless node.is_a?(Prism::Node)
232
+
233
+ record_cvar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::ClassVariableWriteNode)
234
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
235
+
236
+ node.compact_child_nodes.each { |c| gather_cvar_writes(c, scope, class_name, accumulator) }
237
+ end
238
+
239
+ def record_cvar_write(node, scope, class_name, accumulator)
240
+ rvalue_type = scope.type_of(node.value)
241
+ accumulator[class_name] ||= {}
242
+ existing = accumulator[class_name][node.name]
243
+ accumulator[class_name][node.name] =
244
+ existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
245
+ end
246
+
247
+ # Slice 7 phase 6 — program-global pre-pass. Globals are
248
+ # process-wide so the accumulator is a flat
249
+ # `Hash[Symbol, Type::t]` populated from every
250
+ # `Prism::GlobalVariableWriteNode` in the program (top-level
251
+ # AND inside method bodies). The same accumulator is
252
+ # seeded into every method body and the top-level scope.
253
+ def build_program_global_index(root, default_scope)
254
+ accumulator = {}
255
+ gather_global_writes(root, default_scope, accumulator)
256
+ accumulator.freeze
257
+ end
258
+
259
+ def gather_global_writes(node, scope, accumulator)
260
+ return unless node.is_a?(Prism::Node)
261
+
262
+ record_global_write(node, scope, accumulator) if node.is_a?(Prism::GlobalVariableWriteNode)
263
+ node.compact_child_nodes.each { |c| gather_global_writes(c, scope, accumulator) }
264
+ end
265
+
266
+ def record_global_write(node, scope, accumulator)
267
+ rvalue_type = scope.type_of(node.value)
268
+ existing = accumulator[node.name]
269
+ accumulator[node.name] =
270
+ existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
271
+ end
272
+
273
+ # Slice 7 phase 9 — in-source constant value pre-pass.
274
+ # Walks the entire program (top-level AND inside class /
275
+ # module / def bodies) for `Prism::ConstantWriteNode` and
276
+ # `Prism::ConstantPathWriteNode`, types each rvalue, and
277
+ # accumulates by qualified name. Constants defined inside
278
+ # a class body are qualified with the surrounding class
279
+ # path; constants written via a path (`Foo::BAR = ...`)
280
+ # use the rendered path as-is.
281
+ def build_in_source_constants(root, default_scope)
282
+ accumulator = {}
283
+ walk_constant_writes(root, [], default_scope, accumulator)
284
+ accumulator.freeze
285
+ end
286
+
287
+ def walk_constant_writes(node, qualified_prefix, default_scope, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
288
+ return unless node.is_a?(Prism::Node)
289
+
290
+ case node
291
+ when Prism::ClassNode, Prism::ModuleNode
292
+ name = qualified_name_for(node.constant_path)
293
+ if name
294
+ child_prefix = qualified_prefix + [name]
295
+ walk_constant_writes(node.body, child_prefix, default_scope, accumulator) if node.body
296
+ return
297
+ end
298
+ when Prism::ConstantWriteNode
299
+ record_constant_write(node, qualified_prefix, default_scope, accumulator, node.name.to_s)
300
+ return
301
+ when Prism::ConstantPathWriteNode
302
+ full = qualified_name_for(node.target)
303
+ record_constant_write(node, [], default_scope, accumulator, full) if full
304
+ return
305
+ end
306
+
307
+ node.compact_child_nodes.each do |child|
308
+ walk_constant_writes(child, qualified_prefix, default_scope, accumulator)
309
+ end
310
+ end
311
+
312
+ def record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name)
313
+ full = qualified_prefix.empty? ? base_name : "#{qualified_prefix.join('::')}::#{base_name}"
314
+ body_scope = default_scope
315
+ unless qualified_prefix.empty?
316
+ body_scope = body_scope.with_self_type(Type::Combinator.singleton_of(qualified_prefix.join("::")))
317
+ end
318
+ rvalue_type = body_scope.type_of(node.value)
319
+ existing = accumulator[full]
320
+ accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
321
+ end
322
+
323
+ # Slice 7 phase 12 — in-source method discovery pre-pass.
324
+ # Walks every class/module body and records the methods
325
+ # introduced via `Prism::DefNode` (instance + singleton)
326
+ # and via recognised `define_method(:name) { ... }` calls.
327
+ # The returned table maps qualified class name to a
328
+ # `Hash[Symbol, :instance | :singleton]`.
329
+ def build_discovered_methods(root)
330
+ accumulator = {}
331
+ walk_methods(root, [], false, accumulator)
332
+ accumulator.transform_values(&:freeze).freeze
333
+ end
334
+
335
+ def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
336
+ return unless node.is_a?(Prism::Node)
337
+
338
+ case node
339
+ when Prism::ClassNode, Prism::ModuleNode
340
+ name = qualified_name_for(node.constant_path)
341
+ if name
342
+ child_prefix = qualified_prefix + [name]
343
+ walk_methods(node.body, child_prefix, false, accumulator) if node.body
344
+ return
345
+ end
346
+ when Prism::SingletonClassNode
347
+ if node.expression.is_a?(Prism::SelfNode) && node.body
348
+ walk_methods(node.body, qualified_prefix, true, accumulator)
349
+ return
350
+ end
351
+ when Prism::DefNode
352
+ record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
353
+ return
354
+ when Prism::CallNode
355
+ record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
356
+ end
357
+
358
+ node.compact_child_nodes.each do |child|
359
+ walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
360
+ end
361
+ end
362
+
363
+ def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
364
+ return if qualified_prefix.empty?
365
+
366
+ class_name = qualified_prefix.join("::")
367
+ kind = def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
368
+ accumulator[class_name] ||= {}
369
+ accumulator[class_name][def_node.name] = kind
370
+ end
371
+
372
+ def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
373
+ return if qualified_prefix.empty?
374
+ return if call_node.arguments.nil? || call_node.arguments.arguments.empty?
375
+
376
+ first_arg = call_node.arguments.arguments.first
377
+ method_name = literal_method_name(first_arg)
378
+ return if method_name.nil?
379
+
380
+ class_name = qualified_prefix.join("::")
381
+ accumulator[class_name] ||= {}
382
+ accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
383
+ end
384
+
385
+ def literal_method_name(node)
386
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
387
+
388
+ node.unescaped&.to_sym
389
+ end
390
+
391
+ # Walks the program once for `Prism::ModuleNode` and
392
+ # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
393
+ # type for the outermost `constant_path` node of each
394
+ # declaration. Inner segments of a `class Foo::Bar::Baz`
395
+ # path remain real references (resolved through the
396
+ # ordinary lexical walk), so we annotate ONLY the topmost
397
+ # path node. Nested declarations contribute their fully
398
+ # qualified path: `class A::B; class C; ...` produces
399
+ # `A::B` for the outer and `A::B::C` for the inner.
400
+ def build_declaration_artifacts(root)
401
+ identity_table = {}.compare_by_identity
402
+ discovered = {}
403
+ record_declarations(root, [], identity_table, discovered)
404
+ [identity_table.freeze, discovered.freeze]
405
+ end
406
+
407
+ def record_declarations(node, qualified_prefix, identity_table, discovered)
408
+ return unless node.is_a?(Prism::Node)
409
+
410
+ case node
411
+ when Prism::ModuleNode, Prism::ClassNode
412
+ name = qualified_name_for(node.constant_path)
413
+ if name
414
+ full = (qualified_prefix + [name]).join("::")
415
+ singleton = Type::Combinator.singleton_of(full)
416
+ identity_table[node.constant_path] = singleton
417
+ discovered[full] = singleton
418
+ child_prefix = qualified_prefix + [name]
419
+ record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
420
+ return
421
+ end
422
+ end
423
+
424
+ node.compact_child_nodes.each do |child|
425
+ record_declarations(child, qualified_prefix, identity_table, discovered)
426
+ end
427
+ end
428
+
429
+ def qualified_name_for(constant_path_node)
430
+ case constant_path_node
431
+ when Prism::ConstantReadNode
432
+ constant_path_node.name.to_s
433
+ when Prism::ConstantPathNode
434
+ render_constant_path(constant_path_node)
435
+ end
436
+ end
437
+
438
+ def render_constant_path(node)
439
+ prefix =
440
+ case node.parent
441
+ when Prism::ConstantReadNode then "#{node.parent.name}::"
442
+ when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
443
+ else ""
444
+ end
445
+ "#{prefix}#{node.name}"
446
+ end
447
+
448
+ # Walks `node`'s subtree DFS and fills in scope entries for every
449
+ # Prism node the StatementEvaluator did not visit (i.e. expression-
450
+ # interior nodes like the receiver/args of a CallNode). Those
451
+ # nodes inherit their nearest recorded ancestor's scope.
452
+ def propagate(node, table, parent_scope)
453
+ return unless node.is_a?(Prism::Node)
454
+
455
+ current_scope =
456
+ if table.key?(node)
457
+ table[node]
458
+ else
459
+ table[node] = parent_scope
460
+ parent_scope
461
+ end
462
+
463
+ node.compact_child_nodes.each { |child| propagate(child, table, current_scope) }
464
+ end
465
+ end
466
+ # rubocop:enable Metrics/ModuleLength
467
+ end
468
+ end