Module: Rigor::Inference::ScopeIndexer

Defined in:
lib/rigor/inference/scope_indexer.rb

Overview

Builds a per-node scope index for a Prism program by running ‘Rigor::Inference::StatementEvaluator` over the root and recording the entry scope visible at every node. Expression-interior nodes the evaluator does not specialise (call receivers, arguments, array/hash elements, …) inherit their nearest statement-y ancestor’s recorded scope, so a downstream caller that looks up the scope for any Prism node in the tree always gets the scope that was effectively visible at that point.

The CLI commands ‘rigor type-of` and `rigor type-scan` consume the index so that local-variable bindings established earlier in the program are visible to the typer when probing later nodes. Without the index, both commands would type every node under an empty scope and miss the constant-folding / dispatch precision that Slice 3 phase 2’s StatementEvaluator unlocks.

The returned object is an identity-comparing Hash:

“‘ruby index = Rigor::Inference::ScopeIndexer.index(program, default_scope: Scope.empty) index #=> the Rigor::Scope visible at that node “`

Nodes that are not part of the program subtree (e.g. synthesised virtual nodes that the caller looks up after the fact) yield the ‘default_scope`. The returned Hash is mutable in principle but callers MUST treat it as read-only; the indexer itself never exposes a way to update it past construction. rubocop:disable Metrics/ModuleLength

Constant Summary collapse

TOP_LEVEL_DEF_KEY =

v0.0.3 A — sentinel key under which ‘record_def_node` files DefNodes that live outside any class / module body (top-level helpers, `def`s nested inside DSL blocks like `RSpec.describe … do; def helper; end`). Looked up by `Scope#top_level_def_for` to give implicit-self calls priority over RBS dispatch when the file defines a same-named local method.

"<toplevel>"
VISIBILITY_MODIFIERS =
%i[public private protected].freeze

Class Method Summary collapse

Class Method Details

.apply_alias_def_nodes(root, accumulator) ⇒ Object

Post-pass over the ‘def_nodes` accumulator: for every `alias` declaration inside a class body, if the original method name maps to a `Prism::DefNode`, register the new name pointing to the same node so inter-procedural return-type inference works for the aliased name.



632
633
634
635
636
637
638
639
640
641
642
643
644
645
# File 'lib/rigor/inference/scope_indexer.rb', line 632

def apply_alias_def_nodes(root, accumulator)
  alias_map = collect_class_alias_map(root, [], {})
  alias_map.each do |class_name, aliases|
    class_defs = accumulator[class_name]
    next unless class_defs

    aliases.each do |new_name, old_name|
      def_node = class_defs[old_name]
      next unless def_node.is_a?(Prism::DefNode)

      (accumulator[class_name] ||= {})[new_name] = def_node
    end
  end
end

.apply_named_visibility(args, qualified_prefix, visibility, accumulator) ⇒ Object



595
596
597
598
599
600
601
602
603
604
# File 'lib/rigor/inference/scope_indexer.rb', line 595

def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
  class_name = qualified_prefix.join("::")
  args.each do |arg|
    name = visibility_target_name(arg)
    next if name.nil?

    accumulator[class_name] ||= {}
    accumulator[class_name][name] = visibility
  end
end

.apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator) ⇒ Object

Recognises modifier calls on the implicit-self receiver inside a class body. Returns the (possibly updated) current visibility:

  • ‘private` / `public` / `protected` (no args) —switch the running default for subsequent defs.

  • ‘private :foo, :bar` — back-patch the named methods in the accumulator. Returns `current_visibility` unchanged because the running default does NOT change for this form.



581
582
583
584
585
586
587
588
589
590
591
592
593
# File 'lib/rigor/inference/scope_indexer.rb', line 581

def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
  return current_visibility unless call_node.receiver.nil?
  return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
  return current_visibility if qualified_prefix.empty?

  args = call_node.arguments&.arguments || []
  if args.empty?
    call_node.name
  else
    apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
    current_visibility
  end
end

.build_class_cvar_index(root, default_scope) ⇒ Object

Slice 7 phase 6 — class-cvar pre-pass. Same shape as the ivar pre-pass but collects ‘Prism::ClassVariableWriteNode` writes inside ANY def body (instance or singleton) of the enclosing class, because Ruby cvars are shared across both facets. The resulting table is seeded into both instance and singleton method bodies through `Scope#class_cvars_for`.



