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>"
MIXIN_CALL_NAMES =
%i[include prepend].freeze
VISIBILITY_MODIFIERS =
%i[public private protected].freeze
ATTR_MACROS =

The ‘attr_*` accessor macros that introduce methods Rigor must treat as source-declared. Without this, a class that defines an accessor with `attr_reader :x` AND carries RBS that omits `x` (a common gap — the project ships an incomplete `sig/`) fires a false `call.undefined-method` on `obj.x`, because the undefined-method rule only suppressed `def` / `define_method` / `alias_method`-discovered methods. `attr_reader` defines readers, `attr_writer` writers (`x=`), `attr_accessor` both.

%i[attr_reader attr_writer attr_accessor].freeze

Class Method Summary collapse

Class Method Details

.accumulate_project_index(acc, path, root) ⇒ Object

Folds one file’s class-keyed indexes into the cross-file accumulator. ‘method_visibilities` (ADR-35) is collected here so the override-visibility-reduced rule can read an ancestor’s visibility declared in a sibling file.



1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
# File 'lib/rigor/inference/scope_indexer.rb', line 1679

def accumulate_project_index(acc, path, root)
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
  superclasses = build_discovered_superclasses(root)
  includes = build_discovered_includes(root)
  acc[:superclasses].merge!(superclasses)
  includes.each do |class_name, mods|
    acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
  end
  record_class_sources(acc[:class_sources], path, root, superclasses, includes)
  merge_class_keyed_index_tables(acc, root)
  acc[:data_member_layouts].merge!(build_data_member_layouts(root))
end

.additional_initializer?(class_name, method_name, default_scope) ⇒ Boolean

ADR-38 — true when a loaded plugin declares ‘method_name` an additional initializer for `class_name` (or an ancestor). Reads the plugin registry off the pre-pass scope’s environment; the receiver-constraint match reuses ‘Environment#class_ordering` (the same mechanism ADR-16 Tier A’s ‘MacroBlockSelfType` uses). The whole lookup is wrapped so any resolution failure degrades to “no match” —since the gate only ever SUPPRESSES a nil contribution, a missed match is false-positive-safe (it merely leaves the existing nil widening in place).

Returns:

  • (Boolean)


506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# File 'lib/rigor/inference/scope_indexer.rb', line 506

def additional_initializer?(class_name, method_name, default_scope)
  return false if class_name.nil? || default_scope.nil?

  environment = default_scope.environment
  registry = environment&.plugin_registry
  return false if registry.nil?
  return false if registry.respond_to?(:empty?) && registry.empty?
  return false unless registry.respond_to?(:additional_initializers)

  registry.additional_initializers.any? do |entry|
    entry.covers_method?(method_name) &&
      class_matches_constraint?(class_name, entry.receiver_constraint, environment)
  end
rescue StandardError
  false
end

.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.



1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
# File 'lib/rigor/inference/scope_indexer.rb', line 1469

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



1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
# File 'lib/rigor/inference/scope_indexer.rb', line 1432

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.



1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
# File 'lib/rigor/inference/scope_indexer.rb', line 1418

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

.block_initializer?(class_name, method_name, default_scope) ⇒ Boolean

ADR-38 block-form gate: true when a loaded plugin declares ‘method_name` a block-form initializer for `class_name` (or an ancestor). Mirrors `additional_initializer?` but queries `covers_block_method?` instead of `covers_method?`.

Returns:

  • (Boolean)


445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
# File 'lib/rigor/inference/scope_indexer.rb', line 445

def block_initializer?(class_name, method_name, default_scope)
  return false if class_name.nil? || default_scope.nil?

  environment = default_scope.environment
  registry = environment&.plugin_registry
  return false if registry.nil?
  return false if registry.respond_to?(:empty?) && registry.empty?
  return false unless registry.respond_to?(:additional_initializers)

  registry.additional_initializers.any? do |entry|
    entry.covers_block_method?(method_name) &&
      class_matches_constraint?(class_name, entry.receiver_constraint, environment)
  end
rescue StandardError
  false
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`.



767
768
769
770
771
# File 'lib/rigor/inference/scope_indexer.rb', line 767

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`.



208
209
210
211
212
213
214
215
216
217
218
# File 'lib/rigor/inference/scope_indexer.rb', line 208

def build_class_ivar_index(root, default_scope)
  accumulator = {}
  mutated_ivars = {}
  read_before_write = {}
  init_writes = {}
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
                   read_before_write, init_writes)
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
  accumulator.transform_values(&:freeze).freeze
end

.build_data_member_layouts(root) ⇒ Object

ADR-48 — per qualified class name -> ordered ‘Data.define` member-name list, for both the named-subclass form (`class Point < Data.define(:x, :y)`) and the constant-assigned form (`Point = Data.define(:x, :y)`). Only `Data.define` is recorded: `Struct.new` instances are mutable, so member-value folding would be unsound (the Struct follow-up is deferred — see ADR-48 § “Struct follow-up”). Consumed by MethodDispatcher::DataFolding via Scope#data_member_layout.



1227
1228
1229
1230
1231
# File 'lib/rigor/inference/scope_indexer.rb', line 1227

def build_data_member_layouts(root)
  accumulator = {}
  walk_data_member_layouts(root, [], accumulator)
  accumulator.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.



1771
1772
1773
1774
1775
1776
# File 'lib/rigor/inference/scope_indexer.rb', line 1771

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.



1120
1121
1122
1123
1124
1125
# File 'lib/rigor/inference/scope_indexer.rb', line 1120

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_includes(root) ⇒ Object

ADR-24 slice 2 — per-class/module table mapping a fully qualified user class or module to the list of module names it ‘include`s / `prepend`s, AS WRITTEN at the mixin call (`include Foo` / `include Foo::Bar`). Only constant arguments are recorded; dynamic mixins (`include some_method`) produce no entry. `prepend` is bucketed with `include` — both contribute instance methods to the ancestor chain. `extend` is NOT tracked (it adds singleton methods; ADR-24 slice 2 resolves the instance-side chain).



1282
1283
1284
1285
1286
# File 'lib/rigor/inference/scope_indexer.rb', line 1282

def build_discovered_includes(root)
  accumulator = {}
  walk_class_includes(root, [], nil, accumulator)
  accumulator.transform_values { |mods| mods.uniq.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.



1341
1342
1343
1344
1345
# File 'lib/rigor/inference/scope_indexer.rb', line 1341

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]`.



916
917
918
919
920
# File 'lib/rigor/inference/scope_indexer.rb', line 916

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

.build_discovered_superclasses(root) ⇒ Object

ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the ‘class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(…)` and other non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call site against the subclass’s lexical nesting —see ‘ExpressionTyper#resolve_ancestor_class_name`.



