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
Defined Under Namespace
Classes: MethodEffectResolver
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
-
.accumulate_ivar_type(accumulator, class_name, ivar_name, type) ⇒ Object
Unions ‘type` into the class-ivar accumulator for `(class_name, ivar_name)`.
-
.accumulate_project_index(acc, path, root) ⇒ Object
Folds one file’s class-keyed indexes into the cross-file accumulator.
-
.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).
-
.always_raises?(node) ⇒ Boolean
True when ‘node` (a single statement or its last statement) is an unconditional `raise`/`fail` call that always terminates the path — used to treat raise-terminated branches as non-completing (they never observe the seed nil).
-
.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.
- .apply_named_visibility(args, qualified_prefix, visibility, accumulator) ⇒ Object
-
.apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator) ⇒ Object
Recognises modifier calls on the implicit-self receiver inside a class body.
- .bare_module_function?(node) ⇒ Boolean
-
.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).
-
.branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting) ⇒ Boolean
True when a branch body (a StatementsNode / single node) definitely assigns ‘target` non-nil on every path that completes the method through it, OR terminates every path by raise (vacuously safe — no completing path observes the seed nil).
-
.build_class_cvar_index(root, default_scope) ⇒ Object
Slice 7 phase 6 — class-cvar pre-pass.
-
.build_class_ivar_index(root, default_scope) ⇒ Object
Slice 7 phase 2.
-
.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)`).
-
.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.
-
.build_discovered_def_nodes(root) ⇒ Object
v0.0.2 #5 — instance-side def-node recording.
-
.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`).
-
.build_discovered_method_visibilities(root) ⇒ Object
v0.1.2 — per-class method-visibility table for the ‘def.method-visibility-mismatch` CheckRule.
-
.build_discovered_methods(root) ⇒ Object
Slice 7 phase 12 — in-source method discovery pre-pass.
-
.build_discovered_singleton_def_nodes(root) ⇒ Object
Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of ‘build_discovered_def_nodes`.
-
.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.
-
.build_in_source_constants(root, default_scope) ⇒ Object
Slice 7 phase 9 — in-source constant value pre-pass.
-
.build_method_assign_effects(root) ⇒ Object
WD3 — builds the per-class definite-assignment summary ‘=> {method_name => Set<ivar names assigned non-nil on every completing path>}`.
-
.build_program_global_index(root, default_scope) ⇒ Object
Slice 7 phase 6 — program-global pre-pass.
-
.case_assignment_outcome(node, target, class_name, effects, depth, visiting) ⇒ Object
‘case` is a definite assignment only when there is a real `else` clause AND every `when`/`in` body plus the else body definitely assigns (or raises-out).
- .class_matches_constraint?(class_name, constraint, environment) ⇒ Boolean
-
.class_new_call?(node) ⇒ Boolean
Recognises ‘Class.new`, `Class.new(super_class)`, and the block form `Class.new { … }`.
-
.class_new_superclass_name(call_node, qualified_prefix, accumulator) ⇒ Object
Lexically-qualified name of a ‘Class.new(Super)` superclass argument, or nil when there is no positional superclass (a bare `Class.new` / `Module.new`).
-
.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.
-
.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.
-
.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.
-
.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`.
-
.collect_class_method_defs(root, prefix = [], acc = {}) ⇒ Object
Collects ‘=> {method_name => DefNode}` for every instance-method def in the program.
- .collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator) ⇒ Object
-
.collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil, method_assign_effects = nil) ⇒ Object
rubocop:disable Metrics/ParameterLists.
- .collect_defined_test_ivars(node, names) ⇒ Object
- .collect_nil_test_ivars(node, names) ⇒ Object
-
.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.
- .collect_truthy_test_ivars(node, names) ⇒ Object
-
.conditional_assignment_outcome(node, target, class_name, effects, depth, visiting) ⇒ Object
‘if`/`unless` is a definite assignment of `target` only when BOTH the then and else arms definitely assign (or raise-out).
-
.contribute_read_before_write_nil!(accumulator, read_before_write, init_writes) ⇒ Object
B2.3 — finalize the read-before-write nil contribution.
-
.data_define_call?(node) ⇒ Boolean
Recognises ‘Data.define(*Symbol)` and `Data.define(*Symbol) do …
- .dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil) ⇒ Object
- .decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:) ⇒ Object
-
.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).
-
.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).
-
.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`).
- .detect_read_before_write(node, seen_writes, read_first) ⇒ Object
-
.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`.
-
.discovered_def_index_for_paths(paths, buffer: nil) ⇒ Hash{Symbol => Hash}
ADR-24 slice 2 — cross-file companion to ‘discovered_classes_for_paths`.
- .falsey_constant?(type) ⇒ Boolean
- .gather_cvar_writes(node, scope, class_name, accumulator) ⇒ Object
- .gather_global_writes(node, scope, accumulator) ⇒ Object
- .gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, mutated_ivars = nil, dead_writes = nil) ⇒ Object
-
.index(root, default_scope:, converged_loop_recording: false) ⇒ Hash{Prism::Node => Rigor::Scope}
Build the scope index for a Prism program subtree.
-
.ivar_write_targets(node, acc = Set.new) ⇒ Object
Every ivar this body assigns a non-nil value to ANYWHERE (the candidate set for the method’s definite-assignment scan).
- .literal_method_name(node) ⇒ Object
-
.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).
-
.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).
-
.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.
- .meta_call_with_name?(node, receiver_name, method_name) ⇒ Boolean
- .meta_constant_receiver?(node, expected_name) ⇒ Boolean
-
.meta_member_names(call_node) ⇒ Object
The Symbol member names of a ‘Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call.
-
.meta_new_block_body(node) ⇒ Object
v0.1.2 — when a ‘Const = Data.define(*sym) do …
-
.meta_new_constant_type(node, full) ⇒ Object
Survey item (e): when the rvalue is a recognised ‘Module.new do …
-
.method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth) ⇒ Object
Computes the definite-assignment set for one method, memoised per def node.
-
.module_function_toggle?(node) ⇒ Boolean
A bare ‘module_function` (no arguments) flips every following `def` in the module body to module-function (instance + singleton) mode.
-
.module_new_call?(node) ⇒ Boolean
Recognises ‘Module.new` and `Module.new(&block)` / `Module.new do …
-
.multi_write_slot_type(elements, index) ⇒ Object
The per-slot type for index ‘i` of a tuple RHS.
- .nil_literal_value?(node) ⇒ Boolean
-
.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).
- .propagate_if_branches(node, table, current_scope) ⇒ Object
- .propagate_unless_branches(node, table, current_scope) ⇒ Object
- .qualified_name_for(constant_path_node) ⇒ Object
- .record_alias_map_entry(alias_node, qualified_prefix, accumulator) ⇒ Object
-
.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.
- .record_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
-
.record_class_new_constant_decl(node, qualified_prefix, accumulator) ⇒ Object
T1 (template-corpora survey) — record a ‘Const = Class.new(Super)` (and the bare `Class.new` / `Module.new`) class-creating constant in the cross-file discovery table so a reference to `Const` from ANOTHER file under the same namespace resolves to the project class instead of falling through to a core same-named class (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling file’s ‘rescue SyntaxError => e`, which otherwise resolved to core `::SyntaxError`).
- .record_class_or_module?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean
-
.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.
- .record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name) ⇒ Object
- .record_cvar_write(node, scope, class_name, accumulator) ⇒ Object
-
.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.
- .record_declarations(node, qualified_prefix, identity_table, discovered) ⇒ Object
- .record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
- .record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
-
.record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator) ⇒ Object
rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize.
- .record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
- .record_global_write(node, scope, accumulator) ⇒ Object
-
.record_ivar_mutator_call(node, class_name, mutated_ivars) ⇒ Object
Records ‘@ivar.<method>(…)` calls whose method is in `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
- .record_ivar_write(node, scope, class_name, accumulator, guarded: false) ⇒ Object
-
.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.
-
.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.
- .record_mixin_call(node, current_class, accumulator) ⇒ Object
-
.record_module_function_names(node, qualified_prefix, body, accumulator) ⇒ Object
‘module_function :a, :b` retro-marks named siblings (defined earlier OR later in the same body) as module-functions.
- .record_multi_ivar_rest(splat_node, _type, class_name, accumulator) ⇒ Object
- .record_multi_ivar_target(target, type, class_name, accumulator) ⇒ Object
-
.record_multi_target_ivars(node, rhs_type, class_name, accumulator) ⇒ Object
Walks a ‘MultiWriteNode` / `MultiTargetNode` target tree against `rhs_type`, recording ivar targets per slot.
-
.record_multi_write_ivars(node, scope, class_name, accumulator) ⇒ Object
N1 — records each ‘InstanceVariableTargetNode` of a `MultiWriteNode` (parallel / multiple assignment) into the class-ivar union, with the best cheap per-slot type.
- .record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator) ⇒ Object
- .render_constant_path(node) ⇒ Object
-
.singleton_class_prefix(node, qualified_prefix) ⇒ Object
Resolves a ‘class << X` body’s qualified prefix.
-
.statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting) ⇒ Object
Classifies a single statement’s effect on ‘target`: :assigned — every path through the statement that continues OR returns assigns `target` non-nil (suffix is done); :terminates_unassigned — the statement ends the method (return/raise) on some path without a definite assignment, so a completing path escaped; :falls_through_unassigned — control may continue past it without the assignment (keep scanning the suffix).
-
.statements_of(body) ⇒ Object
Direct statement children of a class/module body node (a ‘Prism::StatementsNode`, a `Prism::BeginNode` wrapping one, or a lone statement).
-
.struct_new_call?(node) ⇒ Boolean
Recognises ‘Struct.new(*Symbol)` and `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write rvalue position.
- .struct_new_positionals(args) ⇒ Object
-
.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.
-
.suffix_definitely_assigns?(statements, from, target, class_name, effects) ⇒ Boolean
True when, starting from ‘statements`, EVERY path that completes the method (falls off the end OR hits an early `return`) definitely assigns `target` a non-nil value first.
-
.suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth) ⇒ Boolean
Build-time variant of ‘suffix_definitely_assigns?` that resolves same-class calls through the lazy `resolver` (which recurses into `method_definite_assigns` for not-yet-computed callees) rather than the finished flat table.
-
.symbol_argument_name(arg) ⇒ Object
The Symbol value of a ‘:name` / `“name”` literal argument, or nil.
-
.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.
- .top_level_statements(body) ⇒ Object
-
.unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting) ⇒ Boolean
True when ‘call` is an unconditional, statement-level, implicit-self (or `self.`) call to a SAME-CLASS method whose definite-assignment summary includes `target`.
- .visibility_target_name(arg) ⇒ Object
- .walk_class_cvars(node, qualified_prefix, default_scope, accumulator) ⇒ Object
- .walk_class_includes(node, qualified_prefix, current_class, accumulator) ⇒ Object
-
.walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil, method_assign_effects = nil) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists.
- .walk_class_superclasses(node, qualified_prefix, accumulator) ⇒ Object
-
.walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil, dead_writes = 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.
- .walk_constant_writes(node, qualified_prefix, default_scope, accumulator) ⇒ Object
- .walk_data_member_layouts(node, qualified_prefix, accumulator) ⇒ Object
- .walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
-
.walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize.
-
.walk_methods(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity.
-
.walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
Walks a class/module/singleton-class body’s direct statements in source order, threading the bare-‘module_function` toggle: once a bare `module_function` is seen, every subsequent `def` in the body registers as a singleton method.
-
.walk_singleton_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
Walks every node, entering class/module/singleton-class bodies via #walk_singleton_body so a bare ‘module_function` toggle threads correctly across the body’s sibling statements (a child-by-child recursion would reset it).
- .widen_member_for_observed_mutators(member, observed_methods) ⇒ Object
-
.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.
-
.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 Method Details
.accumulate_ivar_type(accumulator, class_name, ivar_name, type) ⇒ Object
Unions ‘type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and multi-write (parallel-assignment) collectors.
1138 1139 1140 1141 1142 1143 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1138 def accumulate_ivar_type(accumulator, class_name, ivar_name, type) accumulator[class_name] ||= {} existing = accumulator[class_name][ivar_name] accumulator[class_name][ivar_name] = existing ? Type::Combinator.union(existing, type) : type end |
.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.
2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2295 def accumulate_project_index(acc, path, root) merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root) build_discovered_singleton_def_nodes(root).each do |class_name, methods| (acc[:singleton_def_nodes][class_name] ||= {}).merge!(methods) end 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).
539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 |
# File 'lib/rigor/inference/scope_indexer.rb', line 539 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 |
.always_raises?(node) ⇒ Boolean
True when ‘node` (a single statement or its last statement) is an unconditional `raise`/`fail` call that always terminates the path — used to treat raise-terminated branches as non-completing (they never observe the seed nil).
1081 1082 1083 1084 1085 1086 1087 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1081 def always_raises?(node) node = top_level_statements(node).last if node.is_a?(Prism::StatementsNode) return false unless node.is_a?(Prism::CallNode) return false unless node.receiver.nil? %i[raise fail].include?(node.name) 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.
2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2085 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
2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2048 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.
2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2034 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 |
.bare_module_function?(node) ⇒ Boolean
1765 1766 1767 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1765 def (node) node.arguments.nil? || node.arguments.arguments.empty? 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?`.
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 |
# File 'lib/rigor/inference/scope_indexer.rb', line 478 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 |
.branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting) ⇒ Boolean
True when a branch body (a StatementsNode / single node) definitely assigns ‘target` non-nil on every path that completes the method through it, OR terminates every path by raise (vacuously safe — no completing path observes the seed nil). Returns false if any path can complete/return without the assignment.
1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1031 def branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting) stmts = top_level_statements(branch) return false if stmts.empty? stmts.each do |stmt| outcome = statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting) return true if outcome == :assigned return false if outcome == :terminates_unassigned end # Reached the end of the branch without a definite assignment; # safe only if the branch's last statement always raises (no # completing path falls out of it). always_raises?(stmts.last) 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`.
1243 1244 1245 1246 1247 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1243 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`.
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/rigor/inference/scope_indexer.rb', line 218 def build_class_ivar_index(root, default_scope) accumulator = {} mutated_ivars = {} read_before_write = {} init_writes = {} # WD3 — per-class summary of `{class_name => {method_name => # Set<ivar names definitely assigned non-nil on every # completing path>}}`, consulted by `dead_transient_nil_writes` # so a ctor that reassigns `@x` indirectly through an # unconditional same-class method call (`mask!`) credits the # overwrite. Built once per program here, memoised by class. method_assign_effects = build_method_assign_effects(root) walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars, read_before_write, init_writes, method_assign_effects) 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.
1843 1844 1845 1846 1847 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1843 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.
2442 2443 2444 2445 2446 2447 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2442 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.
1596 1597 1598 1599 1600 1601 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1596 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).
1898 1899 1900 1901 1902 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1898 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.
1957 1958 1959 1960 1961 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1957 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]`.
1392 1393 1394 1395 1396 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1392 def build_discovered_methods(root) accumulator = {} walk_methods(root, [], false, accumulator) accumulator.transform_values(&:freeze).freeze end |
.build_discovered_singleton_def_nodes(root) ⇒ Object
Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of ‘build_discovered_def_nodes`. Records the `Prism::DefNode` for every singleton-side method (`def self.x`, `def Foo.x`, a `class << self` body, and a `module_function` method) keyed by qualified class/module name → method → node, so `ExpressionTyper` can re-type the body when a `Singleton` receiver dispatches `Foo.x`. The instance-side table is kept singleton-free on purpose (its ancestor walk binds `self` as `Nominal`), so the two never overlap except for `module_function` defs, which are genuinely callable on both sides and so appear in both tables. Top-level singleton defs (`def self.x` outside any class — `self` is `main`) are not recorded; they have no constant receiver to dispatch through.
1667 1668 1669 1670 1671 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1667 def build_discovered_singleton_def_nodes(root) accumulator = {} walk_singleton_def_nodes(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`.
1802 1803 1804 1805 1806 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1802 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.
1329 1330 1331 1332 1333 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1329 def build_in_source_constants(root, default_scope) accumulator = {} walk_constant_writes(root, [], default_scope, accumulator) accumulator.freeze end |
.build_method_assign_effects(root) ⇒ Object
WD3 — builds the per-class definite-assignment summary ‘=> {method_name => Set<ivar names assigned non-nil on every completing path>}`. Used so a ctor’s ‘dead_transient_nil_writes` can credit an indirect overwrite through an unconditionally-called same-class method (ipaddr’s ‘initialize` reassigns `@mask_addr` via `mask!`).
Each method’s set is computed by the same suffix definite-assignment analysis used for the ctor seed, run from the method body’s first statement for every ivar the method writes anywhere. Same-class calls inside a method are credited transitively (depth-capped, cycle-guarded) so the resulting FLAT table is correct at depth 0 for the ctor lookup.
822 823 824 825 826 827 828 829 830 831 832 833 |
# File 'lib/rigor/inference/scope_indexer.rb', line 822 def build_method_assign_effects(root) defs = collect_class_method_defs(root) effects = {} memo = {}.compare_by_identity defs.each do |class_name, methods| methods.each do |method_name, def_node| assigns = method_definite_assigns(class_name, method_name, def_node, defs, effects, memo, 0) (effects[class_name] ||= {})[method_name] = assigns unless assigns.empty? end end effects.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.
1301 1302 1303 1304 1305 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1301 def build_program_global_index(root, default_scope) accumulator = {} gather_global_writes(root, default_scope, accumulator) accumulator.freeze end |
.case_assignment_outcome(node, target, class_name, effects, depth, visiting) ⇒ Object
‘case` is a definite assignment only when there is a real `else` clause AND every `when`/`in` body plus the else body definitely assigns (or raises-out). A missing else lets an unmatched subject fall through unassigned.
1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1065 def case_assignment_outcome(node, target, class_name, effects, depth, visiting) else_clause = node.else_clause return :falls_through_unassigned unless else_clause.is_a?(Prism::ElseNode) branches = node.conditions.map { |c| c.respond_to?(:statements) ? c.statements : nil } branches << else_clause.statements all_ok = branches.all? do |b| branch_definitely_assigns?(b, target, class_name, effects, depth, visiting) end all_ok ? :assigned : :falls_through_unassigned end |
.class_matches_constraint?(class_name, constraint, environment) ⇒ Boolean
556 557 558 559 560 561 562 563 564 |
# File 'lib/rigor/inference/scope_indexer.rb', line 556 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.
2553 2554 2555 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2553 def class_new_call?(node) (node, :Class, :new) end |
.class_new_superclass_name(call_node, qualified_prefix, accumulator) ⇒ Object
Lexically-qualified name of a ‘Class.new(Super)` superclass argument, or nil when there is no positional superclass (a bare `Class.new` / `Module.new`). When the unqualified super name is a class already discovered under an enclosing-prefix segment, the qualified form is returned (so `Class.new(Error)` inside `module M` resolves to `M::Error`); otherwise the literal name is returned (covering a core / RBS-known superclass spelled bare).
2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2416 def class_new_superclass_name(call_node, qualified_prefix, accumulator) arg = call_node.arguments&.arguments&.first return nil if arg.nil? raw = qualified_name_for(arg) return nil if raw.nil? prefix = qualified_prefix.dup until prefix.empty? candidate = (prefix + [raw]).join("::") return candidate if accumulator.key?(candidate) prefix.pop end raw 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.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 |
# File 'lib/rigor/inference/scope_indexer.rb', line 456 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.
2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2102 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).
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 |
# File 'lib/rigor/inference/scope_indexer.rb', line 583 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`.
2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2362 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 when Prism::ConstantWriteNode record_class_new_constant_decl(node, qualified_prefix, accumulator) end node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) } end |
.collect_class_method_defs(root, prefix = [], acc = {}) ⇒ Object
Collects ‘=> {method_name => DefNode}` for every instance-method def in the program. Singleton defs (`def self.x`) are excluded — the ctor-call crediting only follows instance-method calls on `self`. Last def wins on redefinition.
839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 |
# File 'lib/rigor/inference/scope_indexer.rb', line 839 def collect_class_method_defs(root, prefix = [], acc = {}) return acc unless root.is_a?(Prism::Node) case root when Prism::ClassNode, Prism::ModuleNode name = qualified_name_for(root.constant_path) if name && root.body child = prefix + [name] collect_class_method_defs(root.body, child, acc) end return acc when Prism::DefNode (acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver return acc end root.compact_child_nodes.each { |c| collect_class_method_defs(c, prefix, acc) } acc end |
.collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator) ⇒ Object
1270 1271 1272 1273 1274 1275 1276 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1270 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, method_assign_effects = nil) ⇒ Object
rubocop:disable Metrics/ParameterLists
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 |
# File 'lib/rigor/inference/scope_indexer.rb', line 404 def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists read_before_write = nil, init_writes = nil, method_assign_effects = 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) # C2 — transient `@x = nil` dead-write elimination. When a # method body opens with an unconditional `@x = nil` # (defensive init) and then *definitely* reassigns `@x` to a # non-nil value on every completing path (a later # unconditional statement-level write, OR an `if/else` whose # both branches write `@x`), the opening nil is dead — it can # never be observed at method exit. Recording it anyway folds # a spurious `nil` constituent into the flow-insensitive # class-ivar union, which then poisons reads in OTHER methods # (e.g. ipaddr `IN4MASK ^ @mask_addr` rejects the resulting # `Integer | nil`). The set holds the `object_id`s of the # transient write nodes to skip; soundness is post-domination # at the top statement level, so dropping the nil never hides # a real runtime-nil read. dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects) gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes) # 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
753 754 755 756 757 758 759 760 761 762 763 764 |
# File 'lib/rigor/inference/scope_indexer.rb', line 753 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
766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 |
# File 'lib/rigor/inference/scope_indexer.rb', line 766 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.
503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 |
# File 'lib/rigor/inference/scope_indexer.rb', line 503 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
741 742 743 744 745 746 747 748 749 750 751 |
# File 'lib/rigor/inference/scope_indexer.rb', line 741 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 |
.conditional_assignment_outcome(node, target, class_name, effects, depth, visiting) ⇒ Object
‘if`/`unless` is a definite assignment of `target` only when BOTH the then and else arms definitely assign (or raise-out). A missing else arm means the fall-through path skips the assignment -> not definite. Modifier-form `if`/`unless` (no else, single predicate’d statement) likewise.
1051 1052 1053 1054 1055 1056 1057 1058 1059 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1051 def conditional_assignment_outcome(node, target, class_name, effects, depth, visiting) else_branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause return :falls_through_unassigned unless else_branch.is_a?(Prism::ElseNode) return :falls_through_unassigned unless node.statements then_ok = branch_definitely_assigns?(node.statements, target, class_name, effects, depth, visiting) else_ok = branch_definitely_assigns?(else_branch.statements, target, class_name, effects, depth, visiting) then_ok && else_ok ? :assigned : :falls_through_unassigned 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.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/rigor/inference/scope_indexer.rb', line 252 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.
2506 2507 2508 2509 2510 2511 2512 2513 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2506 def data_define_call?(node) return false unless node.is_a?(Prism::CallNode) return false unless node.name == :define return false unless (node.receiver, :Data) args = node.arguments&.arguments || [] args.all?(Prism::SymbolNode) end |
.dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil) ⇒ Object
930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 |
# File 'lib/rigor/inference/scope_indexer.rb', line 930 def dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil) statements = top_level_statements(body) return nil if statements.length < 2 dead = nil statements.each_with_index do |stmt, i| next unless stmt.is_a?(Prism::InstanceVariableWriteNode) && nil_literal_value?(stmt.value) # The opening `@x = nil` is dead when every completing path # of the SUFFIX after it (normal end OR early `return`, # never a `raise`-terminated path) definitely reassigns # `@x` non-nil. The suffix analysis credits an # unconditionally-called same-class method's own definite # assignments via `method_assign_effects`. if suffix_definitely_assigns?(statements, i + 1, stmt.name, class_name, method_assign_effects) (dead ||= Set.new) << stmt.object_id end end dead end |
.decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:) ⇒ Object
1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1179 def decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:) if rhs_type.is_a?(Type::Tuple) elements = rhs_type.elements fronts = Array.new(front_count) { |i| multi_write_slot_type(elements, i) } if rest_present middle_end = [elements.size - back_count, front_count].max backs = Array.new(back_count) { |i| multi_write_slot_type(elements, middle_end + i) } [fronts, Type::Combinator.untyped, backs] else backs = Array.new(back_count) { |i| multi_write_slot_type(elements, front_count + i) } [fronts, nil, backs] end else # Unanalyzable / non-tuple RHS: every slot is the unknown floor. floor = Type::Combinator.untyped [Array.new(front_count) { floor }, rest_present ? floor : nil, Array.new(back_count) { floor }] end 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).
1401 1402 1403 1404 1405 1406 1407 1408 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1401 def deep_merge_class_methods(base, ) return if base.nil? || base.empty? return base if .empty? base.merge() do |_class_name, base_methods, | base_methods.merge() 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.
1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1569 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.
1559 1560 1561 1562 1563 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1559 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
599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 |
# File 'lib/rigor/inference/scope_indexer.rb', line 599 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.
2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2213 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.
2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2253 def discovered_def_index_for_paths(paths, buffer: nil) acc = { def_nodes: {}, singleton_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 singleton_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
1232 1233 1234 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1232 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
1278 1279 1280 1281 1282 1283 1284 1285 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1278 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
1307 1308 1309 1310 1311 1312 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1307 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, dead_writes = nil) ⇒ Object
624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 |
# File 'lib/rigor/inference/scope_indexer.rb', line 624 def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, mutated_ivars = nil, dead_writes = nil) return unless node.is_a?(Prism::Node) if node.is_a?(Prism::InstanceVariableWriteNode) && !(dead_writes && dead_writes.include?(node.object_id)) record_ivar_write(node, scope, class_name, accumulator, guarded: guarded_ivars.include?(node.name)) end # N1 — parallel / multiple assignment (`old, @cb = @cb, block`, # `@i, @o, @e, @thr = Open3.popen3(cmd)`). A direct # `InstanceVariableWriteNode` is the only write form this # collector handled, so an ivar appearing as a `MultiWriteNode` # target was silently dropped from the class-ivar union — leaving # it to seed as pure `nil` (from a sibling `@cb = nil` ctor write, # or absent entirely) and false-fire `if @cb` always-falsey / # `@thr.alive?` undefined-for-nil. Record each ivar target with # its tuple-position RHS type where the RHS is array/tuple-shaped, # else the unanalyzable floor (the same `Dynamic[top]` a single # write to an unknown RHS records — an unanalyzable multi-write # means unknown, not nil). record_multi_write_ivars(node, scope, class_name, accumulator) 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, dead_writes) return end node.compact_child_nodes.each do |c| gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes) end end |
.index(root, default_scope:, converged_loop_recording: false) ⇒ Hash{Prism::Node => Rigor::Scope}
Build the scope index for a Prism program subtree.
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 154 155 156 157 158 159 |
# File 'lib/rigor/inference/scope_indexer.rb', line 59 def index(root, default_scope:, converged_loop_recording: false) # 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, converged_loop_recording: converged_loop_recording).evaluate(root) propagate(root, table, seeded_scope) table end |
.ivar_write_targets(node, acc = Set.new) ⇒ Object
Every ivar this body assigns a non-nil value to ANYWHERE (the candidate set for the method’s definite-assignment scan).
884 885 886 887 888 889 890 |
# File 'lib/rigor/inference/scope_indexer.rb', line 884 def ivar_write_targets(node, acc = Set.new) return acc unless node.is_a?(Prism::Node) acc << node.name if node.is_a?(Prism::InstanceVariableWriteNode) && !nil_literal_value?(node.value) node.compact_child_nodes.each { |c| ivar_write_targets(c, acc) } acc end |
.literal_method_name(node) ⇒ Object
2175 2176 2177 2178 2179 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2175 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).
2314 2315 2316 2317 2318 2319 2320 2321 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2314 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).
2348 2349 2350 2351 2352 2353 2354 2355 2356 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2348 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.
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 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/rigor/inference/scope_indexer.rb', line 169 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) } singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge( build_discovered_singleton_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_singleton_def_nodes: singleton_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
2557 2558 2559 2560 2561 2562 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2557 def (node, receiver_name, method_name) return false unless node.is_a?(Prism::CallNode) return false unless node.name == method_name (node.receiver, receiver_name) end |
.meta_constant_receiver?(node, expected_name) ⇒ Boolean
2568 2569 2570 2571 2572 2573 2574 2575 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2568 def (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.
1532 1533 1534 1535 1536 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1532 def (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.
1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1492 def (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.
1380 1381 1382 1383 1384 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1380 def (node, full) return nil unless (node) Type::Combinator.singleton_of(full) end |
.method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth) ⇒ Object
Computes the definite-assignment set for one method, memoised per def node. The ‘memo` cycle-guards: a method re-entered while its own summary is in progress contributes nothing (sound under-approximation), so mutual recursion terminates.
863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 |
# File 'lib/rigor/inference/scope_indexer.rb', line 863 def method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth) return Set.new if def_node.body.nil? return memo[def_node] if memo.key?(def_node) return Set.new if depth >= SAME_CLASS_CALL_DEPTH_CAP memo[def_node] = Set.new # in-progress sentinel (cycle guard) statements = top_level_statements(def_node.body) candidates = ivar_write_targets(def_node.body) # A transient `@x = nil` opener whose own method reassigns it # later must still count `@x` as assigned for callers, so the # crediting is computed at the BUILD-time depth. resolver = MethodEffectResolver.new(self, class_name, defs, effects, memo, depth) assigns = Set.new candidates.each do |ivar| assigns << ivar if suffix_definitely_assigns_with_resolver?(statements, 0, ivar, class_name, resolver, depth) end memo[def_node] = assigns end |
.module_function_toggle?(node) ⇒ Boolean
A bare ‘module_function` (no arguments) flips every following `def` in the module body to module-function (instance + singleton) mode.
1761 1762 1763 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1761 def module_function_toggle?(node) node.name == :module_function && node.receiver.nil? 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.
2541 2542 2543 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2541 def module_new_call?(node) (node, :Module, :new) end |
.multi_write_slot_type(elements, index) ⇒ Object
The per-slot type for index ‘i` of a tuple RHS. A missing slot (over-destructure) is `nil` at runtime; a present slot keeps its type. Unlike the local-variable binder we do NOT soften an optional slot here — a class-ivar seed deliberately preserves a genuine `T | nil`, and any spurious nil is removed by the flow-side narrowing, not by dropping it at collection time.
1204 1205 1206 1207 1208 1209 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1204 def multi_write_slot_type(elements, index) element = elements[index] return Type::Combinator.constant_of(nil) if element.nil? element end |
.nil_literal_value?(node) ⇒ Boolean
960 961 962 |
# File 'lib/rigor/inference/scope_indexer.rb', line 960 def nil_literal_value?(node) node.is_a?(Prism::NilNode) 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.
2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2607 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
2628 2629 2630 2631 2632 2633 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2628 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
2635 2636 2637 2638 2639 2640 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2635 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
2577 2578 2579 2580 2581 2582 2583 2584 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2577 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
2123 2124 2125 2126 2127 2128 2129 2130 2131 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2123 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`).
2070 2071 2072 2073 2074 2075 2076 2077 2078 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2070 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
2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2156 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_new_constant_decl(node, qualified_prefix, accumulator) ⇒ Object
T1 (template-corpora survey) — record a ‘Const = Class.new(Super)` (and the bare `Class.new` / `Module.new`) class-creating constant in the cross-file discovery table so a reference to `Const` from ANOTHER file under the same namespace resolves to the project class instead of falling through to a core same-named class (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling file’s ‘rescue SyntaxError => e`, which otherwise resolved to core `::SyntaxError`). Mirrors the single-file `in_source_constants` answer, which types `Class.new(Super)` as `Singleton` (the constructed class answers method lookups through Super’s chain). The superclass name is resolved lexically against the enclosing prefix; a bare ‘Class.new` with no superclass (or `Module.new`) types as `Singleton` itself. The block form is left to the existing `meta_new_block_body` machinery — only the plain `Class.new(Super)` constant (the namespaced-sibling-error idiom) is added here.
2399 2400 2401 2402 2403 2404 2405 2406 2407 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2399 def record_class_new_constant_decl(node, qualified_prefix, accumulator) rvalue = node.value return unless class_new_call?(rvalue) || module_new_call?(rvalue) return if rvalue.block # block form: handled by meta_new_block_body walks full = (qualified_prefix + [node.name.to_s]).join("::") super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator) accumulator[full] = Type::Combinator.singleton_of(super_name || full) end |
.record_class_or_module?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean
2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2464 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.
2333 2334 2335 2336 2337 2338 2339 2340 2341 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2333 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
1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1360 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 = (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
1287 1288 1289 1290 1291 1292 1293 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1287 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.
1877 1878 1879 1880 1881 1882 1883 1884 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1877 def record_data_member_layout(accumulator, qualified_parts, expr) return unless data_define_call?(expr) members = (expr) return if members.empty? accumulator[qualified_parts.join("::")] = members.freeze end |
.record_declarations(node, qualified_prefix, identity_table, discovered) ⇒ Object
2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2449 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 (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
1538 1539 1540 1541 1542 1543 1544 1545 1546 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1538 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
1646 1647 1648 1649 1650 1651 1652 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1646 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
2015 2016 2017 2018 2019 2020 2021 2022 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2015 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
2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2133 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
1314 1315 1316 1317 1318 1319 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1314 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.
672 673 674 675 676 677 678 679 680 681 |
# File 'lib/rigor/inference/scope_indexer.rb', line 672 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
1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1108 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 accumulate_ivar_type(accumulator, class_name, node.name, 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.
2492 2493 2494 2495 2496 2497 2498 2499 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2492 def (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.
1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1515 def (class_node, qualified_prefix, accumulator) superclass = class_node.superclass return unless data_define_call?(superclass) || struct_new_call?(superclass) members = (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
1924 1925 1926 1927 1928 1929 1930 1931 1932 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1924 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 |
.record_module_function_names(node, qualified_prefix, body, accumulator) ⇒ Object
‘module_function :a, :b` retro-marks named siblings (defined earlier OR later in the same body) as module-functions. Resolves each symbol-literal argument against the body’s own ‘def`s and registers the matching `DefNode` on the module’s singleton side. Non-symbol arguments and names with no matching ‘def` are skipped (a miss degrades to today’s ‘Dynamic`, never a false resolution).
1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1775 def record_module_function_names(node, qualified_prefix, body, accumulator) return if qualified_prefix.empty? defs_by_name = statements_of(body).each_with_object({}) do |stmt, acc| acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil? end class_name = qualified_prefix.join("::") node.arguments&.arguments&.each do |arg| name = symbol_argument_name(arg) def_node = name && defs_by_name[name] (accumulator[class_name] ||= {})[name] = def_node if def_node end end |
.record_multi_ivar_rest(splat_node, _type, class_name, accumulator) ⇒ Object
1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1220 def record_multi_ivar_rest(splat_node, _type, class_name, accumulator) return unless splat_node.is_a?(Prism::SplatNode) expression = splat_node.expression return unless expression.is_a?(Prism::InstanceVariableTargetNode) # A splat collects the middle slots into an Array; the precise # element type is not worth recovering here. Record the # unanalyzable floor (an Array of unknown), never nil. accumulate_ivar_type(accumulator, class_name, expression.name, Type::Combinator.untyped) end |
.record_multi_ivar_target(target, type, class_name, accumulator) ⇒ Object
1211 1212 1213 1214 1215 1216 1217 1218 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1211 def record_multi_ivar_target(target, type, class_name, accumulator) case target when Prism::InstanceVariableTargetNode accumulate_ivar_type(accumulator, class_name, target.name, type) when Prism::MultiTargetNode record_multi_target_ivars(target, type, class_name, accumulator) end end |
.record_multi_target_ivars(node, rhs_type, class_name, accumulator) ⇒ Object
Walks a ‘MultiWriteNode` / `MultiTargetNode` target tree against `rhs_type`, recording ivar targets per slot. Mirrors `MultiTargetBinder`’s tuple decomposition but for ivar (rather than local-variable) targets.
1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1167 def record_multi_target_ivars(node, rhs_type, class_name, accumulator) lefts = node.lefts || [] rest = node.rest rights = node.rights || [] fronts, rest_type, backs = decompose_multi_write_rhs(rhs_type, lefts.size, rights.size, rest_present: !rest.nil?) lefts.each_with_index { |t, i| record_multi_ivar_target(t, fronts[i], class_name, accumulator) } record_multi_ivar_rest(rest, rest_type, class_name, accumulator) if rest rights.each_with_index { |t, i| record_multi_ivar_target(t, backs[i], class_name, accumulator) } end |
.record_multi_write_ivars(node, scope, class_name, accumulator) ⇒ Object
N1 — records each ‘InstanceVariableTargetNode` of a `MultiWriteNode` (parallel / multiple assignment) into the class-ivar union, with the best cheap per-slot type. When the RHS is array/tuple-shaped (`Type::Tuple`) the ivar at position `i` records the type of element `i`; otherwise — an unanalyzable RHS such as `Open3.popen3(cmd)` typing to `Dynamic` — every ivar slot records that unanalyzable floor (NOT `nil`: a multi-write we cannot decompose means the value is unknown, and `Dynamic` is the sound union constituent, mirroring what a single write to an unknown RHS records). Nested targets (`(@a, @b), @c = …`) recurse with the slot’s type as the new RHS type.
1156 1157 1158 1159 1160 1161 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1156 def record_multi_write_ivars(node, scope, class_name, accumulator) return unless node.is_a?(Prism::MultiWriteNode) rhs_type = scope.type_of(node.value) record_multi_target_ivars(node, rhs_type, class_name, accumulator) end |
.record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator) ⇒ Object
1750 1751 1752 1753 1754 1755 1756 1757 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1750 def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator) singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on return unless singleton return if qualified_prefix.empty? class_name = qualified_prefix.join("::") (accumulator[class_name] ||= {})[def_node.name] = def_node end |
.render_constant_path(node) ⇒ Object
2586 2587 2588 2589 2590 2591 2592 2593 2594 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2586 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.
1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1464 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 |
.statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting) ⇒ Object
Classifies a single statement’s effect on ‘target`:
:assigned — every path through the statement
that continues OR returns assigns
`target` non-nil (suffix is done);
:terminates_unassigned — the statement ends the method
(return/raise) on some path
without a definite assignment, so
a completing path escaped;
:falls_through_unassigned — control may continue past it
without the assignment (keep
scanning the suffix).
998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 |
# File 'lib/rigor/inference/scope_indexer.rb', line 998 def statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting) case stmt when Prism::InstanceVariableWriteNode return :falls_through_unassigned if stmt.name != target nil_literal_value?(stmt.value) ? :falls_through_unassigned : :assigned when Prism::CallNode if unconditional_call_assigns?(stmt, target, class_name, effects, depth, visiting) :assigned else :falls_through_unassigned end when Prism::IfNode, Prism::UnlessNode conditional_assignment_outcome(stmt, target, class_name, effects, depth, visiting) when Prism::CaseNode case_assignment_outcome(stmt, target, class_name, effects, depth, visiting) when Prism::ReturnNode :terminates_unassigned else # Any other statement — including a bare `raise`/`fail`, # which terminates without a completing path that observes # the seed nil — is neutral: control either continues or the # path never reaches method exit. Keep scanning the suffix. :falls_through_unassigned end end |
.statements_of(body) ⇒ Object
Direct statement children of a class/module body node (a ‘Prism::StatementsNode`, a `Prism::BeginNode` wrapping one, or a lone statement). Returns an empty list for an empty body.
1741 1742 1743 1744 1745 1746 1747 1748 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1741 def statements_of(body) case body when Prism::StatementsNode then body.body when Prism::BeginNode then statements_of(body.statements) when nil then [] else [body] 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.
2522 2523 2524 2525 2526 2527 2528 2529 2530 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2522 def struct_new_call?(node) return false unless (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
2564 2565 2566 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2564 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.
2283 2284 2285 2286 2287 2288 2289 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2283 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 |
.suffix_definitely_assigns?(statements, from, target, class_name, effects) ⇒ Boolean
True when, starting from ‘statements`, EVERY path that completes the method (falls off the end OR hits an early `return`) definitely assigns `target` a non-nil value first. Paths terminated by `raise` are not completing paths and are ignored (they never observe the ivar at method exit). A path that can fall through `statements` without assigning fails.
970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 |
# File 'lib/rigor/inference/scope_indexer.rb', line 970 def suffix_definitely_assigns?(statements, from, target, class_name, effects) statements[from..].each do |stmt| outcome = statement_assignment_outcome(stmt, target, class_name, effects, 0, nil) # The statement assigned on every continuing path -> the # suffix is satisfied no matter what follows. return true if outcome == :assigned # The statement terminates control here (return/raise) and # the value it carried did not assign on every path -> some # completing path reached exit without the assignment. return false if outcome == :terminates_unassigned # Otherwise (:falls_through_unassigned) keep scanning the # remaining statements. end # Fell off the end with no definite assignment. false end |
.suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth) ⇒ Boolean
Build-time variant of ‘suffix_definitely_assigns?` that resolves same-class calls through the lazy `resolver` (which recurses into `method_definite_assigns` for not-yet-computed callees) rather than the finished flat table.
896 897 898 899 900 901 902 903 |
# File 'lib/rigor/inference/scope_indexer.rb', line 896 def suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth) statements[from..].each do |stmt| outcome = statement_assignment_outcome(stmt, target, class_name, resolver, depth, nil) return true if outcome == :assigned return false if outcome == :terminates_unassigned end false end |
.symbol_argument_name(arg) ⇒ Object
The Symbol value of a ‘:name` / `“name”` literal argument, or nil.
1790 1791 1792 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1790 def symbol_argument_name(arg) arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode) 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
730 731 732 733 734 735 736 737 738 739 |
# File 'lib/rigor/inference/scope_indexer.rb', line 730 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 |
.top_level_statements(body) ⇒ Object
953 954 955 956 957 958 |
# File 'lib/rigor/inference/scope_indexer.rb', line 953 def top_level_statements(body) return [] if body.nil? return body.body if body.is_a?(Prism::StatementsNode) [body] end |
.unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting) ⇒ Boolean
True when ‘call` is an unconditional, statement-level, implicit-self (or `self.`) call to a SAME-CLASS method whose definite-assignment summary includes `target`. Calls through a block, on another receiver, or to an unresolved name contribute nothing (the seed nil stays).
1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1094 def unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting) return false if effects.nil? || class_name.nil? return false if depth >= SAME_CLASS_CALL_DEPTH_CAP return false unless call.is_a?(Prism::CallNode) return false unless call.block.nil? # Implicit self (`mask!(x)`) or explicit `self.mask!(x)` only. return false unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode) assigns = effects.dig(class_name, call.name) return false if assigns.nil? assigns.include?(target) end |
.visibility_target_name(arg) ⇒ Object
2059 2060 2061 2062 2063 |
# File 'lib/rigor/inference/scope_indexer.rb', line 2059 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
1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1249 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
1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1904 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, method_assign_effects = nil) ⇒ Object
rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/rigor/inference/scope_indexer.rb', line 354 def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists read_before_write = nil, init_writes = nil, method_assign_effects = 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, method_assign_effects) end return end when Prism::DefNode collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write, init_writes, method_assign_effects) 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, method_assign_effects) end end |
.walk_class_superclasses(node, qualified_prefix, accumulator) ⇒ Object
1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1808 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, dead_writes = 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.
699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/rigor/inference/scope_indexer.rb', line 699 def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil, dead_writes = 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, dead_writes) if node.statements gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars, dead_writes) end branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause return unless branch gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes) end |
.walk_constant_writes(node, qualified_prefix, default_scope, accumulator) ⇒ Object
1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1335 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
1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1849 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
1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1603 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 (node) child_prefix = qualified_prefix + [node.name.to_s] walk_def_nodes((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
1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1964 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 (node) child_prefix = qualified_prefix + [node.name.to_s] walk_method_visibilities((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
1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1411 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] (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 (node) child_prefix = qualified_prefix + [node.name.to_s] walk_methods((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 |
.walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
Walks a class/module/singleton-class body’s direct statements in source order, threading the bare-‘module_function` toggle: once a bare `module_function` is seen, every subsequent `def` in the body registers as a singleton method. Nested classes/modules/defs and `module_function :a, :b` named forms recurse / record through the general walker so the toggle stays scoped to its own body.
1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1719 def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator) module_function_on = false statements_of(body).each do |stmt| if stmt.is_a?(Prism::CallNode) && module_function_toggle?(stmt) if (stmt) module_function_on = true else record_module_function_names(stmt, qualified_prefix, body, accumulator) end next end if stmt.is_a?(Prism::DefNode) record_singleton_def_node(stmt, qualified_prefix, in_singleton_class, module_function_on, accumulator) next end walk_singleton_def_nodes(stmt, qualified_prefix, in_singleton_class, accumulator) end end |
.walk_singleton_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
Walks every node, entering class/module/singleton-class bodies via #walk_singleton_body so a bare ‘module_function` toggle threads correctly across the body’s sibling statements (a child-by-child recursion would reset it). At the top level / inside an arbitrary node there is no ‘module_function` state to carry, so descent is a plain per-child walk.
1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1679 def walk_singleton_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 walk_singleton_body(node.body, qualified_prefix + [name], 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_singleton_body(node.body, singleton_prefix, true, accumulator) return end end when Prism::ConstantWriteNode if (node) child_prefix = qualified_prefix + [node.name.to_s] walk_singleton_body((node), child_prefix, false, accumulator) return end when Prism::DefNode record_singleton_def_node(node, qualified_prefix, in_singleton_class, false, accumulator) return end node.compact_child_nodes.each do |child| walk_singleton_def_nodes(child, qualified_prefix, in_singleton_class, accumulator) end end |
.widen_member_for_observed_mutators(member, observed_methods) ⇒ Object
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 |
# File 'lib/rigor/inference/scope_indexer.rb', line 338 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.
303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/rigor/inference/scope_indexer.rb', line 303 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`).
332 333 334 335 336 |
# File 'lib/rigor/inference/scope_indexer.rb', line 332 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 |