211
212
213
214
215
# File 'lib/rigor/inference/scope_indexer.rb', line 211

def build_class_cvar_index(root, default_scope)
  accumulator = {}
  walk_class_cvars(root, [], default_scope, accumulator)
  accumulator.transform_values(&:freeze).freeze
end

.build_class_ivar_index(root, default_scope) ⇒ Object

Slice 7 phase 2. Builds the class-level ivar accumulator by walking every ‘Prism::ClassNode` / `Prism::ModuleNode` body, descending into each nested `Prism::DefNode`, and typing every `Prism::InstanceVariableWriteNode` rvalue under a scope that carries the appropriate `self_type` for that def (singleton vs instance). The rvalue is typed with NO local bindings — the pre-pass lacks statement-level threading — so `@x = 1` records `Constant` but `@x = some_local + 1` records `Dynamic` (since `some_local` is unbound at pre-pass time). Multiple writes to the same ivar union via `Type::Combinator.union`.



139
140
141
142
143
# File 'lib/rigor/inference/scope_indexer.rb', line 139

def build_class_ivar_index(root, default_scope)
  accumulator = {}
  walk_class_ivars(root, [], default_scope, accumulator)
  accumulator.transform_values(&:freeze).freeze
end

.build_declaration_artifacts(root) ⇒ Object

Walks the program once for ‘Prism::ModuleNode` and `Prism::ClassNode`, recording the `Singleton` type for the outermost `constant_path` node of each declaration. Inner segments of a `class Foo::Bar::Baz` path remain real references (resolved through the ordinary lexical walk), so we annotate ONLY the topmost path node. Nested declarations contribute their fully qualified path: `class A::B; class C; …` produces `A::B` for the outer and `A::B::C` for the inner.



710
711
712
713
714
715
# File 'lib/rigor/inference/scope_indexer.rb', line 710

def build_declaration_artifacts(root)
  identity_table = {}.compare_by_identity
  discovered = {}
  record_declarations(root, [], identity_table, discovered)
  [identity_table.freeze, discovered.freeze]
end

.build_discovered_def_nodes(root) ⇒ Object

v0.0.2 #5 — instance-side def-node recording. Walks class bodies the same way as ‘build_discovered_methods` but records the actual `Prism::DefNode` for each instance method so `ExpressionTyper` can re-type the body at the call site for inter-procedural return inference. Singleton methods and `define_method` calls are intentionally skipped: the inference path needs a statically introspectable body, and singleton dispatch has its own complications (Class / Module ancestry) the first-iteration rule does not yet model.



426
427
428
429
430
431
# File 'lib/rigor/inference/scope_indexer.rb', line 426

def build_discovered_def_nodes(root)
  accumulator = {}
  walk_def_nodes(root, [], false, accumulator)
  apply_alias_def_nodes(root, accumulator)
  accumulator.transform_values(&:freeze).freeze
end

.build_discovered_method_visibilities(root) ⇒ Object

v0.1.2 — per-class method-visibility table for the ‘def.method-visibility-mismatch` CheckRule.

Tracks two visibility-changing forms:

  • **Modifier blocks**: a bare ‘private` / `protected` / `public` call inside a class body switches the “current default” visibility for every subsequent `def` until another modifier flips it again.

  • **Named-argument form**: ‘private :foo, :bar` (or the same with `protected` / `public`) marks specific names already-recorded under the class. Symbol-only args are recognised; `private def foo; end` (the wrap-around form) is not yet — it would need tracking the def-call’s return-value visibility, which is a separate slice.

Top-level (no surrounding class) defs do not contribute — Ruby’s top-level visibility nuances (private at top-level marks the method on ‘Object`) are out of scope for v0.1.2.



507
508
509
510
511
# File 'lib/rigor/inference/scope_indexer.rb', line 507

def build_discovered_method_visibilities(root)
  accumulator = {}
  walk_method_visibilities(root, [], false, :public, accumulator)
  accumulator.transform_values(&:freeze).freeze
end

.build_discovered_methods(root) ⇒ Object