1186
1187
1188
1189
1190
# File 'lib/rigor/inference/scope_indexer.rb', line 1186

def build_discovered_superclasses(root)
  accumulator = {}
  walk_class_superclasses(root, [], accumulator)
  accumulator.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.



853
854
855
856
857
# File 'lib/rigor/inference/scope_indexer.rb', line 853

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.



825
826
827
828
829
# File 'lib/rigor/inference/scope_indexer.rb', line 825

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

.class_matches_constraint?(class_name, constraint, environment) ⇒ Boolean

Returns:

  • (Boolean)


523
524
525
526
527
528
529
530
531
# File 'lib/rigor/inference/scope_indexer.rb', line 523

def class_matches_constraint?(class_name, constraint, environment)
  return true if class_name == constraint
  return false if environment.nil?

  ordering = environment.class_ordering(class_name, constraint)
  %i[equal subclass].include?(ordering)
rescue StandardError
  false
end

.class_new_call?(node) ⇒ Boolean

Recognises ‘Class.new`, `Class.new(super_class)`, and the block form `Class.new { … }`. Like `module_new_call?`, the block body is walked as the anonymous class’s body. The optional ‘super_class` positional is accepted but does NOT route through `ancestor` discovery in this slice — the synthesised class still answers method lookups via its own body’s defs, mirroring how ‘Struct.new` / `Data.define` are handled.

Returns:

  • (Boolean)


1882
1883
1884
# File 'lib/rigor/inference/scope_indexer.rb', line 1882

def class_new_call?(node)
  meta_call_with_name?(node, :Class, :new)
end

.collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator, mutated_ivars, init_writes) ⇒ Object

ADR-38 block-form: collects ivar writes from a CallNode’s block body (e.g. RSpec ‘before { @x = … }` / `let(:x) { … }`) and folds them into `init_writes`, suppressing the read-before-write nil contribution the same way a def-form initializer does. The block body is always treated as an initializer (the caller has already verified the method name is declared as a block_method initializer), so there is no read-before-write evidence collection step here.



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/rigor/inference/scope_indexer.rb', line 423

def collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator,
                              mutated_ivars, init_writes)
  return if block_node.body.nil? || qualified_prefix.empty?

  class_name = qualified_prefix.join("::")
  self_type = Type::Combinator.nominal_of(class_name)
  body_scope = default_scope.with_self_type(self_type)

  gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
                     EMPTY_GUARDED_IVARS, mutated_ivars)

  seen_writes = Set.new
  read_first = Set.new
  detect_read_before_write(block_node.body, seen_writes, read_first)
  init_set = (init_writes[class_name] ||= Set.new)
  seen_writes.each { |name| init_set << name }
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.



1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
# File 'lib/rigor/inference/scope_indexer.rb', line 1486

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_class_body_ivar_writes(node, class_name, init_writes) ⇒ Object

Walks class-body level statements (i.e. NOT inside any nested DefNode / ClassNode / ModuleNode) and records every ‘@x = …` write target as a class-body init. Consumed by `contribute_read_before_write_nil!` to exempt ivars the author already knows might be nil (the `@x = nil` at class-body level is the canonical nullability acknowledgement; the instance @x is technically a separate store, but the pragmatic intent is unambiguous).



550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
# File 'lib/rigor/inference/scope_indexer.rb', line 550

def collect_class_body_ivar_writes(node, class_name, init_writes)
  return unless node.is_a?(Prism::Node)
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }

  if node.is_a?(Prism::InstanceVariableWriteNode) ||
     node.is_a?(Prism::InstanceVariableOrWriteNode) ||
     node.is_a?(Prism::InstanceVariableAndWriteNode) ||
     node.is_a?(Prism::InstanceVariableOperatorWriteNode)
    (init_writes[class_name] ||= Set.new) << node.name
  end

  node.compact_child_nodes.each do |child|
    collect_class_body_ivar_writes(child, class_name, init_writes)
  end
end

.collect_class_decls(node, qualified_prefix, accumulator) ⇒ Object

Class-only variant of ‘record_declarations` — descends into nested module bodies (so `module Foo; class Bar` registers `Foo::Bar`) but never registers the module itself in `accumulator`.



1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
# File 'lib/rigor/inference/scope_indexer.rb', line 1743

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

  case node
  when Prism::ClassNode
    name = qualified_name_for(node.constant_path)
    if name
      full = (qualified_prefix + [name]).join("::")
      accumulator[full] = Type::Combinator.singleton_of(full)
      return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
    end
  when Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
  end

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

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



794
795
796
797
798
799
800
# File 'lib/rigor/inference/scope_indexer.rb', line 794

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, mutated_ivars, read_before_write = nil, init_writes = nil) ⇒ Object



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/rigor/inference/scope_indexer.rb', line 387

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

  class_name = qualified_prefix.join("::")
  singleton = def_node.receiver.is_a?(Prism::SelfNode) ||
              def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
  self_type =
    if singleton
      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, EMPTY_GUARDED_IVARS, mutated_ivars)

  # B2.3 — collect per-method evidence for the read-before-
  # write nil contribution. The accumulator-level decision
  # ("is this ivar truly read-before-write across the
  # class lifetime?") is finalised at
  # `contribute_read_before_write_nil!` after the whole
  # class body has been walked, using `init_writes` as
  # the soundness gate (an ivar written in `initialize`
  # is initialised before any other method body runs).
  collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
end

.collect_defined_test_ivars(node, names) ⇒ Object



697
698
699
700
701
702
703
704
705
706
707
708
# File 'lib/rigor/inference/scope_indexer.rb', line 697

def collect_defined_test_ivars(node, names)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::DefinedNode
    target = node.value
    names << target.name if target.is_a?(Prism::InstanceVariableReadNode)
  when Prism::AndNode, Prism::OrNode
    collect_defined_test_ivars(node.left, names)
    collect_defined_test_ivars(node.right, names)
  end
end

.collect_nil_test_ivars(node, names) ⇒ Object



710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
# File 'lib/rigor/inference/scope_indexer.rb', line 710

def collect_nil_test_ivars(node, names)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::CallNode
    receiver = node.receiver
    if receiver.is_a?(Prism::InstanceVariableReadNode) &&
       %i[nil? !].include?(node.name)
      names << receiver.name
    end
  when Prism::AndNode, Prism::OrNode
    collect_nil_test_ivars(node.left, names)
    collect_nil_test_ivars(node.right, names)
  end
end

.collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope = nil) ⇒ Object

