Class: Rigor::Scope

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/scope.rb,
lib/rigor/scope/discovery_index.rb,
sig/rigor/scope.rbs

Overview

Immutable analyzer scope: holds local-variable bindings and a reference to the surrounding Environment. State changes return new scopes through explicit transition methods (#with_local). The central query is #type_of(node), the Rigor counterpart of PHPStan's $scope->getType($node).

See docs/internal-spec/inference-engine.md for the binding contract. rubocop:disable Metrics/ClassLength,Metrics/ParameterLists

Defined Under Namespace

Classes: ChainKey, DiscoveryIndex, IndexedKey

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(environment:, locals:, fact_store: Analysis::FactStore.empty, self_type: nil, ivars: EMPTY_VAR_BINDINGS, cvars: EMPTY_VAR_BINDINGS, globals: EMPTY_VAR_BINDINGS, discovery: DiscoveryIndex::EMPTY, indexed_narrowings: EMPTY_INDEXED_NARROWINGS, method_chain_narrowings: EMPTY_CHAIN_NARROWINGS, declaration_sourced: EMPTY_DECLARATION_SOURCED, source_path: nil, struct_fold_safe_locals: EMPTY_FOLD_SAFE, opaque_block_self: false, dynamic_origins: {}.compare_by_identity, local_origins: EMPTY_ORIGINS, ivar_origins: EMPTY_ORIGINS, void_origins: {}.compare_by_identity, optimistic_origins: {}.compare_by_identity, optimistic_locals: EMPTY_ORIGINS, optimistic_ivars: EMPTY_ORIGINS) ⇒ Scope

Returns a new instance of Scope.

Parameters:

  • environment: (Environment)
  • locals: (Hash[Symbol, Type::t])
  • fact_store: (Analysis::FactStore) (defaults to: Analysis::FactStore.empty)
  • self_type: (Type::t, nil) (defaults to: nil)
  • ivars: (Hash[Symbol, Type::t]) (defaults to: EMPTY_VAR_BINDINGS)
  • cvars: (Hash[Symbol, Type::t]) (defaults to: EMPTY_VAR_BINDINGS)
  • globals: (Hash[Symbol, Type::t]) (defaults to: EMPTY_VAR_BINDINGS)
  • discovery: (DiscoveryIndex) (defaults to: DiscoveryIndex::EMPTY)
  • source_path: (String, nil) (defaults to: nil)
  • dynamic_origins: (Hash[untyped, Symbol]) (defaults to: {}.compare_by_identity)


138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/rigor/scope.rb', line 138

def initialize(
  environment:, locals:,
  fact_store: Analysis::FactStore.empty,
  self_type: nil,
  ivars: EMPTY_VAR_BINDINGS,
  cvars: EMPTY_VAR_BINDINGS,
  globals: EMPTY_VAR_BINDINGS,
  discovery: DiscoveryIndex::EMPTY,
  indexed_narrowings: EMPTY_INDEXED_NARROWINGS,
  method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
  source_path: nil,
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
  opaque_block_self: false,
  dynamic_origins: {}.compare_by_identity,
  local_origins: EMPTY_ORIGINS,
  ivar_origins: EMPTY_ORIGINS,
  void_origins: {}.compare_by_identity,
  optimistic_origins: {}.compare_by_identity,
  optimistic_locals: EMPTY_ORIGINS,
  optimistic_ivars: EMPTY_ORIGINS
)
  @environment = environment
  @locals = locals
  @fact_store = fact_store
  @self_type = self_type
  @ivars = ivars
  @cvars = cvars
  @globals = globals
  @discovery = discovery
  @indexed_narrowings = indexed_narrowings
  @method_chain_narrowings = method_chain_narrowings
  @declaration_sourced = declaration_sourced
  @source_path = source_path
  @struct_fold_safe_locals = struct_fold_safe_locals
  @opaque_block_self = opaque_block_self
  @dynamic_origins = dynamic_origins
  @local_origins = local_origins
  @ivar_origins = ivar_origins
  @void_origins = void_origins
  @optimistic_origins = optimistic_origins
  @optimistic_locals = optimistic_locals
  @optimistic_ivars = optimistic_ivars
  freeze
end

Instance Attribute Details

#cvarsHash[Symbol, Type::t] (readonly)

Returns the value of attribute cvars.

Returns:

  • (Hash[Symbol, Type::t])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def cvars
  @cvars
end

#declaration_sourcedSet[[Symbol, Symbol]] (readonly)

Returns the value of attribute declaration_sourced.

Returns:

  • (Set[[Symbol, Symbol]])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def declaration_sourced
  @declaration_sourced
end

#discoveryDiscoveryIndex (readonly)

Returns the value of attribute discovery.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def discovery
  @discovery
end

#dynamic_originsHash[untyped, Symbol] (readonly)

Returns the value of attribute dynamic_origins.

Returns:

  • (Hash[untyped, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def dynamic_origins
  @dynamic_origins
end

#environmentEnvironment (readonly)

Returns the value of attribute environment.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def environment
  @environment
end

#fact_storeAnalysis::FactStore (readonly)

Returns the value of attribute fact_store.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def fact_store
  @fact_store
end

#globalsHash[Symbol, Type::t] (readonly)

Returns the value of attribute globals.

Returns:

  • (Hash[Symbol, Type::t])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def globals
  @globals
end

#indexed_narrowingsHash[IndexedKey, Type::t] (readonly)

Returns the value of attribute indexed_narrowings.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def indexed_narrowings
  @indexed_narrowings
end

#ivar_originsHash[Symbol, Symbol] (readonly)

Returns the value of attribute ivar_origins.

Returns:

  • (Hash[Symbol, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def ivar_origins
  @ivar_origins
end

#ivarsHash[Symbol, Type::t] (readonly)

Returns the value of attribute ivars.

Returns:

  • (Hash[Symbol, Type::t])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def ivars
  @ivars
end

#local_originsHash[Symbol, Symbol] (readonly)

Returns the value of attribute local_origins.

Returns:

  • (Hash[Symbol, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def local_origins
  @local_origins
end

#localsHash[Symbol, Type::t] (readonly)

Returns the value of attribute locals.

Returns:

  • (Hash[Symbol, Type::t])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def locals
  @locals
end

#method_chain_narrowingsHash[ChainKey, Type::t] (readonly)

Returns the value of attribute method_chain_narrowings.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def method_chain_narrowings
  @method_chain_narrowings
end

#opaque_block_selfBoolean (readonly)

Returns the value of attribute opaque_block_self.

Returns:

  • (Boolean)


21
22
23
# File 'lib/rigor/scope.rb', line 21

def opaque_block_self
  @opaque_block_self
end

#optimistic_ivarsHash[Symbol, Symbol] (readonly)

Returns the value of attribute optimistic_ivars.

Returns:

  • (Hash[Symbol, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def optimistic_ivars
  @optimistic_ivars
end

#optimistic_localsHash[Symbol, Symbol] (readonly)

Returns the value of attribute optimistic_locals.

Returns:

  • (Hash[Symbol, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def optimistic_locals
  @optimistic_locals
end

#optimistic_originsHash[untyped, Symbol] (readonly)

Returns the value of attribute optimistic_origins.

Returns:

  • (Hash[untyped, Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def optimistic_origins
  @optimistic_origins
end

#self_typeType::t? (readonly)

Returns the value of attribute self_type.

Returns:

  • (Type::t, nil)


21
22
23
# File 'lib/rigor/scope.rb', line 21

def self_type
  @self_type
end

#source_pathString? (readonly)

Returns the value of attribute source_path.

Returns:

  • (String, nil)


21
22
23
# File 'lib/rigor/scope.rb', line 21

def source_path
  @source_path
end

#struct_fold_safe_localsSet[Symbol] (readonly)

Returns the value of attribute struct_fold_safe_locals.

Returns:

  • (Set[Symbol])


21
22
23
# File 'lib/rigor/scope.rb', line 21

def struct_fold_safe_locals
  @struct_fold_safe_locals
end

#void_originsHash[untyped, Inference::VoidOrigin] (readonly)

Returns the value of attribute void_origins.

Returns:



21
22
23
# File 'lib/rigor/scope.rb', line 21

def void_origins
  @void_origins
end

Class Method Details

.empty(environment: Environment.default, source_path: nil) ⇒ Scope

Parameters:

  • environment: (Environment) (defaults to: Environment.default)
  • source_path: (String, nil) (defaults to: nil)

Returns:



117
118
119
120
# File 'lib/rigor/scope.rb', line 117

def empty(environment: Environment.default, source_path: nil)
  new(environment: environment, locals: {}.freeze,
      fact_store: Analysis::FactStore.empty, source_path: source_path)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (Object)

Returns:

  • (Boolean)


787
788
789
790
791
792
793
794
795
796
797
798
799
# File 'lib/rigor/scope.rb', line 787

def ==(other)
  other.is_a?(Scope) &&
    environment.equal?(other.environment) &&
    @locals == other.locals &&
    fact_store == other.fact_store &&
    self_type == other.self_type &&
    @ivars == other.ivars &&
    @cvars == other.cvars &&
    @globals == other.globals &&
    @indexed_narrowings == other.indexed_narrowings &&
    @method_chain_narrowings == other.method_chain_narrowings &&
    @declaration_sourced == other.declaration_sourced
end

#bindable_top_level_def_for(method_name) ⇒ Object?

Issue #316 — the CONFIDENCE-GATED companion of #top_level_def_for, and the only accessor the type inference may bind through. #top_level_def_for stays unrestricted because it also serves the suppression side (call.unresolved-toplevel, call.undefined-method): a name the project defines at the top level must never be reported as unresolved, whatever this gate decides.

Returns nil — decline to bind, stay silent — when BOTH hold:

  1. The call site sits inside a block whose self is unmodelled (#opaque_block_self?) and no narrowed self_type says otherwise. A top-level def is a private method on Object, so it is callable from any self; what the analyzer cannot see is whether the block's real self gained a PUBLIC same-named method by include / extend, which wins the MRO over the private Object def. RSpec's output / include / match matchers against a project's own def output are exactly this.
  2. The def lives in a DIFFERENT file from the call site. Collocation is the evidence that the two belong to one lexical structure — the RSpec.describe do; def helper; end; it { helper } end case v0.0.3 A and #319 deliberately serve. Cross-file, the two share only a name.

Both conditions are required, so a top-level helper called from genuine top-level code keeps resolving (cross-file included), and a helper defined beside its DSL-block call site keeps resolving too. When the project pre-pass recorded no source for the name, the file test cannot be answered and the historical bind is kept.

Parameters:

  • method_name (String, Symbol)

Returns:

  • (Object, nil)


549
550
551
552
553
554
555
# File 'lib/rigor/scope.rb', line 549

def bindable_top_level_def_for(method_name)
  node = top_level_def_for(method_name)
  return node if node.nil?
  return node unless @opaque_block_self && @self_type.nil?

  same_file_top_level_def?(method_name) ? node : nil
end

#class_cvarsHash[String, Hash[Symbol, Type::t]]

Returns:

  • (Hash[String, Hash[Symbol, Type::t]])


40
# File 'lib/rigor/scope.rb', line 40

def class_cvars = @discovery.class_cvars

#class_cvars_for(class_name) ⇒ Hash[Symbol, Type::t]

Slice 7 phase 6 — class-level cvar accumulator (same shape as class_ivars but populated from Prism::ClassVariableWriteNode writes, and seeded on BOTH instance and singleton method bodies because Ruby cvars are visible from each).

Parameters:

  • class_name (String, Symbol, nil)

Returns:

  • (Hash[Symbol, Type::t])


440
441
442
443
444
# File 'lib/rigor/scope.rb', line 440

def class_cvars_for(class_name)
  return EMPTY_VAR_BINDINGS if class_name.nil?

  @discovery.class_cvars[class_name.to_s] || EMPTY_VAR_BINDINGS
end

#class_ivarsHash[String, Hash[Symbol, Type::t]]

Returns:

  • (Hash[String, Hash[Symbol, Type::t]])


39
# File 'lib/rigor/scope.rb', line 39

def class_ivars = @discovery.class_ivars

#class_ivars_for(class_name) ⇒ Hash[Symbol, Type::t]

Slice 7 phase 2 — class-level ivar accumulator. Keyed by the qualified class name (e.g. "Rigor::Scope"); the value is a Hash[Symbol, Type::t] of every ivar that appears as a write target inside any def body of that class. StatementEvaluator#build_method_entry_scope seeds the method body's ivars map from this table so a def get; @x; end reads the type written in a sibling def init; @x = 1; end.

ScopeIndexer populates the table once at index time through a separate pre-pass over the program. The map is frozen and shared by structural reference across every derived scope.

Parameters:

  • class_name (String, Symbol, nil)

Returns:

  • (Hash[Symbol, Type::t])


431
432
433
434
435
# File 'lib/rigor/scope.rb', line 431

def class_ivars_for(class_name)
  return EMPTY_VAR_BINDINGS if class_name.nil?

  @discovery.class_ivars[class_name.to_s] || EMPTY_VAR_BINDINGS
end

#cvar(name) ⇒ Type::t?

Parameters:

  • name (String, Symbol)

Returns:

  • (Type::t, nil)


316
317
318
# File 'lib/rigor/scope.rb', line 316

def cvar(name)
  @cvars[name.to_sym]
end

#data_member_layout(class_name) ⇒ Array[Symbol]?

ADR-48 — per-class table mapping a fully qualified class name to its ordered Data.define / Struct.new member-name list. Populated by ScopeIndexer for both the constant-assigned form (Point = Data.define(:x, :y)) and the named-subclass form (class Point < Data.define(:x, :y)). Consumed by Inference::MethodDispatcher::DataFolding so Point.new(...) on a Singleton[Point] receiver materialises a precise member instance. Returns nil when the class has no recorded layout.

Parameters:

  • class_name (String, Symbol)

Returns:

  • (Array[Symbol], nil)


621
622
623
624
625
626
627
# File 'lib/rigor/scope.rb', line 621

def data_member_layout(class_name)
  layout = @discovery.data_member_layouts[class_name.to_s]
  # Record the ancestry dependency only on a hit — DataFolding consults this for every `Singleton[*].new`,
  # and a miss (the common case: an ordinary class) must not manufacture a spurious cross-file edge.
  record_class_dependency(class_name) if layout && Analysis::DependencyRecorder.active?
  layout
end

#data_member_layoutsHash[String, Array[Symbol]]

Returns:

  • (Hash[String, Array[Symbol]])


53
# File 'lib/rigor/scope.rb', line 53

def data_member_layouts = @discovery.data_member_layouts

#declaration_sourced?(kind, name) ⇒ Boolean

ADR-58 WD1 — true when (kind, name)'s binding optionality is purely declaration-sourced (no flow-live write/narrowing has touched it).

Parameters:

  • kind (Symbol)
  • name (String, Symbol)

Returns:

  • (Boolean)


362
363
364
# File 'lib/rigor/scope.rb', line 362

def declaration_sourced?(kind, name)
  @declaration_sourced.include?([kind.to_sym, name.to_sym])
end

#declared_typesHash[untyped, Type::t]

ADR-53 Track A — the seed-time discovery tables live on the DiscoveryIndex; Scope keeps per-table readers as delegates.

Returns:

  • (Hash[untyped, Type::t])


38
# File 'lib/rigor/scope.rb', line 38

def declared_types = @discovery.declared_types

#discovered_class_sourcesHash[String, Set[String]]

Returns:

  • (Hash[String, Set[String]])


52
# File 'lib/rigor/scope.rb', line 52

def discovered_class_sources = @discovery.discovered_class_sources

#discovered_classesHash[String, Type::Singleton]

Returns:



42
# File 'lib/rigor/scope.rb', line 42

def discovered_classes = @discovery.discovered_classes

#discovered_def_nodesHash[String, Hash[Symbol, untyped]]

Returns:

  • (Hash[String, Hash[Symbol, untyped]])


45
# File 'lib/rigor/scope.rb', line 45

def discovered_def_nodes = @discovery.discovered_def_nodes

#discovered_def_sourcesHash[String, Hash[Symbol, String]]

Returns:

  • (Hash[String, Hash[Symbol, String]])


47
# File 'lib/rigor/scope.rb', line 47

def discovered_def_sources = @discovery.discovered_def_sources

#discovered_includesHash[String, Array[String]]

Returns:

  • (Hash[String, Array[String]])


51
# File 'lib/rigor/scope.rb', line 51

def discovered_includes = @discovery.discovered_includes

#discovered_method?(class_name, method_name, kind) ⇒ Boolean

Slice 7 phase 12 — in-source method discovery. Maps a qualified class name to a Hash[Symbol, Symbol] of method_name => :instance | :singleton. Populated by ScopeIndexer from every Prism::DefNode and recognised define_method invocation inside class/module bodies. The rigor check undefined-method and wrong-arity rules consult this map to suppress diagnostics for methods the user has defined dynamically, even when no RBS sig describes them. A name defined on both sides of one class records Rigor::Scope::DiscoveryIndex::METHOD_KIND_BOTH and matches either kind.

Parameters:

  • class_name (String, Symbol)
  • method_name (String, Symbol)
  • kind (Symbol)

Returns:

  • (Boolean)


452
453
454
455
456
457
458
# File 'lib/rigor/scope.rb', line 452

def discovered_method?(class_name, method_name, kind)
  table = @discovery.discovered_methods[class_name.to_s]
  return false unless table

  recorded = table[method_name.to_sym]
  recorded == kind || recorded == DiscoveryIndex::METHOD_KIND_BOTH
end

#discovered_method_visibilitiesHash[String, Hash[Symbol, Symbol]]

Returns:

  • (Hash[String, Hash[Symbol, Symbol]])


49
# File 'lib/rigor/scope.rb', line 49

def discovered_method_visibilities = @discovery.discovered_method_visibilities

#discovered_method_visibility(class_name, method_name) ⇒ Symbol?

v0.1.2 — per-class table mapping method_name (Symbol) → :public | :private | :protected. Populated by ScopeIndexer for every def it sees inside a class body, with the visibility taken from the surrounding private / protected / public modifier state plus any post-hoc private :name, ... named-argument calls. Consumed by the def.method-visibility-mismatch rule so explicit-non-self calls to a private method surface a diagnostic.

Parameters:

  • class_name (String, Symbol)
  • method_name (String, Symbol)

Returns:

  • (Symbol, nil)


668
669
670
671
672
673
# File 'lib/rigor/scope.rb', line 668

def discovered_method_visibility(class_name, method_name)
  table = @discovery.discovered_method_visibilities[class_name.to_s]
  return nil unless table

  table[method_name.to_sym]
end

#discovered_methodsHash[String, Hash[Symbol, Symbol]]

Returns:

  • (Hash[String, Hash[Symbol, Symbol]])


44
# File 'lib/rigor/scope.rb', line 44

def discovered_methods = @discovery.discovered_methods

#discovered_singleton_def_nodesHash[String, Hash[Symbol, untyped]]

Returns:

  • (Hash[String, Hash[Symbol, untyped]])


46
# File 'lib/rigor/scope.rb', line 46

def discovered_singleton_def_nodes = @discovery.discovered_singleton_def_nodes

#discovered_singleton_def_sourcesHash[String, Hash[Symbol, String]]

Returns:

  • (Hash[String, Hash[Symbol, String]])


48
# File 'lib/rigor/scope.rb', line 48

def discovered_singleton_def_sources = @discovery.discovered_singleton_def_sources

#discovered_superclassesHash[String, String]

Returns:

  • (Hash[String, String])


50
# File 'lib/rigor/scope.rb', line 50

def discovered_superclasses = @discovery.discovered_superclasses

#entering_opaque_blockScope

Issue #316 — marks a block body whose self Rigor does not model. Ruby gives a block no self of its own: the yielding method decides, and instance_eval / instance_exec (the mechanism behind every self-rebinding DSL — RSpec example groups, Class.new { … }, Rake, Sinatra) is indistinguishable from Array#each without knowing the callee. The flag is set at every block entry that leaves self_type unnarrowed and is inherited by every scope derived inside the block; it never leaks past the block, because eval_call returns the caller's scope unchanged.

Returns:



286
287
288
289
290
# File 'lib/rigor/scope.rb', line 286

def entering_opaque_block
  return self if @opaque_block_self

  rebuild(opaque_block_self: true)
end

#evaluate(node, tracer: nil) ⇒ [Type::t, Scope]

Statement-level evaluation: returns the pair [type, scope'] where type is what the node produces and scope' is the scope observable after the node has run. The receiver scope is never mutated. See Inference::StatementEvaluator for the catalogue of nodes that thread scope; everything else defers to #type_of and returns the receiver scope unchanged.

Parameters:

Returns:



764
765
766
# File 'lib/rigor/scope.rb', line 764

def evaluate(node, tracer: nil)
  Inference::StatementEvaluator.new(scope: self, tracer: tracer).evaluate(node)
end

#facts_for(target: nil, bucket: nil) ⇒ Array[Analysis::FactStore::Fact]

Parameters:

Returns:



740
741
742
# File 'lib/rigor/scope.rb', line 740

def facts_for(target: nil, bucket: nil)
  fact_store.facts_for(target: target, bucket: bucket)
end

#forget_match_globalsScope

Returns:



418
419
420
421
422
# File 'lib/rigor/scope.rb', line 418

def forget_match_globals
  return self unless @globals.keys.any? { |k| MATCH_DATA_GLOBALS.include?(k) }

  rebuild(globals: @globals.except(*MATCH_DATA_GLOBALS).freeze)
end

#global(name) ⇒ Type::t?

Parameters:

  • name (String, Symbol)

Returns:

  • (Type::t, nil)


320
321
322
# File 'lib/rigor/scope.rb', line 320

def global(name)
  @globals[name.to_sym]
end

#hashInteger

Returns:

  • (Integer)


802
803
804
# File 'lib/rigor/scope.rb', line 802

def hash
  [Scope, environment.object_id, @locals, fact_store, self_type, @ivars, @cvars, @globals].hash
end

#in_source_constantsHash[String, Type::t]

Returns:

  • (Hash[String, Type::t])


43
# File 'lib/rigor/scope.rb', line 43

def in_source_constants = @discovery.in_source_constants

#includes_of(class_name) ⇒ Array[String]

ADR-24 slice 2 — per-class/module table mapping a fully qualified user class or module to the list of module names it includes / prepends, AS WRITTEN at the mixin call. Populated by ScopeIndexer (per-file plus the cross-file pre-pass) and consumed by ExpressionTyper#resolve_user_def_through_ancestors so an implicit-self call resolves against an included module's defs, not just the superclass chain. As-written names are resolved to qualified classes at walk time.

Parameters:

  • class_name (String, Symbol)

Returns:

  • (Array[String])


645
646
647
648
# File 'lib/rigor/scope.rb', line 645

def includes_of(class_name)
  record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
  @discovery.discovered_includes[class_name.to_s] || []
end

#indexed_narrowing(receiver_kind, receiver_name, key) ⇒ Type::t?

Closes the "params[:f] ||= []; params[:f] << x" precision gap (ROADMAP § Type-language / engine — indexed-collection narrowing through Hash[k] ||= default). After receiver[key] ||= default, the next read at receiver[key] is known non-nil; recording the post-||= type keyed on (receiver_kind, receiver_name, literal_key) lets the ExpressionTyper's [] dispatch hand back the narrowed type. Receiver-rebind and []=/mutator invalidation rules are documented at the call sites in Inference::StatementEvaluator.

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • key (Object)

Returns:

  • (Type::t, nil)


681
682
683
# File 'lib/rigor/scope.rb', line 681

def indexed_narrowing(receiver_kind, receiver_name, key)
  @indexed_narrowings[indexed_key(receiver_kind, receiver_name, key)]
end

#inferred_param?(name) ⇒ Boolean

ADR-67 WD6b — true when name's local binding is a pristine inferred parameter (the call-site union seeded at method entry, untouched by a flow-live write). The guard predicate the negative in-body rules consult.

Parameters:

  • name (String, Symbol)

Returns:

  • (Boolean)


398
399
400
# File 'lib/rigor/scope.rb', line 398

def inferred_param?(name)
  @declaration_sourced.include?([:inferred_param, name.to_sym])
end

#ivar(name) ⇒ Type::t?

Slice 7 phase 1 — instance/class/global variable bindings. ivar(name) / cvar(name) / global(name) return the type currently bound for the named variable, or nil when the variable has not been written in the analyzed slice of the program. The first cut tracks bindings only within a single method body (each def enters with a fresh binding map), so reads in other methods of the same class fall through to Dynamic[Top]. Cross-method ivar/cvar inference is a follow-up slice.

Parameters:

  • name (String, Symbol)

Returns:

  • (Type::t, nil)


312
313
314
# File 'lib/rigor/scope.rb', line 312

def ivar(name)
  @ivars[name.to_sym]
end

#ivar_origin(name) ⇒ Symbol?

Parameters:

  • name (String, Symbol)

Returns:

  • (Symbol, nil)


212
# File 'lib/rigor/scope.rb', line 212

def ivar_origin(name) = @ivar_origins[name.to_sym]

#join(other) ⇒ Scope

Joins this scope with another at a control-flow merge point. The joined scope is bound to every local that BOTH branches bind, with the type widened to the union of both sides. Names bound in only one branch are dropped from the joined scope; the eventual statement-level evaluator (Slice 3 phase 2) is responsible for nil-injecting half-bound names where the language semantics demand it. The two scopes MUST share the same Environment.

Parameters:

Returns:

Raises:

  • (ArgumentError)


773
774
775
776
777
778
779
780
781
782
783
784
785
# File 'lib/rigor/scope.rb', line 773

def join(other)
  raise ArgumentError, "join requires a Rigor::Scope, got #{other.class}" unless other.is_a?(Scope)

  unless environment.equal?(other.environment)
    raise ArgumentError, "join requires both scopes to share the same Environment"
  end

  joined_locals = join_bindings(locals, other.locals)
  joined_ivars = join_bindings(ivars, other.ivars)
  joined_cvars = join_bindings(cvars, other.cvars)
  joined_globals = join_bindings(globals, other.globals)
  build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
end

#local(name) ⇒ Type::t?

Parameters:

  • name (String, Symbol)

Returns:

  • (Type::t, nil)


228
229
230
# File 'lib/rigor/scope.rb', line 228

def local(name)
  @locals[name.to_sym]
end

#local_facts(name, bucket: nil) ⇒ Array[Analysis::FactStore::Fact]

Parameters:

  • name (String, Symbol)
  • bucket: (Symbol, nil) (defaults to: nil)

Returns:



744
745
746
# File 'lib/rigor/scope.rb', line 744

def local_facts(name, bucket: nil)
  facts_for(target: Analysis::FactStore::Target.local(name), bucket: bucket)
end

#local_origin(name) ⇒ Symbol?

ADR-82 WD1 — the propagated origin of the Dynamic value currently bound to a local / instance variable, or nil when none is tracked. Consulted by Inference::ProtectionScanner when a dispatch's receiver is a bare x / @x read whose own node carries no origin.

Parameters:

  • name (String, Symbol)

Returns:

  • (Symbol, nil)


211
# File 'lib/rigor/scope.rb', line 211

def local_origin(name) = @local_origins[name.to_sym]

#method_chain_narrowing(receiver_kind, receiver_name, method_name) ⇒ Type::t?

Closes the "stable receiver method-chain narrowing" gap (ROADMAP § Future cycles / Type-language / engine — "Method-call receiver narrowing across stable receivers"; 2026-05-28 Redmine survey). After if x.last.is_a?(Array) the dominated body's x.last reads MUST observe the truthy-narrowed type; the same chain reaching the falsey edge observes the negative narrowing.

Address shape mirrors #indexed_narrowing: stable root variable + no-arg single-hop method name. See ChainKey for the precise contract.

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • method_name (String, Symbol)

Returns:

  • (Type::t, nil)


714
715
716
# File 'lib/rigor/scope.rb', line 714

def method_chain_narrowing(receiver_kind, receiver_name, method_name)
  @method_chain_narrowings[chain_key(receiver_kind, receiver_name, method_name)]
end

#opaque_block_self?Boolean

True when this scope sits inside a block whose self is unmodelled (#entering_opaque_block).

Returns:

  • (Boolean)


293
# File 'lib/rigor/scope.rb', line 293

def opaque_block_self? = @opaque_block_self

#optimistic_ivar(name) ⇒ Symbol?

Parameters:

  • name (String, Symbol)

Returns:

  • (Symbol, nil)


194
# File 'lib/rigor/scope.rb', line 194

def optimistic_ivar(name) = @optimistic_ivars[name.to_sym]

#optimistic_local(name) ⇒ Symbol?

Parameters:

  • name (String, Symbol)

Returns:

  • (Symbol, nil)


193
# File 'lib/rigor/scope.rb', line 193

def optimistic_local(name) = @optimistic_locals[name.to_sym]

#param_inferred_typesHash[[String, Symbol, Symbol], Hash[Symbol, Type::t]]

ADR-67 WD3 — call-site-inferred parameter types, keyed by [class_name, method_name, kind]. build_method_entry_scope consults this to seed an undeclared def parameter with the union of its resolved call-site argument types (precision-additive; an RBS-declared parameter always wins). Empty unless a collection pass seeded it.

Returns:

  • (Hash[[String, Symbol, Symbol], Hash[Symbol, Type::t]])


59
60
61
# File 'lib/rigor/scope.rb', line 59

def param_inferred_types = @discovery.param_inferred_types
# ADR-84 WD2 — the per-run identity token the user-method return memo buckets on (nil outside runner-seeded
# scopes; the memo then falls back to the per-file `discovered_def_nodes` identity).

#program_globalsHash[Symbol, Type::t]

Returns:

  • (Hash[Symbol, Type::t])


41
# File 'lib/rigor/scope.rb', line 41

def program_globals = @discovery.program_globals

#record_dynamic_origin(node, cause) ⇒ Scope

Parameters:

  • node (Object)
  • cause (Symbol)

Returns:



123
124
125
126
# File 'lib/rigor/scope.rb', line 123

def record_dynamic_origin(node, cause)
  @dynamic_origins[node] = cause
  self
end

#record_optimistic_origin(node, cause) ⇒ Scope

Issue #286 — the Inference::OptimisticOrigin cause attached to a call node whose result is nil-free only because RbsDispatch reads past %a{implicitly-returns-nil}, or nil when the value's nil-freeness is a property of its class. Mirrors #dynamic_origins / #void_origins: advisory metadata, ignored by == / hash, and never varying a flow decision on its own.

Parameters:

  • node (Object)
  • cause (Symbol)

Returns:



188
189
190
191
# File 'lib/rigor/scope.rb', line 188

def record_optimistic_origin(node, cause)
  @optimistic_origins[node] = cause
  self
end

#record_void_origin(node, origin) ⇒ Scope

ADR-100 WD3 — records that the value introduced at node (a call node) is a top recovered from an author-declared -> void return, keyed by the origin site (origin, an Inference::VoidOrigin). The value-context check rule static.value-use.void consumes this table. Mirrors #record_dynamic_origin exactly: identity-keyed advisory metadata, mutated in place on the shared table (threaded by reference through #join / #rebuild), excluded from == / hash, so it never forks a flow-dedup or cache key.

Parameters:

Returns:



133
134
135
136
# File 'lib/rigor/scope.rb', line 133

def record_void_origin(node, origin)
  @void_origins[node] = origin
  self
end

#run_generationObject?

ADR-84 WD2 — the per-run identity token the user-method return memo buckets on (nil outside runner-seeded scopes; the memo then falls back to the per-file discovered_def_nodes identity).

Returns:

  • (Object, nil)


62
# File 'lib/rigor/scope.rb', line 62

def run_generation = @discovery.run_generation

#seed_declaration_sourced_ivar(name, type) ⇒ Scope

ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only provenance is the class-ivar index. Unlike with_ivar this binds the type AND records the declaration-sourced mark in one transition.

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



340
341
342
343
# File 'lib/rigor/scope.rb', line 340

def seed_declaration_sourced_ivar(name, type)
  rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
          declaration_sourced: add_declaration_sourced(:ivar, name))
end

#singleton_def_for(class_name, method_name) ⇒ Object?

Module-singleton call resolution (ADR-57 follow-up) — companion of #user_def_for for SINGLETON-side defs (def self.x, def Foo.x, class << self bodies, and module_function defs). Returns the Prism::DefNode for class_name.method_name invoked on the module/class constant itself, or nil. The discovered_def_nodes table is deliberately instance-side only (its ancestor walk binds self as Nominal), so singleton bodies live in a parallel table the ScopeIndexer populates alongside it. Records the same cross-file dependency edge as the instance path (ADR-46).

Parameters:

  • class_name (String, Symbol)
  • method_name (String, Symbol)

Returns:

  • (Object, nil)


490
491
492
493
494
495
# File 'lib/rigor/scope.rb', line 490

def singleton_def_for(class_name, method_name)
  table = @discovery.discovered_singleton_def_nodes[class_name.to_s]
  entry = table && table[method_name.to_sym] # live node or DefHandle (ADR-85 WD3)
  record_cross_file_method(class_name, method_name, entry, singleton: true) if Analysis::DependencyRecorder.active?
  Inference::DefNodeResolver.resolve(entry)
end

#struct_fold_safe?(name) ⇒ Boolean

True when name's Struct member reads are fold-safe in this body (the local is provably never mutated / aliased / escaped).

Parameters:

  • name (String, Symbol)

Returns:

  • (Boolean)


297
298
299
# File 'lib/rigor/scope.rb', line 297

def struct_fold_safe?(name)
  @struct_fold_safe_locals.include?(name.to_sym)
end

#struct_member_layout(class_name) ⇒ { members: Array[Symbol], keyword_init: bool }?

ADR-48 Struct follow-up — the { members:, keyword_init: } layout recorded for a Struct.new(...)-defined class, in the constant form (Point = Struct.new(:x, :y)) and the named-subclass form (class Point < Struct.new(:x, :y)). Consumed by Inference::MethodDispatcher::StructFolding so Point.new(...) on a Singleton[Point] receiver materialises a member instance. Returns nil when the class has no recorded struct layout. Mirrors #data_member_layout's dependency-recording contract.

Parameters:

  • class_name (String, Symbol)

Returns:

  • ({ members: Array[Symbol], keyword_init: bool }, nil)


634
635
636
637
638
# File 'lib/rigor/scope.rb', line 634

def struct_member_layout(class_name)
  layout = @discovery.struct_member_layouts[class_name.to_s]
  record_class_dependency(class_name) if layout && Analysis::DependencyRecorder.active?
  layout
end

#struct_member_layoutsHash[String, { members: Array[Symbol], keyword_init: bool }]

Returns:

  • (Hash[String, { members: Array[Symbol], keyword_init: bool }])


54
55
56
57
58
# File 'lib/rigor/scope.rb', line 54

def struct_member_layouts = @discovery.struct_member_layouts
# ADR-67 WD3 — call-site-inferred parameter types, keyed by `[class_name, method_name, kind]`.
# `build_method_entry_scope` consults this to seed an undeclared `def` parameter with the union of its resolved
# call-site argument types (precision-additive; an RBS-declared parameter always wins). Empty unless a
# collection pass seeded it.

#superclass_of(class_name) ⇒ String?

ADR-24 slice 2 — per-class table mapping a fully qualified user-class name to its superclass name AS WRITTEN at the class Foo < Bar declaration ("Bar", possibly a qualified "A::B"). Populated by ScopeIndexer — per-file plus the cross-file project pre-pass — and consumed by ExpressionTyper#try_user_method_inference to walk the superclass chain when an implicit-self call does not resolve against the enclosing class's own defs. The as-written name is resolved to a qualified class at walk time against the call's lexical nesting.

Parameters:

  • class_name (String, Symbol)

Returns:

  • (String, nil)


611
612
613
614
# File 'lib/rigor/scope.rb', line 611

def superclass_of(class_name)
  record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
  @discovery.discovered_superclasses[class_name.to_s]
end

#top_level_def_for(method_name) ⇒ Object?

v0.0.3 A — top-level def lookup for implicit-self calls. Returns the Prism::DefNode for a top-level (or DSL-block-nested, outside any class body) def <method_name> in the file, or nil. The sentinel key is owned by Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY; consumers should treat its presence as an opaque implementation detail and go through this accessor.

Parameters:

  • method_name (String, Symbol)

Returns:

  • (Object, nil)


522
523
524
525
526
527
# File 'lib/rigor/scope.rb', line 522

def top_level_def_for(method_name)
  table = @discovery.discovered_def_nodes[Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
  entry = table && table[method_name.to_sym] # live node or DefHandle (ADR-85 WD3)
  record_cross_file_toplevel(method_name, entry) if Analysis::DependencyRecorder.active?
  Inference::DefNodeResolver.resolve(entry)
end

#toplevel?Boolean

ADR-34 § "Decision" — predicate identifying a toplevel-shaped scope (no enclosing class / module body). True at the top of a file AND inside a top-level def body (since toplevel defs leave self_type nil per the existing scope-construction contract — the same nil-self_type signal ADR-24's self-call return adoption historically keyed on before ADR-57 opened the gate unconditionally). Used by CheckRules#unresolved_toplevel_diagnostic to gate the call.unresolved-toplevel rule so it fires only outside class / module bodies, where Rails-DSL metaprogramming leniency (ADR-24 WD3 → WD4) does not apply.

Returns:

  • (Boolean)


466
467
468
# File 'lib/rigor/scope.rb', line 466

def toplevel?
  @self_type.nil?
end

#type_of(node, tracer: nil) ⇒ Type::t

Parameters:

Returns:

  • (Type::t)


748
749
750
# File 'lib/rigor/scope.rb', line 748

def type_of(node, tracer: nil)
  Inference::ExpressionTyper.new(scope: self, tracer: tracer).type_of(node)
end

#user_def_for(class_name, method_name) ⇒ Object?

v0.0.2 #5 — per-class table mapping method_name (Symbol) → Prism::DefNode. Populated by ScopeIndexer alongside discovered_methods for instance-side defs only (singleton-side and define_method-introduced methods do not contribute a static body the engine can re-type). Consumed by ExpressionTyper to do inter-procedural return-type inference when the receiver class is user-defined and has no RBS sig.

Parameters:

  • class_name (String, Symbol)
  • method_name (String, Symbol)

Returns:

  • (Object, nil)


474
475
476
477
478
479
480
481
482
# File 'lib/rigor/scope.rb', line 474

def user_def_for(class_name, method_name)
  table = @discovery.discovered_def_nodes[class_name.to_s]
  # ADR-85 WD3 — the value is either a live `Prism::DefNode` (cold / re-walked file) or a `DefHandle`
  # (unchanged file, bundle-rebuilt index). Dependency recording keys on the table's PRESENCE (both are
  # truthy), so it is sound regardless of resolution; only the returned node is resolved lazily.
  entry = table && table[method_name.to_sym]
  record_cross_file_method(class_name, method_name, entry) if Analysis::DependencyRecorder.active?
  Inference::DefNodeResolver.resolve(entry)
end

#user_def_site_for(class_name, method_name) ⇒ String?

Companion to #user_def_for: returns the "path:line" where the project defines class_name#method_name (instance-side), or nil. Populated only by the cross-file project pre-pass (Inference::ScopeIndexer.discovered_def_index_for_paths) — a Prism::Location hides its source file, so the site is recorded at scan time. CheckRules#undefined_method_diagnostic consults this to name the defining file when a project monkey-patch on a core/stdlib/gem class is called cross-file, so the diagnostic can point at pre_eval: (ADR-17) instead of reading as a bare unresolved call.

Parameters:

  • class_name (String, Symbol)
  • method_name (String, Symbol)

Returns:

  • (String, nil)


591
592
593
594
595
596
597
598
599
600
601
602
603
# File 'lib/rigor/scope.rb', line 591

def user_def_site_for(class_name, method_name)
  table = @discovery.discovered_def_sources[class_name.to_s]
  site = table && table[method_name.to_sym]
  # ADR-88 WD3 — record the SAME instance-side cross-file method edge {#user_def_for} records at :378, so a
  # move / body-edit of `class_name#method_name`'s definition re-checks the consumer that named the
  # defining file. `CheckRules#undefined_method_diagnostic` reads this to set `project_definition_site`
  # (`"path:line"`) on a `call.undefined-method` for a project monkey-patch; without the edge, a line-shift
  # in the defining file left the cached diagnostic pointing at a stale line (the ADR-46 symbol-granularity
  # closure never re-checked the caller). Recording keys on the source-entry PRESENCE (truthy `site`),
  # sound whether or not the caller also went through {#user_def_for}.
  record_cross_file_method(class_name, method_name, site) if Analysis::DependencyRecorder.active?
  site
end

#user_method_return(def_node, receiver, arg_types) ⇒ Type::t?

ADR-89 WD2 — the inferred return type of def_node called with receiver / arg_types, computed against THIS scope's discovery index (so cross-file dispatches in the body resolve). The incremental session re-drives a declaration-stable changed callee at each previously-observed call key to prove its return is unchanged before skipping its symbol dependents.

Parameters:

  • def_node (Object)
  • receiver (Type::t)
  • arg_types (Array[Type::t])

Returns:

  • (Type::t, nil)


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

def user_method_return(def_node, receiver, arg_types)
  Inference::ExpressionTyper.new(scope: self).return_type_for(def_node, receiver, arg_types)
end

#with_cvar(name, type) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



402
403
404
# File 'lib/rigor/scope.rb', line 402

def with_cvar(name, type)
  rebuild(cvars: @cvars.merge(name.to_sym => type).freeze)
end

#with_declaration_sourced_local(name, type) ⇒ Scope

ADR-58 WD1 — a local assignment r = @right whose RHS is a pure read of a declaration-sourced ivar inherits the mark, so the survey's exact rotation/traversal shape (r = @right; r.key) does not fire. Binds the type and stamps the local's mark in one transition (the plain with_local would have dropped it).

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



348
349
350
351
# File 'lib/rigor/scope.rb', line 348

def with_declaration_sourced_local(name, type)
  written = with_local(name, type)
  written.with_local_declaration_mark(name)
end

#with_discovery(index) ⇒ Scope

ADR-53 Track A — swaps the whole discovery index in one transition. The sole seeding path; the per-table writers it replaced are derived off-Scope through scope.discovery.with(table_name: table).

Parameters:

Returns:



303
304
305
# File 'lib/rigor/scope.rb', line 303

def with_discovery(index)
  rebuild(discovery: index)
end

#with_fact(fact) ⇒ Scope

Parameters:

Returns:



256
257
258
# File 'lib/rigor/scope.rb', line 256

def with_fact(fact)
  rebuild(fact_store: fact_store.with_fact(fact))
end

#with_global(name, type) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



406
407
408
# File 'lib/rigor/scope.rb', line 406

def with_global(name, type)
  rebuild(globals: @globals.merge(name.to_sym => type).freeze)
end

#with_indexed_narrowing(receiver_kind, receiver_name, key, type) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • key (Object)
  • type (Type::t)

Returns:



685
686
687
688
689
690
# File 'lib/rigor/scope.rb', line 685

def with_indexed_narrowing(receiver_kind, receiver_name, key, type)
  new_table = @indexed_narrowings.merge(
    indexed_key(receiver_kind, receiver_name, key) => type
  ).freeze
  rebuild(indexed_narrowings: new_table)
end

#with_inferred_param_mark(name) ⇒ Scope

ADR-67 WD6b — stamp the "inferred, not declared" provenance mark on a parameter local seeded from the call-site parameter-inference table (Inference::ParameterInferenceCollector). Rides the ADR-58 WD1 declaration-sourced side-mark machinery under a distinct :inferred_param kind (never a carrier field, so the displayed type is unchanged) so the negative in-body rules can decline on a receiver / argument whose type is an open-call-site lower bound — firing against a lower bound is a false positive by construction (the ADR-67 WD1 reasoning at the parameter boundary, carried one hop into the body). The distinct kind keeps the inferred-param sites separable from ADR-58's ivar-copy :local mark, which a later un-guarding slice (WD6b) needs — and the two kinds behave OPPOSITELY on both axes: :local is dropped by with_local and intersected by join, while :inferred_param is sticky across with_local and unioned by join. See #without_inferred_param_mark below for the clearing contract, and docs/internal-spec/inference-engine.md § "Declaration-sourced provenance mark (ADR-58)" for the normative statement of both.

Parameters:

  • name (String, Symbol)

Returns:



378
379
380
# File 'lib/rigor/scope.rb', line 378

def with_inferred_param_mark(name)
  rebuild(declaration_sourced: add_declaration_sourced(:inferred_param, name))
end

#with_ivar(name, type) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/rigor/scope.rb', line 324

def with_ivar(name, type)
  new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
  new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
  # ADR-58 WD1 — a method-local ivar write or narrowing is flow-live: drop any declaration-sourced mark so
  # subsequent reads of `@name` observe flow-live provenance and fire as before. The seed path uses
  # `seed_declaration_sourced_ivar` to (re-)establish the mark.
  rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
          indexed_narrowings: new_indexed_narrowings,
          method_chain_narrowings: new_chain_narrowings,
          declaration_sourced: drop_declaration_sourced_for(:ivar, name),
          ivar_origins: drop_origin(@ivar_origins, name),
          optimistic_ivars: drop_origin(@optimistic_ivars, name))
end

#with_ivar_origin(name, cause) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • cause (Symbol, nil)

Returns:



222
223
224
225
226
# File 'lib/rigor/scope.rb', line 222

def with_ivar_origin(name, cause)
  return self if cause.nil?

  rebuild(ivar_origins: @ivar_origins.merge(name.to_sym => cause).freeze)
end

#with_local(name, type) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • type (Type::t)

Returns:



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/rigor/scope.rb', line 232

def with_local(name, type)
  # `rigor trace` — the moment a local enters the scope.
  Inference::FlowTracer.bind(name, type) if Inference::FlowTracer.active?
  new_locals = @locals.merge(name.to_sym => type).freeze
  new_fact_store = fact_store.invalidate_target(Analysis::FactStore::Target.local(name))
  # Rebinding `name` invalidates every "after `receiver[key] ||= default`" narrowing keyed on it — the slot at
  # `name[*]` is reachable through the old binding only, so the next read against the new binding does not
  # inherit the earlier non-nil guarantee. The same logic applies to method-chain narrowings: `x.last` after
  # `x = something_new` is a call on the new binding and any prior `is_a?`-driven narrowing keyed on
  # `(local, :x, :last)` no longer holds.
  new_indexed_narrowings = drop_indexed_narrowings_for(:local, name)
  new_chain_narrowings = drop_chain_narrowings_for(:local, name)
  # ADR-58 WD1 — rebinding a local is a flow-live touch: any prior declaration-sourced mark on `name` no
  # longer holds (the new value may carry a method-local nil). `with_declaration_sourced_local` re-establishes
  # the mark afterward when the RHS is a pure copy of a declaration-sourced ivar read; the default is to drop
  # it.
  rebuild(locals: new_locals, fact_store: new_fact_store,
          indexed_narrowings: new_indexed_narrowings,
          method_chain_narrowings: new_chain_narrowings,
          declaration_sourced: drop_declaration_sourced_for(:local, name),
          local_origins: drop_origin(@local_origins, name),
          optimistic_locals: drop_origin(@optimistic_locals, name))
end

#with_local_declaration_mark(name) ⇒ Scope

ADR-58 WD1 — re-stamp the local mark on a scope produced by with_local (which always drops it). Public so the sibling with_declaration_sourced_local can call it across the new post-write receiver without reaching into a private method.

Parameters:

  • name (String, Symbol)

Returns:



356
357
358
# File 'lib/rigor/scope.rb', line 356

def with_local_declaration_mark(name)
  rebuild(declaration_sourced: add_declaration_sourced(:local, name))
end

#with_local_origin(name, cause) ⇒ Scope

Records the cause of the Dynamic value being bound to name. A nil cause is a no-op (the common case — most bindings are concrete or have no recorded origin), so callers need not pre-check.

Parameters:

  • name (String, Symbol)
  • cause (Symbol, nil)

Returns:



216
217
218
219
220
# File 'lib/rigor/scope.rb', line 216

def with_local_origin(name, cause)
  return self if cause.nil?

  rebuild(local_origins: @local_origins.merge(name.to_sym => cause).freeze)
end

#with_method_chain_narrowing(receiver_kind, receiver_name, method_name, type) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • method_name (String, Symbol)
  • type (Type::t)

Returns:



718
719
720
721
722
723
# File 'lib/rigor/scope.rb', line 718

def with_method_chain_narrowing(receiver_kind, receiver_name, method_name, type)
  new_table = @method_chain_narrowings.merge(
    chain_key(receiver_kind, receiver_name, method_name) => type
  ).freeze
  rebuild(method_chain_narrowings: new_table)
end

#with_optimistic_ivar(name, cause) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • cause (Symbol, nil)

Returns:



202
203
204
205
206
# File 'lib/rigor/scope.rb', line 202

def with_optimistic_ivar(name, cause)
  return self if cause.nil?

  rebuild(optimistic_ivars: @optimistic_ivars.merge(name.to_sym => cause).freeze)
end

#with_optimistic_local(name, cause) ⇒ Scope

Parameters:

  • name (String, Symbol)
  • cause (Symbol, nil)

Returns:



196
197
198
199
200
# File 'lib/rigor/scope.rb', line 196

def with_optimistic_local(name, cause)
  return self if cause.nil?

  rebuild(optimistic_locals: @optimistic_locals.merge(name.to_sym => cause).freeze)
end

#with_self_type(type) ⇒ Scope

Slice A-engine. Returns a scope with self_type set to type, preserving locals and facts. StatementEvaluator injects this at class-body and method-body boundaries; ExpressionTyper consults it when typing Prism::SelfNode and implicit-self Prism::CallNode receivers.

Parameters:

  • type (Type::t, nil)

Returns:



263
264
265
# File 'lib/rigor/scope.rb', line 263

def with_self_type(type)
  rebuild(self_type: type)
end

#with_source_path(path) ⇒ Scope

ADR-28 / ADR-52 slice 5a — per-file source path carried on the scope. The analyzer stamps the current file's path onto the seed scope; nested rebuilds propagate it so plugin rules (dynamic_return's file_methods: gate, sigil checks) can resolve "which file does this call site belong to?" without thread-locals.

Parameters:

  • path (String, nil)

Returns:



270
271
272
# File 'lib/rigor/scope.rb', line 270

def with_source_path(path)
  rebuild(source_path: path)
end

#with_struct_fold_safe(locals) ⇒ Scope

ADR-48 Struct slice 3 — installs the per-body fold-safe-local set (Inference::StructFoldSafety). Set once at body entry; inherited unchanged through subsequent flow transitions.

Parameters:

  • locals (Set[Symbol])

Returns:



276
277
278
# File 'lib/rigor/scope.rb', line 276

def with_struct_fold_safe(locals)
  rebuild(struct_fold_safe_locals: locals)
end

#without_indexed_narrowing(receiver_kind, receiver_name, key) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • key (Object)

Returns:



692
693
694
695
696
697
698
# File 'lib/rigor/scope.rb', line 692

def without_indexed_narrowing(receiver_kind, receiver_name, key)
  lookup = indexed_key(receiver_kind, receiver_name, key)
  return self unless @indexed_narrowings.key?(lookup)

  new_table = @indexed_narrowings.reject { |k, _| k == lookup }.freeze
  rebuild(indexed_narrowings: new_table)
end

#without_indexed_narrowings_for(receiver_kind, receiver_name) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)

Returns:



700
701
702
703
704
705
# File 'lib/rigor/scope.rb', line 700

def without_indexed_narrowings_for(receiver_kind, receiver_name)
  new_table = drop_indexed_narrowings_for(receiver_kind, receiver_name)
  return self if new_table.equal?(@indexed_narrowings)

  rebuild(indexed_narrowings: new_table)
end

#without_inferred_param_mark(name) ⇒ Scope

ADR-67 WD6b — explicitly clear the inferred-parameter taint on name. The mark is deliberately STICKY: with_local (used by both narrowing and reassignment) does NOT drop it, so it survives the narrowing / join transitions between a lower-bound value's definition and its use (v = param[i]-1; if 0<=v and v<n — the and narrows v between the two comparisons). It is cleared only at a genuine source-level local write whose RHS does not derive from an inferred parameter (StatementEvaluator#eval_local_write), so a local rebound to an independent value stops being treated as a lower bound. Over-retention (a mark that outlives a rebind the write-path did not catch) only ever suppresses a diagnostic — the FP-safe direction — so stickiness is the conservative choice. Zero-alloc when no mark is present.

Parameters:

  • name (String, Symbol)

Returns:



390
391
392
393
# File 'lib/rigor/scope.rb', line 390

def without_inferred_param_mark(name)
  dropped = drop_declaration_sourced_for(:inferred_param, name)
  dropped.equal?(@declaration_sourced) ? self : rebuild(declaration_sourced: dropped)
end

#without_method_chain_narrowing(receiver_kind, receiver_name, method_name) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)
  • method_name (String, Symbol)

Returns:



725
726
727
728
729
730
731
# File 'lib/rigor/scope.rb', line 725

def without_method_chain_narrowing(receiver_kind, receiver_name, method_name)
  lookup = chain_key(receiver_kind, receiver_name, method_name)
  return self unless @method_chain_narrowings.key?(lookup)

  new_table = @method_chain_narrowings.reject { |k, _| k == lookup }.freeze
  rebuild(method_chain_narrowings: new_table)
end

#without_method_chain_narrowings_for(receiver_kind, receiver_name) ⇒ Scope

Parameters:

  • receiver_kind (Symbol)
  • receiver_name (String, Symbol)

Returns:



733
734
735
736
737
738
# File 'lib/rigor/scope.rb', line 733

def without_method_chain_narrowings_for(receiver_kind, receiver_name)
  new_table = drop_chain_narrowings_for(receiver_kind, receiver_name)
  return self if new_table.equal?(@method_chain_narrowings)

  rebuild(method_chain_narrowings: new_table)
end