Slice 7 phase 12 — in-source method discovery pre-pass. Walks every class/module body and records the methods introduced via ‘Prism::DefNode` (instance + singleton) and via recognised `define_method(:name) { … }` calls. The returned table maps qualified class name to a `Hash[Symbol, :instance | :singleton]`.



345
346
347
348
349
# File 'lib/rigor/inference/scope_indexer.rb', line 345

def build_discovered_methods(root)
  accumulator = {}
  walk_methods(root, [], false, accumulator)
  accumulator.transform_values(&:freeze).freeze
end

.build_in_source_constants(root, default_scope) ⇒ Object

Slice 7 phase 9 — in-source constant value pre-pass. Walks the entire program (top-level AND inside class / module / def bodies) for ‘Prism::ConstantWriteNode` and `Prism::ConstantPathWriteNode`, types each rvalue, and accumulates by qualified name. Constants defined inside a class body are qualified with the surrounding class path; constants written via a path (`Foo::BAR = …`) use the rendered path as-is.



297
298
299
300
301
# File 'lib/rigor/inference/scope_indexer.rb', line 297

def build_in_source_constants(root, default_scope)
  accumulator = {}
  walk_constant_writes(root, [], default_scope, accumulator)
  accumulator.freeze
end

.build_program_global_index(root, default_scope) ⇒ Object

Slice 7 phase 6 — program-global pre-pass. Globals are process-wide so the accumulator is a flat ‘Hash[Symbol, Type::t]` populated from every `Prism::GlobalVariableWriteNode` in the program (top-level AND inside method bodies). The same accumulator is seeded into every method body and the top-level scope.



269
270
271
272
273
# File 'lib/rigor/inference/scope_indexer.rb', line 269

def build_program_global_index(root, default_scope)
  accumulator = {}
  gather_global_writes(root, default_scope, accumulator)
  accumulator.freeze
end

.collect_class_alias_map(node, qualified_prefix, accumulator) ⇒ Object

Builds a map ‘=> {new_name_sym => old_name_sym}` by walking the tree for `AliasMethodNode` nodes inside class bodies. rubocop:disable Metrics/CyclomaticComplexity



650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# File 'lib/rigor/inference/scope_indexer.rb', line 650

def collect_class_alias_map(node, qualified_prefix, accumulator)
  return accumulator unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      collect_class_alias_map(node.body, qualified_prefix + [name], accumulator) if node.body
      return accumulator
    end
  when Prism::SingletonClassNode
    return accumulator
  when Prism::AliasMethodNode
    record_alias_map_entry(node, qualified_prefix, accumulator)
    return accumulator
  end

  node.compact_child_nodes.each { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
  accumulator
end

.collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator) ⇒ Object



238
239
240
241
242
243
244
# File 'lib/rigor/inference/scope_indexer.rb', line 238

def collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator)
  return if def_node.body.nil? || qualified_prefix.empty?

  class_name = qualified_prefix.join("::")
  body_scope = default_scope.with_self_type(Type::Combinator.nominal_of(class_name))
  gather_cvar_writes(def_node.body, body_scope, class_name, accumulator)
end

.collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/rigor/inference/scope_indexer.rb', line 166

def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator)
  return if def_node.body.nil? || qualified_prefix.empty?

  class_name = qualified_prefix.join("::")
  self_type =
    if def_node.receiver.is_a?(Prism::SelfNode)
      Type::Combinator.singleton_of(class_name)
    else
      Type::Combinator.nominal_of(class_name)
    end
  body_scope = default_scope.with_self_type(self_type)

  gather_ivar_writes(def_node.body, body_scope, class_name, accumulator)
end

.data_define_call?(node) ⇒ Boolean