Walks the method body in AST (== execution) order tracking ivar names whose first reference is a read. The set is unioned into the class-wide ‘read_before_write` accumulator. For `initialize` def bodies, every write target is unioned into `init_writes` instead — used by the finalisation step to suppress nil contribution for ivars the constructor guarantees are initialised.



470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# File 'lib/rigor/inference/scope_indexer.rb', line 470

def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope = nil)
  return if read_before_write.nil? || init_writes.nil?

  seen_writes = Set.new
  read_first = Set.new
  detect_read_before_write(def_node.body, seen_writes, read_first)

  # ADR-38 — `initialize` is the built-in initializer gate;
  # a plugin may declare additional `def`-form initializer
  # methods (minitest `setup`, Rails `after_initialize`, DI
  # setters) on a constrained class. Both fold their writes
  # into `init_writes`, suppressing the read-before-write nil
  # contribution for sibling readers.
  if def_node.name == :initialize ||
     additional_initializer?(class_name, def_node.name, default_scope)
    init_set = (init_writes[class_name] ||= Set.new)
    seen_writes.each { |name| init_set << name }
    return
  end

  return if read_first.empty?

  rbw_set = (read_before_write[class_name] ||= Set.new)
  read_first.each { |name| rbw_set << name }
end

.collect_truthy_test_ivars(node, names) ⇒ Object



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

def collect_truthy_test_ivars(node, names)
  return unless node.is_a?(Prism::Node)

  case node
  when Prism::InstanceVariableReadNode
    names << node.name
  when Prism::AndNode, Prism::OrNode
    collect_truthy_test_ivars(node.left, names)
    collect_truthy_test_ivars(node.right, names)
  end
end

.contribute_read_before_write_nil!(accumulator, read_before_write, init_writes) ⇒ Object

B2.3 — finalize the read-before-write nil contribution. For each class, for each ivar where SOME method body observed a read-before-write AND no ‘initialize` write exists for that ivar, contribute `Constant` to the class-wide accumulator.

The ‘initialize` filter is the soundness gate: Ruby semantics guarantee `initialize` runs first (via `Class.new`), so a write there reaches every other method body’s read. Read-before-write in a non-init method is then NOT a nil-at-runtime case — it’s just AST-order coincidence. Without this filter a normal ‘def initialize; @x = … end` / `def use; @x.foo end` class would have `@x` widened with nil, producing FPs at every `@x.foo` call.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/rigor/inference/scope_indexer.rb', line 235

def contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
  nil_t = Type::Combinator.constant_of(nil)
  read_before_write.each do |class_name, ivar_set|
    init_set = init_writes[class_name] || EMPTY_GUARDED_IVARS
    per_class = accumulator[class_name]
    next if per_class.nil?

    ivar_set.each do |ivar_name|
      # Soundness gates (in order):
      # (1) `initialize` writes the ivar → it's set
      #     before any other method runs, so the
      #     read-before-write in a sibling method is
      #     NOT a runtime nil case.
      # (2) The accumulator has NO entry for the ivar
      #     → some write was deliberately skipped (the
      #     falsey-default `@x = nil unless @x` slice's
      #     no-seed behaviour). Adding nil here would
      #     defeat that skip and re-introduce the
      #     `Constant[nil]` FP the skip silenced.
      next if init_set.include?(ivar_name)
      next unless per_class.key?(ivar_name)

      existing = per_class[ivar_name]
      per_class[ivar_name] = Type::Combinator.union(existing, nil_t)
    end
  end
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)


1835
1836
1837
1838
1839
1840
1841
1842
# File 'lib/rigor/inference/scope_indexer.rb', line 1835

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

.deep_merge_class_methods(base, overlay) ⇒ Object

Merges two ‘class_name => { method => kind }` tables, unioning the per-class method maps (so a seeded cross-file table and the current file’s table combine instead of clobbering).



925
926
927
928
929
930
931
932
# File 'lib/rigor/inference/scope_indexer.rb', line 925

def deep_merge_class_methods(base, overlay)
  return overlay if base.nil? || base.empty?
  return base if overlay.empty?

  base.merge(overlay) do |_class_name, base_methods, overlay_methods|
    base_methods.merge(overlay_methods)
  end
end

.def_receiver_targets_lexical_self?(receiver, qualified_prefix) ⇒ Boolean

Only ‘Prism::ConstantReadNode` is observed in real Ruby —Prism mis-parses `def C::P.method` as `def C.P` (Ruby itself rejects the form as a SyntaxError). The ConstantPathNode branch stays defensive in case Prism’s grammar widens.

Returns:

  • (Boolean)


1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
# File 'lib/rigor/inference/scope_indexer.rb', line 1093

def def_receiver_targets_lexical_self?(receiver, qualified_prefix)
  return false if qualified_prefix.empty?

  case receiver
  when Prism::ConstantReadNode
    receiver.name.to_s == qualified_prefix.last
  when Prism::ConstantPathNode
    rendered = render_constant_path(receiver)
    return false unless rendered

    path = rendered.split("::")
    qualified_prefix.last(path.length) == path
  else
    false
  end
end

.def_singleton?(def_node, qualified_prefix, in_singleton_class) ⇒ Boolean

‘def Foo.bar` inside `module Foo` (or `def Meta.init` inside `module Meta`) is semantically equivalent to `def self.bar`: at the def-site, the runtime value of the constant `Foo` is the module itself (== `self`). Recognise the form so the method registers as singleton on the enclosing class.

The cross-class form ‘def Bar.baz` inside `module Foo` —where the receiver names a constant other than the enclosing class — is not supported at this slice; falls through to `:instance` (current behaviour) rather than silently re-routing the registration.

Returns:

  • (Boolean)


1083
1084
1085
1086
1087
# File 'lib/rigor/inference/scope_indexer.rb', line 1083

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

  def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
end

.detect_read_before_write(node, seen_writes, read_first) ⇒ Object



566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
# File 'lib/rigor/inference/scope_indexer.rb', line 566

def detect_read_before_write(node, seen_writes, read_first)
  return unless node.is_a?(Prism::Node)
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }

  read_first << node.name if node.is_a?(Prism::InstanceVariableReadNode) && !seen_writes.include?(node.name)

  # Descend BEFORE recording a write — `@x = @x + 1`'s
  # RHS is an `InstanceVariableReadNode` that runs before
  # the write is committed; the read is therefore
  # read-before-write semantically. Prism's
  # `compact_child_nodes` returns the value child before
  # the lvalue target, matching this order.
  node.compact_child_nodes.each do |c|
    detect_read_before_write(c, seen_writes, read_first)
  end

  seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
end

.discovered_classes_for_paths(paths, buffer: nil) ⇒ Hash{String => Rigor::Type::Singleton}