Recognises ‘Data.define(*Symbol)` and `Data.define(*Symbol) do … end` at constant-write rvalue position. The receiver MUST be the bare `Data` constant (or `::Data`); other receivers (a local variable, a method call return) are rejected because their identity is not statically known.

Returns:

  • (Boolean)


774
775
776
777
778
779
780
781
# File 'lib/rigor/inference/scope_indexer.rb', line 774

def data_define_call?(node)
  return false unless node.is_a?(Prism::CallNode)
  return false unless node.name == :define
  return false unless meta_constant_receiver?(node.receiver, :Data)

  args = node.arguments&.arguments || []
  args.all?(Prism::SymbolNode)
end

.gather_cvar_writes(node, scope, class_name, accumulator) ⇒ Object



246
247
248
249
250
251
252
253
# File 'lib/rigor/inference/scope_indexer.rb', line 246

def gather_cvar_writes(node, scope, class_name, accumulator)
  return unless node.is_a?(Prism::Node)

  record_cvar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::ClassVariableWriteNode)
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }

  node.compact_child_nodes.each { |c| gather_cvar_writes(c, scope, class_name, accumulator) }
end

.gather_global_writes(node, scope, accumulator) ⇒ Object



275
276
277
278
279
280
# File 'lib/rigor/inference/scope_indexer.rb', line 275

def gather_global_writes(node, scope, accumulator)
  return unless node.is_a?(Prism::Node)

  record_global_write(node, scope, accumulator) if node.is_a?(Prism::GlobalVariableWriteNode)
  node.compact_child_nodes.each { |c| gather_global_writes(c, scope, accumulator) }
end

.gather_ivar_writes(node, scope, class_name, accumulator) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rigor/inference/scope_indexer.rb', line 184

def gather_ivar_writes(node, scope, class_name, accumulator)
  return unless node.is_a?(Prism::Node)

  record_ivar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::InstanceVariableWriteNode)

  # Don't recurse into nested defs, classes, or modules; their
  # ivars belong to their own enclosing class.
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }

  node.compact_child_nodes.each { |c| gather_ivar_writes(c, scope, class_name, accumulator) }
end

.index(root, default_scope:) ⇒ Hash{Prism::Node => Rigor::Scope}

Build the scope index for a Prism program subtree.

Parameters:

  • root (Prism::Node)

    usually a ‘Prism::ProgramNode`, but any subtree the caller wants the indexer to walk works.

  • default_scope (Rigor::Scope)

    the scope used for the root, and the fallback returned for any Prism node not contained in ‘root`’s subtree.

Returns:

  • (Hash{Prism::Node => Rigor::Scope})

    identity-comparing table whose default value is ‘default_scope`.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/rigor/inference/scope_indexer.rb', line 53

def index(root, default_scope:) # rubocop:disable Metrics/AbcSize
  # Slice A-declarations. Build the declaration overrides
  # first so every scope handed to the StatementEvaluator
  # already carries the table; structural sharing through
  # `Scope#with_local` / `#with_fact` / `#with_self_type`
  # propagates it across every derived scope.
  declared_types, discovered_classes = build_declaration_artifacts(root)
  seeded_scope = default_scope
                 .with_declared_types(declared_types)
                 .with_discovered_classes(discovered_classes)

  # Slice 7 phase 2. Pre-pass over every class/module body
  # to collect the per-class ivar accumulator. Seeded after
  # declared_types so the rvalue typer in the pre-pass can
  # see declaration overrides.
  class_ivars = build_class_ivar_index(root, seeded_scope)
  seeded_scope = seeded_scope.with_class_ivars(class_ivars)

  # Slice 7 phase 6. Same pre-pass shape for cvars (per
  # class) and globals (program-wide). Globals are also
  # materialised into the top-level scope's `globals` map
  # so reads at the top level (and in CLI probes that do
  # not enter a method body) observe the precise type
  # without consulting the accumulator on every lookup.
  class_cvars = build_class_cvar_index(root, seeded_scope)
  seeded_scope = seeded_scope.with_class_cvars(class_cvars)
  program_globals = build_program_global_index(root, seeded_scope)
  seeded_scope = seeded_scope.with_program_globals(program_globals)
  program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }

  # Slice 7 phase 9. In-source constant value tracking.
  # Walks every ConstantWriteNode/ConstantPathWriteNode in
  # the program and types its rvalue under a scope that
  # carries the surrounding qualified prefix as
  # `self_type`, so the rvalue typer sees in-class
  # references resolve correctly. Multiple writes to the
  # same qualified name union via `Type::Combinator.union`.
  in_source_constants = build_in_source_constants(root, seeded_scope)
  seeded_scope = seeded_scope.with_in_source_constants(in_source_constants)

  # Slice 7 phase 12. In-source method discovery. Walks
  # every class/module body for `Prism::DefNode` and
  # recognised `define_method` calls and records the
  # introduced method names. `rigor check` consults the
  # table to suppress false positives for methods the
  # user has defined but no RBS sig describes.
  discovered_methods = build_discovered_methods(root)
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)

  # v0.0.2 #5 — also record the def node itself for
  # instance methods so the engine can re-type the body
  # when a call site dispatches against a user-defined
  # method without an RBS sig.
  discovered_def_nodes = build_discovered_def_nodes(root)
  seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)

  # v0.1.2 — per-class table of method visibilities
  # (`:public` / `:private` / `:protected`). The
  # `def.method-visibility-mismatch` CheckRule consults
  # the table to flag explicit-non-self calls to a
  # private user method.
  discovered_method_visibilities = build_discovered_method_visibilities(root)
  seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)

  table = {}.compare_by_identity
  table.default = seeded_scope

  on_enter = ->(node, scope) { table[node] = scope unless table.key?(node) }
  StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)

  propagate(root, table, seeded_scope)
  table
end

.literal_method_name(node) ⇒ Object



695
696
697
698
699
# File 'lib/rigor/inference/scope_indexer.rb', line 695

def literal_method_name(node)
  return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)

  node.unescaped&.to_sym
end

.meta_call_with_name?(node, receiver_name, method_name) ⇒ Boolean

Returns:

  • (Boolean)


800
801
802
803
804
805
# File 'lib/rigor/inference/scope_indexer.rb', line 800

def meta_call_with_name?(node, receiver_name, method_name)
  return false unless node.is_a?(Prism::CallNode)
  return false unless node.name == method_name

  meta_constant_receiver?(node.receiver, receiver_name)
end

.meta_constant_receiver?(node, expected_name) ⇒ Boolean

Returns:

  • (Boolean)


811
812
813
814
815
816
817
818
# File 'lib/rigor/inference/scope_indexer.rb', line 811

def meta_constant_receiver?(node, expected_name)
  case node
  when Prism::ConstantReadNode
    node.name == expected_name
  when Prism::ConstantPathNode
    node.parent.nil? && node.name == expected_name
  end
end

.meta_new_block_body(node) ⇒ Object

v0.1.2 — when a ‘Const = Data.define(*sym) do … end` / `Const = Struct.new(*sym) do … end` constant write carries a block, the block body holds method overrides whose canonical class is `Const`. Returns the block body node (a `Prism::StatementsNode`) when the rvalue matches; nil otherwise. Used by `walk_methods` / `walk_def_nodes` to push `Const` onto the qualified prefix before recursing.



398
399
400
401
402
403
404
405
# File 'lib/rigor/inference/scope_indexer.rb', line 398

def meta_new_block_body(node)
  return nil unless node.is_a?(Prism::ConstantWriteNode)

  rvalue = node.value
  return nil unless data_define_call?(rvalue) || struct_new_call?(rvalue)

  rvalue.block&.body
end

.propagate(node, table, parent_scope) ⇒ Object

Walks ‘node`’s subtree DFS and fills in scope entries for every Prism node the StatementEvaluator did not visit (i.e. expression- interior nodes like the receiver/args of a CallNode). Those nodes inherit their nearest recorded ancestor’s scope.

‘IfNode` / `UnlessNode` are special-cased: the truthy and falsey branches each get their predicate’s narrowed scope before recursing. This handles expression-position conditionals (e.g. ‘cache = if cond; t; else; e; end` and conditionals nested as call arguments) which are typed by ExpressionTyper without going through `eval_if`’s narrowing path.



850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
# File 'lib/rigor/inference/scope_indexer.rb', line 850

def propagate(node, table, parent_scope)
  return unless node.is_a?(Prism::Node)

  current_scope =
    if table.key?(node)
      table[node]
    else
      table[node] = parent_scope
      parent_scope
    end

  case node
  when Prism::IfNode
    propagate_if_branches(node, table, current_scope)
  when Prism::UnlessNode
    propagate_unless_branches(node, table, current_scope)
  else
    node.compact_child_nodes.each { |child| propagate(child, table, current_scope) }
  end
end

.propagate_if_branches(node, table, current_scope) ⇒ Object



871
872
873
874
875
876
# File 'lib/rigor/inference/scope_indexer.rb', line 871

def propagate_if_branches(node, table, current_scope)
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
  propagate(node.predicate, table, current_scope) if node.predicate
  propagate(node.statements, table, truthy_scope) if node.statements
  propagate(node.subsequent, table, falsey_scope) if node.subsequent
end

.propagate_unless_branches(node, table, current_scope) ⇒ Object



878
879
880
881
882
883
# File 'lib/rigor/inference/scope_indexer.rb', line 878

def propagate_unless_branches(node, table, current_scope)
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
  propagate(node.predicate, table, current_scope) if node.predicate
  propagate(node.statements, table, falsey_scope) if node.statements
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
end

.qualified_name_for(constant_path_node) ⇒ Object