Walks every file in ‘paths` (each path is parsed once with `Prism.parse_file`) and returns the unioned project-wide `discovered_classes` Hash: `=> Singleton`. Used by `Analysis::Runner` to seed each file’s ‘default_scope.discovered_classes` so that lexical constant lookup in one file resolves a `class Foo` declared in a sibling file. Per-file collisions are last-write-wins (matches the existing in-file merge semantics). Parse failures fail-soft to an empty contribution. The `buffer` argument, when present, redirects reads for the bound logical path to the buffer’s physical path so editor-mode pre-passes see the in-flight bytes.

**Modules are intentionally excluded** from the project-wide seed: a ‘module M; module_function; def x; end; end` body, when surfaced as `singleton(M)` to the dispatcher, falls through to `Kernel#x` (or any Module ancestor method) when the project’s per-file ‘discovered_methods` doesn’t know ‘M.x` — leading to surprising types like `Kernel.select → Array`. Until cross-file `discovered_methods` follows the same project-wide seed, registering modules here would introduce regressions in modules-with-module_function idioms that previously resolved to `Dynamic`. Class declarations are safe because per-file `discovered_methods` already tracks `def self.x` / `def x` instance and singleton methods consistently.

Parameters:

Returns:



1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
# File 'lib/rigor/inference/scope_indexer.rb', line 1597

def discovered_classes_for_paths(paths, buffer: nil)
  accumulator = {}
  paths.each do |path|
    physical = buffer ? buffer.resolve(path) : path
    source = File.read(physical)
    root = Prism.parse(source, filepath: path).value
    collect_class_decls(root, [], accumulator)
  rescue StandardError
    # Skip files that fail to parse or read; the per-file
    # analyzer surfaces the parse error separately.
    next
  end
  accumulator.freeze
end

.discovered_def_index_for_paths(paths, buffer: nil) ⇒ Hash{Symbol => Hash}

ADR-24 slice 2 — cross-file companion to ‘discovered_classes_for_paths`. Walks every project file once and returns both the merged `discovered_def_nodes` table (a class reopened across files has its method tables merged) and the merged class -> superclass-name map. The engine consults these so an implicit-self call inside a subclass resolves against a superclass `def` declared in a sibling file (`Mastodon::CLI::Accounts` calling a helper defined in `Mastodon::CLI::Base`).

The returned ‘def_sources` map mirrors `def_nodes` but stores a `“path:line”` String per `(class_name, method_name)` instead of the `Prism::DefNode`. A `Prism::Location` does not expose its source file through public API, so the source site is captured here, in the pre-pass loop that still holds `path`. `CheckRules#undefined_method_diagnostic` consults the seeded copy to name the defining file when a project monkey-patch on a core/stdlib/gem class is called cross-file (ADR-17). First write wins, matching `def_nodes`’ own merge order.

Parameters:

Returns:

  • (Hash{Symbol => Hash})

    ‘{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`



1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
# File 'lib/rigor/inference/scope_indexer.rb', line 1637

def discovered_def_index_for_paths(paths, buffer: nil)
  acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {},
          class_sources: {}, data_member_layouts: {} }
  paths.each do |path|
    physical = buffer ? buffer.resolve(path) : path
    root = Prism.parse(File.read(physical), filepath: path).value
    accumulate_project_index(acc, path, root)
  rescue StandardError
    # Skip files that fail to parse or read; the per-file
    # analyzer surfaces the parse error separately.
    next
  end
  # Cross-file method suppression is for the project's OWN
  # accessors (attr_* / define_method / alias) — NOT for plain
  # `def`s. A cross-file `def` on a class is exactly the ADR-17
  # monkey-patch case the undefined-method rule deliberately
  # surfaces (fire + def-site annotation, nudging `pre_eval:`),
  # so dropping the `def`-declared names keeps that contract
  # intact while still letting `attr_reader :x` in one file
  # suppress a false undefined-method for `obj.x` in another.
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
  %i[def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
    acc[key].each_value(&:freeze)
  end
  acc.transform_values(&:freeze)
end

.falsey_constant?(type) ⇒ Boolean

Returns:

  • (Boolean)


756
757
758
# File 'lib/rigor/inference/scope_indexer.rb', line 756

def falsey_constant?(type)
  type.is_a?(Type::Constant) && (type.value.nil? || type.value == false)
end

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



802
803
804
805
806
807
808
809
# File 'lib/rigor/inference/scope_indexer.rb', line 802

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



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

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, guarded_ivars = EMPTY_GUARDED_IVARS, mutated_ivars = nil) ⇒ Object



591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/rigor/inference/scope_indexer.rb', line 591

def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
                       mutated_ivars = nil)
  return unless node.is_a?(Prism::Node)

  if node.is_a?(Prism::InstanceVariableWriteNode)
    record_ivar_write(node, scope, class_name, accumulator,
                      guarded: guarded_ivars.include?(node.name))
  end
  record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)

  # 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) }

  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
    walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
    return
  end

  node.compact_child_nodes.each do |c|
    gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
  end
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`.



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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rigor/inference/scope_indexer.rb', line 54

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)
  # Merge the indexer's findings on top of whatever the
  # base scope already carries so callers that seed
  # cross-file class knowledge (e.g. the ADR-14
  # `SigGen::ObservationCollector` pre-walking project
  # `lib/` before scanning `spec/`) keep their seeds
  # alongside the per-file declarations the indexer
  # itself discovers. Indexer-found entries win on
  # collision — same-file declarations are the most
  # specific authority.
  merged_classes = default_scope.discovered_classes.merge(discovered_classes)
  seeded_scope = default_scope.with_discovery(
    default_scope.discovery.with(declared_types: declared_types,
                                 discovered_classes: merged_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_discovery(seeded_scope.discovery.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_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
  program_globals = build_program_global_index(root, seeded_scope)
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.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_discovery(
    seeded_scope.discovery.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.
  # Merged UNDER any cross-file pre-pass seed (like the def-node
  # / include tables below) so a method `def`/`attr_reader`-
  # declared in one file suppresses a false `undefined-method`
  # for a call in another — `rigor check` seeds the project-wide
  # table via `Runner#seed_project_scope`.
  discovered_methods = deep_merge_class_methods(
    default_scope.discovered_methods, build_discovered_methods(root)
  )
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))

  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
  # def nodes, the class -> superclass map, and the
  # class/module -> included-modules map, each merged under
  # the cross-file pre-pass seed (see below).
  # v0.1.2 — per-class table of method visibilities
  # (`:public` / `:private` / `:protected`). The
  # `def.method-visibility-mismatch` and ADR-35
  # `def.override-visibility-reduced` CheckRules consult the
  # table. Seeded inside `merge_project_method_indexes` so the
  # per-file visibilities merge OVER the cross-file project seed
  # rather than overwriting it.
  seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)

  table = {}.compare_by_identity
  table.default = seeded_scope

  # Last-visit-wins, not first: when `StatementEvaluator`
  # internally re-evaluates a subtree (notably `eval_begin`'s
  # retry-edge widening pass), the LATER visit carries the
  # corrected entry scope (e.g. a `tries` widened to
  # `Nominal[Integer]` after the rescue body's `tries += 1;
  # retry` is observed). The diagnostic layer reads
  # `table[node]` to type predicates; the second pass's
  # entry is the one that reflects all flow-derived
  # rebinds, so it MUST overwrite the first.
  on_enter = ->(node, scope) { table[node] = scope }
  StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)

  propagate(root, table, seeded_scope)
  table