820
821
822
823
824
825
826
827
# File 'lib/rigor/inference/scope_indexer.rb', line 820

def qualified_name_for(constant_path_node)
  case constant_path_node
  when Prism::ConstantReadNode
    constant_path_node.name.to_s
  when Prism::ConstantPathNode
    render_constant_path(constant_path_node)
  end
end

.record_alias_map_entry(alias_node, qualified_prefix, accumulator) ⇒ Object

rubocop:enable Metrics/CyclomaticComplexity



672
673
674
675
676
677
678
679
680
# File 'lib/rigor/inference/scope_indexer.rb', line 672

def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
  return if qualified_prefix.empty?
  return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)

  class_name = qualified_prefix.join("::")
  new_name = alias_node.new_name.unescaped.to_sym
  old_name = alias_node.old_name.unescaped.to_sym
  (accumulator[class_name] ||= {})[new_name] = old_name
end

.record_alias_method(alias_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object

Registers the alias name in the ‘discovered_methods` table so `undefined-method` diagnostics are not emitted for calls to the aliased name. The kind mirrors the surrounding class context (instance inside a regular class body, singleton inside `class << self`).



617
618
619
620
621
622
623
624
625
# File 'lib/rigor/inference/scope_indexer.rb', line 617

def record_alias_method(alias_node, qualified_prefix, in_singleton_class, accumulator)
  return if qualified_prefix.empty?
  return unless alias_node.new_name.is_a?(Prism::SymbolNode)

  class_name = qualified_prefix.join("::")
  new_name = alias_node.new_name.unescaped.to_sym
  kind = in_singleton_class ? :singleton : :instance
  (accumulator[class_name] ||= {})[new_name] = kind
end

.record_class_or_module?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean

Returns:

  • (Boolean)


732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/rigor/inference/scope_indexer.rb', line 732

def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
  name = qualified_name_for(node.constant_path)
  return false unless name

  full = (qualified_prefix + [name]).join("::")
  singleton = Type::Combinator.singleton_of(full)
  identity_table[node.constant_path] = singleton
  discovered[full] = singleton
  child_prefix = qualified_prefix + [name]
  record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
  true
end

.record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name) ⇒ Object



328
329
330
331
332
333
334
335
336
337
# File 'lib/rigor/inference/scope_indexer.rb', line 328

def record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name)
  full = qualified_prefix.empty? ? base_name : "#{qualified_prefix.join('::')}::#{base_name}"
  body_scope = default_scope
  unless qualified_prefix.empty?
    body_scope = body_scope.with_self_type(Type::Combinator.singleton_of(qualified_prefix.join("::")))
  end
  rvalue_type = body_scope.type_of(node.value)
  existing = accumulator[full]
  accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
end

.record_cvar_write(node, scope, class_name, accumulator) ⇒ Object



255
256
257
258
259
260
261
# File 'lib/rigor/inference/scope_indexer.rb', line 255

def record_cvar_write(node, scope, class_name, accumulator)
  rvalue_type = scope.type_of(node.value)
  accumulator[class_name] ||= {}
  existing = accumulator[class_name][node.name]
  accumulator[class_name][node.name] =
    existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
end

.record_declarations(node, qualified_prefix, identity_table, discovered) ⇒ Object



717
718
719
720
721
722
723
724
725
726
727
728
729
730
# File 'lib/rigor/inference/scope_indexer.rb', line 717

def record_declarations(node, qualified_prefix, identity_table, discovered)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ModuleNode, Prism::ClassNode
    return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
  when Prism::ConstantWriteNode
    return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
  end

  node.compact_child_nodes.each do |child|
    record_declarations(child, qualified_prefix, identity_table, discovered)
  end
end

.record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object



407
408
409
410
411
412
413
414
# File 'lib/rigor/inference/scope_indexer.rb', line 407

def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
  return if qualified_prefix.empty?

  class_name = qualified_prefix.join("::")
  kind = def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
  accumulator[class_name] ||= {}
  accumulator[class_name][def_node.name] = kind
end

.record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object



476
477
478
479
480
481
482
# File 'lib/rigor/inference/scope_indexer.rb', line 476

def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class

  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
  accumulator[class_name] ||= {}
  accumulator[class_name][def_node.name] = def_node
end

.record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator) ⇒ Object

rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize



562
563
564
565
566
567
568
569
# File 'lib/rigor/inference/scope_indexer.rb', line 562

def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
  return if qualified_prefix.empty?

  class_name = qualified_prefix.join("::")
  accumulator[class_name] ||= {}
  accumulator[class_name][def_node.name] = current_visibility
end

.record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object



682
683
684
685
686
687
688
689
690
691
692
693
# File 'lib/rigor/inference/scope_indexer.rb', line 682

def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
  return if qualified_prefix.empty?
  return if call_node.arguments.nil? || call_node.arguments.arguments.empty?

  first_arg = call_node.arguments.arguments.first
  method_name = literal_method_name(first_arg)
  return if method_name.nil?

  class_name = qualified_prefix.join("::")
  accumulator[class_name] ||= {}
  accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
end

.record_global_write(node, scope, accumulator) ⇒ Object



282
283
284
285
286
287
# File 'lib/rigor/inference/scope_indexer.rb', line 282

def record_global_write(node, scope, accumulator)
  rvalue_type = scope.type_of(node.value)
  existing = accumulator[node.name]
  accumulator[node.name] =
    existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
end

.record_ivar_write(node, scope, class_name, accumulator) ⇒ Object



196
197
198
199
200
201
202
# File 'lib/rigor/inference/scope_indexer.rb', line 196

def record_ivar_write(node, scope, class_name, accumulator)
  rvalue_type = scope.type_of(node.value)
  accumulator[class_name] ||= {}
  existing = accumulator[class_name][node.name]
  accumulator[class_name][node.name] =
    existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
end

.record_meta_new_constant?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean

Recognises class-creating meta calls at constant-write rvalue position and registers ‘Const` (qualified by the surrounding class/module path) as a discovered class. `Const.new(…)` then resolves to a fresh `Nominal` via `meta_new`, instead of the un-narrowed `Dynamic` returned by the default `Class#new` envelope.

Two recognised meta forms:

  • ‘Const = Data.define(*Symbol) [do … end]`

  • ‘Const = Struct.new(*Symbol [, keyword_init: …]) [do … end]`

The block body, if present, is recursed into so any nested class/module declarations in the override block (rare but legal) still feed the discovered table.

Returns:

  • (Boolean)


760
761
762
763
764
765
766
767
# File 'lib/rigor/inference/scope_indexer.rb', line 760

def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
  return false unless data_define_call?(node.value) || struct_new_call?(node.value)

  full = (qualified_prefix + [node.name.to_s]).join("::")
  discovered[full] = Type::Combinator.singleton_of(full)
  record_declarations(node.value, qualified_prefix, identity_table, discovered)
  true
end

.render_constant_path(node) ⇒ Object



829
830
831
832
833
834
835
836
837
# File 'lib/rigor/inference/scope_indexer.rb', line 829

def render_constant_path(node)
  prefix =
    case node.parent
    when Prism::ConstantReadNode then "#{node.parent.name}::"
    when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
    else ""
    end
  "#{prefix}#{node.name}"
end

.struct_new_call?(node) ⇒ Boolean

Recognises ‘Struct.new(*Symbol)` and `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write rvalue position. A trailing `KeywordHashNode` (the `keyword_init: …` form) is accepted but does not contribute to member discovery; every other argument MUST be a `Prism::SymbolNode`. At least one Symbol member is required —`Struct.new()` is a degenerate form callers don’t typically use.

Returns:

  • (Boolean)


790
791
792
793
794
795
796
797
798
# File 'lib/rigor/inference/scope_indexer.rb', line 790

def struct_new_call?(node)
  return false unless meta_call_with_name?(node, :Struct, :new)

  args = node.arguments&.arguments || []
  positional = struct_new_positionals(args)
  return false if positional.nil? || positional.empty?

  positional.all?(Prism::SymbolNode)
end

.struct_new_positionals(args) ⇒ Object



807
808
809
# File 'lib/rigor/inference/scope_indexer.rb', line 807

def struct_new_positionals(args)
  args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
end

.visibility_target_name(arg) ⇒ Object



606
607
608
609
610
# File 'lib/rigor/inference/scope_indexer.rb', line 606

def visibility_target_name(arg)
  return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)

  nil