end

.literal_method_name(node) ⇒ Object



1559
1560
1561
1562
1563
# File 'lib/rigor/inference/scope_indexer.rb', line 1559

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

  node.unescaped&.to_sym
end

.merge_class_keyed_index_tables(acc, root) ⇒ Object

Folds the per-class method-visibility and method-existence tables of one file into the cross-file accumulator (kept out of #accumulate_project_index to hold its ABC budget).



1695
1696
1697
1698
1699
1700
1701
1702
# File 'lib/rigor/inference/scope_indexer.rb', line 1695

def merge_class_keyed_index_tables(acc, root)
  build_discovered_method_visibilities(root).each do |class_name, table|
    (acc[:method_visibilities][class_name] ||= {}).merge!(table)
  end
  build_discovered_methods(root).each do |class_name, table|
    (acc[:methods][class_name] ||= {}).merge!(table)
  end
end

.merge_discovered_defs(def_nodes, def_sources, path, root) ⇒ Object

Merges one file’s ‘class → method → DefNode` map into the cross-file `def_nodes` index and records each method’s first- seen ‘“path:line”` definition site in `def_sources` (ADR-17 —the un-registered-project-patch signal `call.undefined-method` and `rigor triage` key on).



1729
1730
1731
1732
1733
1734
1735
1736
1737
# File 'lib/rigor/inference/scope_indexer.rb', line 1729

def merge_discovered_defs(def_nodes, def_sources, path, root)
  build_discovered_def_nodes(root).each do |class_name, methods|
    (def_nodes[class_name] ||= {}).merge!(methods)
    sources = (def_sources[class_name] ||= {})
    methods.each do |method_name, def_node|
      sources[method_name] ||= "#{path}:#{def_node.location&.start_line || 1}"
    end
  end
end

.merge_project_method_indexes(seeded_scope, default_scope, root) ⇒ Object

v0.0.2 #5 + ADR-24 slice 2 — seeds the three project-method indexes onto ‘seeded_scope`: the per-instance-method def-node table, the class -> superclass map, and the class/module -> included-modules map. Each per-file table is merged UNDER the cross-file `discovered_def_index_for_paths` seed carried on `default_scope` — same-file declarations win per entry, the cross-file seed supplies sibling-file ancestors.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rigor/inference/scope_indexer.rb', line 163

def merge_project_method_indexes(seeded_scope, default_scope, root)
  def_nodes = default_scope.discovered_def_nodes.merge(
    build_discovered_def_nodes(root)
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
  superclasses = default_scope.discovered_superclasses.merge(
    build_discovered_superclasses(root)
  )
  includes = default_scope.discovered_includes.merge(
    build_discovered_includes(root)
  ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
  # ADR-35 — per-file visibilities merged OVER the cross-file
  # seed (the current file is authoritative for its own classes;
  # sibling-file ancestors are preserved from the project seed).
  method_visibilities = default_scope.discovered_method_visibilities.merge(
    build_discovered_method_visibilities(root)
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
  # ADR-48 — per-file Data member layouts merged OVER the cross-file
  # seed (same-file declaration is authoritative for its own classes).
  data_member_layouts = default_scope.data_member_layouts.merge(
    build_data_member_layouts(root)
  )

  seeded_scope.with_discovery(
    seeded_scope.discovery.with(
      discovered_def_nodes: def_nodes,
      discovered_superclasses: superclasses,
      discovered_includes: includes,
      discovered_method_visibilities: method_visibilities,
      data_member_layouts: data_member_layouts
    )
  )
end

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

Returns:

  • (Boolean)


1886
1887
1888
1889
1890
1891
# File 'lib/rigor/inference/scope_indexer.rb', line 1886

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)


1897
1898
1899
1900
1901
1902
1903
1904
# File 'lib/rigor/inference/scope_indexer.rb', line 1897

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_member_names(call_node) ⇒ Object

The Symbol member names of a ‘Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call. For `Struct.new` the optional leading String name and trailing `keyword_init:` hash are stripped by #struct_new_positionals; `Data.define` args are all Symbols already.



1056
1057
1058
1059
1060
# File 'lib/rigor/inference/scope_indexer.rb', line 1056

def meta_member_names(call_node)
  raw = call_node.arguments&.arguments || []
  symbols = struct_new_call?(call_node) ? (struct_new_positionals(raw) || []) : raw
  symbols.filter_map { |arg| arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) }
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`. Survey item (e) extended the recognition to `Const = Module.new do … end` and `Const = Class.new(?super) do … end` — the ADR-16 Tier A “block-as-method” idiom at constant-write position. 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.



1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
# File 'lib/rigor/inference/scope_indexer.rb', line 1016

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) ||
                    module_new_call?(rvalue) ||
                    class_new_call?(rvalue)

  rvalue.block&.body
end

.meta_new_constant_type(node, full) ⇒ Object

Survey item (e): when the rvalue is a recognised ‘Module.new do … end` / `Class.new do … end` / `Struct.new(*sym) do … end` / `Data.define(*sym) do … end` form, type the named constant as `Singleton` so the discovered-method table registered under `full` becomes reachable through singleton-side dispatch (`Const.[]=` etc.). Returns nil for non-meta-new rvalues so the caller falls back to the default `body_scope.type_of(node.value)` shape.



904
905
906
907
908
# File 'lib/rigor/inference/scope_indexer.rb', line 904

def meta_new_constant_type(node, full)
  return nil unless meta_new_block_body(node)

  Type::Combinator.singleton_of(full)
end

.module_new_call?(node) ⇒ Boolean

Recognises ‘Module.new` and `Module.new(&block)` / `Module.new do … end` at constant-write rvalue position. The block body is the anonymous module’s ‘module_eval` body; defs inside it bind methods on the named constant (`Const = Module.new do …; def foo; …; end; end`). Arguments are NOT inspected because `Module.new` accepts no positionals — Ruby raises ArgumentError if any are passed — so a malformed call falls through the walker without affecting analysis.

Returns:

  • (Boolean)


1870
1871
1872
# File 'lib/rigor/inference/scope_indexer.rb', line 1870

def module_new_call?(node)
  meta_call_with_name?(node, :Module, :new)
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.



1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
# File 'lib/rigor/inference/scope_indexer.rb', line 1936

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



1957
1958
1959
1960
1961
1962
# File 'lib/rigor/inference/scope_indexer.rb', line 1957

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



1964
1965
1966
1967
1968
1969
# File 'lib/rigor/inference/scope_indexer.rb', line 1964

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



1906
1907
1908
1909
1910
1911
1912
1913
# File 'lib/rigor/inference/scope_indexer.rb', line 1906

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



1507
1508
1509
1510
1511
1512
1513
1514
1515
# File 'lib/rigor/inference/scope_indexer.rb', line 1507

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`).



1454
1455
1456
1457
1458
1459
1460
1461
1462
# File 'lib/rigor/inference/scope_indexer.rb', line 1454

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_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object



1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
# File 'lib/rigor/inference/scope_indexer.rb', line 1540

def record_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator)
  return if qualified_prefix.empty?
  return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
  return if call_node.arguments.nil?

  kind = in_singleton_class ? :singleton : :instance
  reader = call_node.name != :attr_writer
  writer = call_node.name != :attr_reader
  class_name = qualified_prefix.join("::")
  call_node.arguments.arguments.each do |arg|
    base = literal_method_name(arg)
    next if base.nil?

    accumulator[class_name] ||= {}
    accumulator[class_name][base] = kind if reader
    accumulator[class_name][:"#{base}="] = kind if writer
  end
end

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

Returns:

  • (Boolean)


1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
# File 'lib/rigor/inference/scope_indexer.rb', line 1793

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_class_sources(class_sources, path, root, superclasses, includes) ⇒ Object

ADR-46 slice 1 — accumulates, per qualified user class/module name, the set of files that declare it. A class’s declaration shape (its body ‘def`s, its `class Foo < Bar` superclass, its `include`s) lives wherever the class is opened, so every file that contributes a def / superclass / include for a name is a source of that name’s ancestry edges. Scope#superclass_of / Scope#includes_of record this set when resolving the edge during dependency recording (ADR-46). The class-declaration walk (‘collect_class_decls`) catches bodyless / def-less reopenings the other three builders miss.



1714
1715
1716
1717
1718
1719
1720
1721
1722
# File 'lib/rigor/inference/scope_indexer.rb', line 1714

def record_class_sources(class_sources, path, root, superclasses, includes)
  names = Set.new
  collect_class_decls(root, [], decls = {})
  names.merge(decls.keys)
  names.merge(superclasses.keys)
  names.merge(includes.keys)
  names.merge(build_discovered_def_nodes(root).keys)
  names.each { |name| (class_sources[name] ||= Set.new) << path }
end

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



884
885
886
887
888
889
890
891
892
893
# File 'lib/rigor/inference/scope_indexer.rb', line 884

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 = meta_new_constant_type(node, full) || 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



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

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_data_member_layout(accumulator, qualified_parts, expr) ⇒ Object

Records ‘qualified -> [members]` when `expr` is a `Data.define(*Symbol)` call with at least one literal-Symbol member.



1261
1262
1263
1264
1265
1266
1267
1268
# File 'lib/rigor/inference/scope_indexer.rb', line 1261

def record_data_member_layout(accumulator, qualified_parts, expr)
  return unless data_define_call?(expr)

  members = meta_member_names(expr)
  return if members.empty?

  accumulator[qualified_parts.join("::")] = members.freeze
end

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



1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
# File 'lib/rigor/inference/scope_indexer.rb', line 1778

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



1062
1063
1064
1065
1066
1067
1068
1069
1070
# File 'lib/rigor/inference/scope_indexer.rb', line 1062

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

  class_name = qualified_prefix.join("::")
  singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class)
  kind = singleton ? :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



1170
1171
1172
1173
1174
1175
1176
# File 'lib/rigor/inference/scope_indexer.rb', line 1170

def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
  return if def_singleton?(def_node, qualified_prefix, 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



1399
1400
1401
1402
1403
1404
1405
1406
# File 'lib/rigor/inference/scope_indexer.rb', line 1399

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



1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
# File 'lib/rigor/inference/scope_indexer.rb', line 1517

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



838
839
840
841
842
843
# File 'lib/rigor/inference/scope_indexer.rb', line 838

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_mutator_call(node, class_name, mutated_ivars) ⇒ Object

Records ‘@ivar.<method>(…)` calls whose method is in `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`. The class-ivar pre-pass uses the resulting set to widen the post-collected accumulator entries (see widen_mutated_ivar_entries!). Always-safe to over- collect: any name that the widening primitive declines is ignored at finalization.



622
623
624
625
626
627
628
629
630
631
# File 'lib/rigor/inference/scope_indexer.rb', line 622

def record_ivar_mutator_call(node, class_name, mutated_ivars)
  receiver = node.receiver
  return unless receiver.is_a?(Prism::InstanceVariableReadNode)
  return unless MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
                MutationWidening::HASH_MUTATORS.include?(node.name)

  per_class = (mutated_ivars[class_name] ||= {})
  per_ivar = (per_class[receiver.name] ||= Set.new)
  per_ivar << node.name
end

.record_ivar_write(node, scope, class_name, accumulator, guarded: false) ⇒ Object



726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
# File 'lib/rigor/inference/scope_indexer.rb', line 726

def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
  rvalue_type = scope.type_of(node.value)

  # `@x = nil unless @x` / `@y = false unless @y` —
  # follow-up to the polarity-aware defensive-init guard
  # fix (ROADMAP § Future cycles — "Defensive ivar-init
  # with nil / false rvalue"). When the rvalue is itself a
  # falsey Constant, `union(rvalue, Constant[nil])`
  # collapses (for `nil`) or doesn't widen the type's
  # truthiness profile (for `false`) — the predicate
  # `unless @x` then folds to a single `Constant[nil]` /
  # `Constant[false]` and the
  # `flow.always-truthy-condition` / `-always-falsey-`
  # rule false-fires on the no-op-but-documented-default
  # idiom. Skip the seed contribution for this write
  # (matches the existing skip for `@x ||= v`, which the
  # pre-pass also does not seed). Other writes to the
  # same ivar still contribute; the falsey-default write
  # carries no useful precision the predicate hasn't
  # already given us. See tdiary-core HEAD `ee40c2b`
  # `lib/tdiary/configuration.rb:157` for the worked site.
  return if guarded && falsey_constant?(rvalue_type)

  rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
  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)


1821
1822
1823
1824
1825
1826
1827
1828
# File 'lib/rigor/inference/scope_indexer.rb', line 1821

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

.record_meta_superclass_members(class_node, qualified_prefix, accumulator) ⇒ Object

‘class Foo < Data.define(:a, :b)` / `class Bar < Struct.new(:x)` synthesizes reader methods (`a`, `b`, `x`) on the subclass that no `def` / `attr_*` declares. Register them in the discovered-methods existence table so an implicit-self read of a member inside the class body is known to exist — both for the existing undefined-method suppression and for the ADR-24 slice-4 self-call recorder, which must treat a synthesized member as an existing method, not an unresolved call. The block-form (`Const = Data.define(:a) do … end`) is handled by the `ConstantWriteNode` branch’s block recursion; its members type ‘self` as `Object`, out of scope here.



1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
# File 'lib/rigor/inference/scope_indexer.rb', line 1039

def record_meta_superclass_members(class_node, qualified_prefix, accumulator)
  superclass = class_node.superclass
  return unless data_define_call?(superclass) || struct_new_call?(superclass)

  members = meta_member_names(superclass)
  return if members.empty?

  class_name = qualified_prefix.join("::")
  table = (accumulator[class_name] ||= {})
  members.each { |member| table[member] ||= :instance }