end

.walk_class_cvars(node, qualified_prefix, default_scope, accumulator) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/rigor/inference/scope_indexer.rb', line 217

def walk_class_cvars(node, qualified_prefix, default_scope, accumulator)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_class_cvars(node.body, child_prefix, default_scope, accumulator) if node.body
      return
    end
  when Prism::DefNode
    collect_def_cvar_writes(node, qualified_prefix, default_scope, accumulator)
    return
  end

  node.compact_child_nodes.each do |child|
    walk_class_cvars(child, qualified_prefix, default_scope, accumulator)
  end
end

.walk_class_ivars(node, qualified_prefix, default_scope, accumulator) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/rigor/inference/scope_indexer.rb', line 145

def walk_class_ivars(node, qualified_prefix, default_scope, accumulator)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_class_ivars(node.body, child_prefix, default_scope, accumulator) if node.body
      return
    end
  when Prism::DefNode
    collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator)
    return
  end

  node.compact_child_nodes.each do |child|
    walk_class_ivars(child, qualified_prefix, default_scope, accumulator)
  end
end

.walk_constant_writes(node, qualified_prefix, default_scope, accumulator) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/rigor/inference/scope_indexer.rb', line 303

def walk_constant_writes(node, qualified_prefix, default_scope, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_constant_writes(node.body, child_prefix, default_scope, accumulator) if node.body
      return
    end
  when Prism::ConstantWriteNode
    record_constant_write(node, qualified_prefix, default_scope, accumulator, node.name.to_s)
    return
  when Prism::ConstantPathWriteNode
    full = qualified_name_for(node.target)
    record_constant_write(node, [], default_scope, accumulator, full) if full
    return
  end

  node.compact_child_nodes.each do |child|
    walk_constant_writes(child, qualified_prefix, default_scope, accumulator)
  end
end

.walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity



434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
# File 'lib/rigor/inference/scope_indexer.rb', line 434

def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_def_nodes(node.body, child_prefix, false, accumulator) if node.body
      return
    end
  when Prism::SingletonClassNode
    if node.expression.is_a?(Prism::SelfNode) && node.body
      walk_def_nodes(node.body, qualified_prefix, true, accumulator)
      return
    end
  when Prism::ConstantWriteNode
    if meta_new_block_body(node)
      child_prefix = qualified_prefix + [node.name.to_s]
      walk_def_nodes(meta_new_block_body(node), child_prefix, false, accumulator)
      return
    end
  when Prism::DefNode
    record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
    return
  end

  node.compact_child_nodes.each do |child|
    walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
  end
end

.walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
# File 'lib/rigor/inference/scope_indexer.rb', line 514

def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
  return current_visibility unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
      return current_visibility
    end
  when Prism::SingletonClassNode
    if node.expression.is_a?(Prism::SelfNode) && node.body
      walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
      return current_visibility
    end
  when Prism::ConstantWriteNode
    if meta_new_block_body(node)
      child_prefix = qualified_prefix + [node.name.to_s]
      walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
      return current_visibility
    end
  when Prism::DefNode
    record_def_visibility(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
    return current_visibility
  when Prism::CallNode
    updated = apply_visibility_call(node, qualified_prefix, current_visibility, accumulator)
    return updated unless updated.equal?(current_visibility)
  end

  # Statement-position StatementsNode preserves
  # left-to-right visibility flow; everything else
  # recurses with the entry visibility unchanged.
  if node.is_a?(Prism::StatementsNode)
    local_visibility = current_visibility
    node.compact_child_nodes.each do |child|
      local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
                                                  local_visibility, accumulator)
    end
  else
    node.compact_child_nodes.each do |child|
      walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
    end
  end
  current_visibility
end

.walk_methods(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/rigor/inference/scope_indexer.rb', line 352

def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::ClassNode, Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      child_prefix = qualified_prefix + [name]
      walk_methods(node.body, child_prefix, false, accumulator) if node.body
      return
    end
  when Prism::SingletonClassNode
    if node.expression.is_a?(Prism::SelfNode) && node.body
      walk_methods(node.body, qualified_prefix, true, accumulator)
      return
    end
  when Prism::ConstantWriteNode
    if meta_new_block_body(node)
      child_prefix = qualified_prefix + [node.name.to_s]
      walk_methods(meta_new_block_body(node), child_prefix, false, accumulator)
      return
    end
  when Prism::DefNode
    record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
    return
  when Prism::AliasMethodNode
    record_alias_method(node, qualified_prefix, in_singleton_class, accumulator)
    return
  when Prism::CallNode
    record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
  end

  node.compact_child_nodes.each do |child|
    walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
  end
end