end

.record_mixin_call(node, current_class, accumulator) ⇒ Object



1308
1309
1310
1311
1312
1313
1314
1315
1316
# File 'lib/rigor/inference/scope_indexer.rb', line 1308

def record_mixin_call(node, current_class, accumulator)
  return unless current_class && node.receiver.nil?
  return unless MIXIN_CALL_NAMES.include?(node.name)

  node.arguments&.arguments&.each do |arg|
    mod = qualified_name_for(arg)
    (accumulator[current_class] ||= []) << mod if mod
  end
end

.render_constant_path(node) ⇒ Object



1915
1916
1917
1918
1919
1920
1921
1922
1923
# File 'lib/rigor/inference/scope_indexer.rb', line 1915

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

.singleton_class_prefix(node, qualified_prefix) ⇒ Object

Resolves a ‘class << X` body’s qualified prefix.

- `class << self` keeps `qualified_prefix` (the enclosing class).
- `class << Foo` inside `class Foo` collapses to the same prefix
  (semantically `class << self`).
- `class << Foo` not nested in `class Foo` returns `[Foo]`
  so methods defined inside register on Foo's singleton.
- Any other expression (variable, method call) returns nil
  so the walker falls through and skips the body.


988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
# File 'lib/rigor/inference/scope_indexer.rb', line 988

def singleton_class_prefix(node, qualified_prefix)
  case node.expression
  when Prism::SelfNode
    qualified_prefix
  when Prism::ConstantReadNode, Prism::ConstantPathNode
    rendered = qualified_name_for(node.expression)
    return nil unless rendered

    if !qualified_prefix.empty? && qualified_prefix.last == rendered
      qualified_prefix
    else
      rendered.split("::")
    end
  end
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)


1851
1852
1853
1854
1855
1856
1857
1858
1859
# File 'lib/rigor/inference/scope_indexer.rb', line 1851

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



1893
1894
1895
# File 'lib/rigor/inference/scope_indexer.rb', line 1893

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

.subtract_def_methods(methods, def_nodes) ⇒ Object

Removes, per class, the method names that have a project ‘def` node, leaving only accessor/alias/define_method-introduced methods in the cross-file suppression table.



1667
1668
1669
1670
1671
1672
1673
# File 'lib/rigor/inference/scope_indexer.rb', line 1667

def subtract_def_methods(methods, def_nodes)
  methods.each_with_object({}) do |(class_name, table), out|
    defs = def_nodes[class_name] || {}
    kept = table.reject { |method_name, _kind| defs.key?(method_name) }
    out[class_name] = kept unless kept.empty?
  end
end

.then_body_guarded_ivars(node) ⇒ Object

Returns the set of ivar names that, in the THEN body of this conditional, are statically known to be in a nil / unset state — i.e. the body really IS the defensive-init half of the idiom. Conservative on purpose: only the shapes that idiomatically express “the ivar is missing” qualify.

For ‘unless P; body; end`, body runs when `P` is falsey:

- `P = @x` (or `@x && other` / `@x || other`)            → @x is falsey
- `P = defined?(@x)`                                     → @x is undefined

For ‘if P; body; …`, body runs when `P` is truthy:

- `P = @x.nil?`                                          → @x is nil
- `P = !@x` / `not @x`                                   → @x is falsey


674
675
676
677
678
679
680
681
682
683
# File 'lib/rigor/inference/scope_indexer.rb', line 674

def then_body_guarded_ivars(node)
  names = Set.new
  if node.is_a?(Prism::UnlessNode)
    collect_truthy_test_ivars(node.predicate, names)
    collect_defined_test_ivars(node.predicate, names)
  else
    collect_nil_test_ivars(node.predicate, names)
  end
  names
end

.visibility_target_name(arg) ⇒ Object



1443
1444
1445
1446
1447
# File 'lib/rigor/inference/scope_indexer.rb', line 1443

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



773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
# File 'lib/rigor/inference/scope_indexer.rb', line 773

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_includes(node, qualified_prefix, current_class, accumulator) ⇒ Object



1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
# File 'lib/rigor/inference/scope_indexer.rb', line 1288

def walk_class_includes(node, qualified_prefix, current_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
      full = (qualified_prefix + [name]).join("::")
      walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
      return
    end
  when Prism::CallNode
    record_mixin_call(node, current_class, accumulator)
  end

  node.compact_child_nodes.each do |child|
    walk_class_includes(child, qualified_prefix, current_class, accumulator)
  end
end

.walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
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
# File 'lib/rigor/inference/scope_indexer.rb', line 337

def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity
                     read_before_write = nil, init_writes = nil)
  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]
      if node.body
        # Class-body level `@x = nil` writes don't
        # initialise instance ivars at runtime (the
        # class's own singleton ivars and the instance's
        # ivars are separate stores), but they signal
        # "the author KNOWS @x could be nil" and extend
        # the B2.3 soundness gate: an ivar with a
        # class-body write is exempted from the
        # read-before-write nil contribution because the
        # seed already reflects the author's acknowledged
        # nullability via the def-body writes' union.
        # Without this exemption, code that explicitly
        # `@x = nil`s at class-body level then writes
        # `@x = SomeClass.new` inside an instance method
        # gains an unjustified nil widening at every
        # read.
        collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
        walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
                         mutated_ivars, read_before_write, init_writes)
      end
      return
    end
  when Prism::DefNode
    collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
                            mutated_ivars, read_before_write, init_writes)
    return
  when Prism::CallNode
    if init_writes && !qualified_prefix.empty? &&
       node.block.is_a?(Prism::BlockNode) &&
       block_initializer?(qualified_prefix.join("::"), node.name, default_scope)
      collect_block_ivar_writes(node.block, qualified_prefix, default_scope,
                                accumulator, mutated_ivars, init_writes)
    end
  end

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

.walk_class_superclasses(node, qualified_prefix, accumulator) ⇒ Object



1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
# File 'lib/rigor/inference/scope_indexer.rb', line 1192

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

  case node
  when Prism::ClassNode
    name = qualified_name_for(node.constant_path)
    if name
      full = (qualified_prefix + [name]).join("::")
      superclass = node.superclass && qualified_name_for(node.superclass)
      accumulator[full] = superclass if superclass
      walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
      return
    end
  when Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
      return
    end
  end

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

.walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil) ⇒ Object

Walk an ‘IfNode` / `UnlessNode` so writes inside the THEN body that look like defensive ivar initialisation gain a `nil` union in the seeded type. Without this, `@x = v unless @x` records `Constant` for `@x`, then the predicate folds to that same constant and `flow.always-truthy-condition` fires against a working program. Mirrors the existing skip for `@x ||= v` (`Prism::InstanceVariableOrWriteNode`, which the pre-pass does not seed at all).

Polarity-aware on purpose: only the THEN body picks up the guard. The ELSE branch of ‘if @x; …; else; @x = init; end` would otherwise be marked too — but that pattern (write @x in the else of `if @x`) is a separate idiom whose surrounding reads of `@x` would then surface a nil-receiver FP. The ELSE branch is left ungarded so those reads continue to type as they did before this fix.



649
650
651
652
653
654
655
656
657
658
659
# File 'lib/rigor/inference/scope_indexer.rb', line 649

def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil)
  then_guards = then_body_guarded_ivars(node)
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)

  gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
  if node.statements
    gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars)
  end
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
  gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if branch
end

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



859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
# File 'lib/rigor/inference/scope_indexer.rb', line 859

def walk_constant_writes(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_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_data_member_layouts(node, qualified_prefix, accumulator) ⇒ Object



1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
# File 'lib/rigor/inference/scope_indexer.rb', line 1233

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

  case node
  when Prism::ClassNode
    name = qualified_name_for(node.constant_path)
    if name
      record_data_member_layout(accumulator, qualified_prefix + [name], node.superclass)
      walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
      return
    end
  when Prism::ModuleNode
    name = qualified_name_for(node.constant_path)
    if name
      walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
      return
    end
  when Prism::ConstantWriteNode
    record_data_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
  end

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

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



1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
# File 'lib/rigor/inference/scope_indexer.rb', line 1127

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.body
      singleton_prefix = singleton_class_prefix(node, qualified_prefix)
      if singleton_prefix
        walk_def_nodes(node.body, singleton_prefix, true, accumulator)
        return
      end
    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



1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
# File 'lib/rigor/inference/scope_indexer.rb', line 1348

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.body
      singleton_prefix = singleton_class_prefix(node, qualified_prefix)
      if singleton_prefix
        walk_method_visibilities(node.body, singleton_prefix, true, :public, accumulator)
        return current_visibility
      end
    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/AbcSize, Metrics/PerceivedComplexity



935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
# File 'lib/rigor/inference/scope_indexer.rb', line 935

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]
      record_meta_superclass_members(node, child_prefix, accumulator) if node.is_a?(Prism::ClassNode)
      walk_methods(node.body, child_prefix, false, accumulator) if node.body
      return
    end
  when Prism::SingletonClassNode
    if node.body
      singleton_prefix = singleton_class_prefix(node, qualified_prefix)
      if singleton_prefix
        walk_methods(node.body, singleton_prefix, true, accumulator)
        return
      end
    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
    if ATTR_MACROS.include?(node.name)
      record_attr_methods(node, qualified_prefix, in_singleton_class,
                          accumulator)
    end
  end

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

.widen_member_for_observed_mutators(member, observed_methods) ⇒ Object



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/rigor/inference/scope_indexer.rb', line 321

def widen_member_for_observed_mutators(member, observed_methods)
  case member
  when Type::Tuple
    return member unless observed_methods.any? { |m| MutationWidening::ARRAY_MUTATORS.include?(m) }

    Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
  when Type::HashShape
    return member unless observed_methods.any? { |m| MutationWidening::HASH_MUTATORS.include?(m) }

    Type::Combinator.nominal_of("Hash",
                                type_args: [Type::Combinator.untyped, Type::Combinator.untyped])
  else
    member
  end
end

.widen_mutated_ivar_entries!(accumulator, mutated_ivars) ⇒ Object

Walks the post-collected accumulator and widens any Tuple / HashShape entry for an ivar that observed a mutator call anywhere in the same class body. The mutation evidence comes from ‘gather_ivar_writes` recording every `@ivar.<method>(…)` call whose method is in `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.

The widening uses ‘MutationWidening.widen_for_mutator` —the same primitive `Inference::StatementEvaluator#eval_call` applies for per-method-body widening on a local / ivar receiver. The class-level pass extends that primitive’s reach so a ‘Tuple`-seeded ivar in `initialize` is observed as `Nominal` at the entry of every OTHER method body in the class — closing the cross-method gap noted in ROADMAP § Future cycles / Type-language / engine (“Tuple / HashShape widening for ivar-seeded literals after mutation”; Redmine 6.1.2 `Redmine::Views::Builders::Structure` is the canonical worked site).

Always-safe: the widening can only LOSE precision; the underlying nominal (‘Array` / `Hash`) and the element union are preserved.



286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/rigor/inference/scope_indexer.rb', line 286

def widen_mutated_ivar_entries!(accumulator, mutated_ivars)
  accumulator.each do |class_name, ivars|
    observed = mutated_ivars[class_name]
    next if observed.nil? || observed.empty?

    ivars.each do |ivar_name, type|
      methods = observed[ivar_name]
      next if methods.nil? || methods.empty?

      ivars[ivar_name] = widen_type_for_observed_mutators(type, methods)
    end
  end
end

.widen_type_for_observed_mutators(type, observed_methods) ⇒ Object

Walks a class-ivar accumulator entry (which may be a ‘Union` of multiple write rvalues) and widens any `Tuple` or `HashShape` member whose corresponding mutator family was observed against the ivar somewhere in the class. Class-level widening is more aggressive than the per-method-body `MutationWidening` primitive: it widens both the SHAPE carrier (Tuple → Array, HashShape → Hash) AND the element types to `Dynamic`. The justification — once any method mutates the ivar, its post-mutation contents are statically unknown across method boundaries, so preserving the seed-write’s element precision would be an unsound over-claim (e.g. ‘@struct = [{}]; somewhere: `Constant`).



315
316
317
318
319
# File 'lib/rigor/inference/scope_indexer.rb', line 315

def widen_type_for_observed_mutators(type, observed_methods)
  members = type.is_a?(Type::Union) ? type.members : [type]
  widened = members.map { |m| widen_member_for_observed_mutators(m, observed_methods) }
  Type::Combinator.union(*widened)
end