Module: Rigor::Inference::ScopeIndexer
- Defined in:
- lib/rigor/inference/scope_indexer.rb
Overview
Builds a per-node scope index for a Prism program by running ‘Rigor::Inference::StatementEvaluator` over the root and recording the entry scope visible at every node. Expression-interior nodes the evaluator does not specialise (call receivers, arguments, array/hash elements, …) inherit their nearest statement-y ancestor’s recorded scope, so a downstream caller that looks up the scope for any Prism node in the tree always gets the scope that was effectively visible at that point.
The CLI commands ‘rigor type-of` and `rigor type-scan` consume the index so that local-variable bindings established earlier in the program are visible to the typer when probing later nodes. Without the index, both commands would type every node under an empty scope and miss the constant-folding / dispatch precision that Slice 3 phase 2’s StatementEvaluator unlocks.
The returned object is an identity-comparing Hash:
“‘ruby index = Rigor::Inference::ScopeIndexer.index(program, default_scope: Scope.empty) index #=> the Rigor::Scope visible at that node “`
Nodes that are not part of the program subtree (e.g. synthesised virtual nodes that the caller looks up after the fact) yield the ‘default_scope`. The returned Hash is mutable in principle but callers MUST treat it as read-only; the indexer itself never exposes a way to update it past construction. rubocop:disable Metrics/ModuleLength
Constant Summary collapse
- TOP_LEVEL_DEF_KEY =
v0.0.3 A — sentinel key under which ‘record_def_node` files DefNodes that live outside any class / module body (top-level helpers, `def`s nested inside DSL blocks like `RSpec.describe … do; def helper; end`). Looked up by `Scope#top_level_def_for` to give implicit-self calls priority over RBS dispatch when the file defines a same-named local method.
"<toplevel>"- MIXIN_CALL_NAMES =
%i[include prepend].freeze
- VISIBILITY_MODIFIERS =
%i[public private protected].freeze
Class Method Summary collapse
-
.accumulate_project_index(acc, path, root) ⇒ Object
Folds one file’s class-keyed indexes into the cross-file accumulator.
-
.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.
-
.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_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_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_program_global_index(root, default_scope) ⇒ Object
Slice 7 phase 6 — program-global pre-pass.
-
.class_new_call?(node) ⇒ Boolean
Recognises ‘Class.new`, `Class.new(super_class)`, and the block form `Class.new { … }`.
-
.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_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) ⇒ Object
- .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) ⇒ 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
-
.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 …
-
.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) ⇒ Object
-
.index(root, default_scope:) ⇒ Hash{Prism::Node => Rigor::Scope}
Build the scope index for a Prism program subtree.
- .literal_method_name(node) ⇒ Object
-
.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_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 …
-
.module_new_call?(node) ⇒ Boolean
Recognises ‘Module.new` and `Module.new(&block)` / `Module.new do …
-
.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_class_or_module?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean
- .record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name) ⇒ Object
- .record_cvar_write(node, scope, class_name, accumulator) ⇒ Object
- .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_mixin_call(node, current_class, accumulator) ⇒ Object
- .render_constant_path(node) ⇒ Object
-
.singleton_class_prefix(node, qualified_prefix) ⇒ Object
Resolves a ‘class << X` body’s qualified prefix.
-
.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
-
.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.
- .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) ⇒ Object
- .walk_class_superclasses(node, qualified_prefix, accumulator) ⇒ Object
-
.walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil) ⇒ Object
Walk an ‘IfNode` / `UnlessNode` so writes inside the THEN body that look like defensive ivar initialisation gain a `nil` union in the seeded type.
- .walk_constant_writes(node, qualified_prefix, default_scope, 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.
- .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_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.
1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1407 def accumulate_project_index(acc, path, root) merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root) acc[:superclasses].merge!(build_discovered_superclasses(root)) build_discovered_includes(root).each do |class_name, mods| acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq end build_discovered_method_visibilities(root).each do |class_name, table| (acc[:method_visibilities][class_name] ||= {}).merge!(table) end 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.
1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1249 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
1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1212 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.
1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1198 def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator) return current_visibility unless call_node.receiver.nil? return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name) return current_visibility if qualified_prefix.empty? args = call_node.arguments&.arguments || [] if args.empty? call_node.name else apply_named_visibility(args, qualified_prefix, call_node.name, accumulator) current_visibility end end |
.build_class_cvar_index(root, default_scope) ⇒ Object
Slice 7 phase 6 — class-cvar pre-pass. Same shape as the ivar pre-pass but collects ‘Prism::ClassVariableWriteNode` writes inside ANY def body (instance or singleton) of the enclosing class, because Ruby cvars are shared across both facets. The resulting table is seeded into both instance and singleton method bodies through `Scope#class_cvars_for`.
650 651 652 653 654 |
# File 'lib/rigor/inference/scope_indexer.rb', line 650 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`.
189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/rigor/inference/scope_indexer.rb', line 189 def build_class_ivar_index(root, default_scope) accumulator = {} mutated_ivars = {} read_before_write = {} init_writes = {} walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars, read_before_write, init_writes) widen_mutated_ivar_entries!(accumulator, mutated_ivars) contribute_read_before_write_nil!(accumulator, read_before_write, init_writes) accumulator.transform_values(&:freeze).freeze end |
.build_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.
1465 1466 1467 1468 1469 1470 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1465 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.
952 953 954 955 956 957 |
# File 'lib/rigor/inference/scope_indexer.rb', line 952 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).
1062 1063 1064 1065 1066 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1062 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.
1121 1122 1123 1124 1125 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1121 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]`.
799 800 801 802 803 |
# File 'lib/rigor/inference/scope_indexer.rb', line 799 def build_discovered_methods(root) accumulator = {} walk_methods(root, [], false, accumulator) accumulator.transform_values(&:freeze).freeze end |
.build_discovered_superclasses(root) ⇒ Object
ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the ‘class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(…)` and other non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call site against the subclass’s lexical nesting —see ‘ExpressionTyper#resolve_ancestor_class_name`.
1018 1019 1020 1021 1022 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1018 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.
736 737 738 739 740 |
# File 'lib/rigor/inference/scope_indexer.rb', line 736 def build_in_source_constants(root, default_scope) accumulator = {} walk_constant_writes(root, [], default_scope, accumulator) accumulator.freeze end |
.build_program_global_index(root, default_scope) ⇒ Object
Slice 7 phase 6 — program-global pre-pass. Globals are process-wide so the accumulator is a flat ‘Hash[Symbol, Type::t]` populated from every `Prism::GlobalVariableWriteNode` in the program (top-level AND inside method bodies). The same accumulator is seeded into every method body and the top-level scope.
708 709 710 711 712 |
# File 'lib/rigor/inference/scope_indexer.rb', line 708 def build_program_global_index(root, default_scope) accumulator = {} gather_global_writes(root, default_scope, accumulator) accumulator.freeze 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.
1576 1577 1578 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1576 def class_new_call?(node) (node, :Class, :new) 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.
1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1266 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).
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/rigor/inference/scope_indexer.rb', line 433 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`.
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 1437 def collect_class_decls(node, qualified_prefix, accumulator) return unless node.is_a?(Prism::Node) case node when Prism::ClassNode name = qualified_name_for(node.constant_path) if name full = (qualified_prefix + [name]).join("::") accumulator[full] = Type::Combinator.singleton_of(full) return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body end when Prism::ModuleNode name = qualified_name_for(node.constant_path) return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body end node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) } end |
.collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator) ⇒ Object
677 678 679 680 681 682 683 |
# File 'lib/rigor/inference/scope_indexer.rb', line 677 def collect_def_cvar_writes(def_node, qualified_prefix, default_scope, accumulator) return if def_node.body.nil? || qualified_prefix.empty? class_name = qualified_prefix.join("::") body_scope = default_scope.with_self_type(Type::Combinator.nominal_of(class_name)) gather_cvar_writes(def_node.body, body_scope, class_name, accumulator) end |
.collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil) ⇒ Object
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/rigor/inference/scope_indexer.rb', line 361 def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil) return if def_node.body.nil? || qualified_prefix.empty? class_name = qualified_prefix.join("::") singleton = def_node.receiver.is_a?(Prism::SelfNode) || def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix) self_type = if singleton Type::Combinator.singleton_of(class_name) else Type::Combinator.nominal_of(class_name) end body_scope = default_scope.with_self_type(self_type) gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars) # B2.3 — collect per-method evidence for the read-before- # write nil contribution. The accumulator-level decision # ("is this ivar truly read-before-write across the # class lifetime?") is finalised at # `contribute_read_before_write_nil!` after the whole # class body has been walked, using `init_writes` as # the soundness gate (an ivar written in `initialize` # is initialised before any other method body runs). collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes) end |
.collect_defined_test_ivars(node, names) ⇒ Object
580 581 582 583 584 585 586 587 588 589 590 591 |
# File 'lib/rigor/inference/scope_indexer.rb', line 580 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
593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 |
# File 'lib/rigor/inference/scope_indexer.rb', line 593 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) ⇒ 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.
397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/rigor/inference/scope_indexer.rb', line 397 def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes) 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) if def_node.name == :initialize 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
568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/rigor/inference/scope_indexer.rb', line 568 def collect_truthy_test_ivars(node, names) return unless node.is_a?(Prism::Node) case node when Prism::InstanceVariableReadNode names << node.name when Prism::AndNode, Prism::OrNode collect_truthy_test_ivars(node.left, names) collect_truthy_test_ivars(node.right, names) end end |
.contribute_read_before_write_nil!(accumulator, read_before_write, init_writes) ⇒ Object
B2.3 — finalize the read-before-write nil contribution. For each class, for each ivar where SOME method body observed a read-before-write AND no ‘initialize` write exists for that ivar, contribute `Constant` to the class-wide accumulator.
The ‘initialize` filter is the soundness gate: Ruby semantics guarantee `initialize` runs first (via `Class.new`), so a write there reaches every other method body’s read. Read-before-write in a non-init method is then NOT a nil-at-runtime case — it’s just AST-order coincidence. Without this filter a normal ‘def initialize; @x = … end` / `def use; @x.foo end` class would have `@x` widened with nil, producing FPs at every `@x.foo` call.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
# File 'lib/rigor/inference/scope_indexer.rb', line 216 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.
1529 1530 1531 1532 1533 1534 1535 1536 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1529 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 |
.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.
925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 |
# File 'lib/rigor/inference/scope_indexer.rb', line 925 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.
915 916 917 918 919 |
# File 'lib/rigor/inference/scope_indexer.rb', line 915 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
449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 |
# File 'lib/rigor/inference/scope_indexer.rb', line 449 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.
1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1348 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.
1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1388 def discovered_def_index_for_paths(paths, buffer: nil) acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {} } 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 %i[def_nodes def_sources includes method_visibilities].each { |key| acc[key].each_value(&:freeze) } acc.transform_values(&:freeze) end |
.falsey_constant?(type) ⇒ Boolean
639 640 641 |
# File 'lib/rigor/inference/scope_indexer.rb', line 639 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
685 686 687 688 689 690 691 692 |
# File 'lib/rigor/inference/scope_indexer.rb', line 685 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
714 715 716 717 718 719 |
# File 'lib/rigor/inference/scope_indexer.rb', line 714 def gather_global_writes(node, scope, accumulator) return unless node.is_a?(Prism::Node) record_global_write(node, scope, accumulator) if node.is_a?(Prism::GlobalVariableWriteNode) node.compact_child_nodes.each { |c| gather_global_writes(c, scope, accumulator) } end |
.gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, mutated_ivars = nil) ⇒ Object
474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 |
# File 'lib/rigor/inference/scope_indexer.rb', line 474 def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, mutated_ivars = nil) return unless node.is_a?(Prism::Node) if node.is_a?(Prism::InstanceVariableWriteNode) record_ivar_write(node, scope, class_name, accumulator, guarded: guarded_ivars.include?(node.name)) end record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode) # Don't recurse into nested defs, classes, or modules; their # ivars belong to their own enclosing class. return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) } if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode) walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars) return end node.compact_child_nodes.each do |c| gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars) end end |
.index(root, default_scope:) ⇒ Hash{Prism::Node => Rigor::Scope}
Build the scope index for a Prism program subtree.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/rigor/inference/scope_indexer.rb', line 54 def index(root, default_scope:) # rubocop:disable Metrics/AbcSize # Slice A-declarations. Build the declaration overrides # first so every scope handed to the StatementEvaluator # already carries the table; structural sharing through # `Scope#with_local` / `#with_fact` / `#with_self_type` # propagates it across every derived scope. declared_types, discovered_classes = build_declaration_artifacts(root) # Merge the indexer's findings on top of whatever the # base scope already carries so callers that seed # cross-file class knowledge (e.g. the ADR-14 # `SigGen::ObservationCollector` pre-walking project # `lib/` before scanning `spec/`) keep their seeds # alongside the per-file declarations the indexer # itself discovers. Indexer-found entries win on # collision — same-file declarations are the most # specific authority. merged_classes = default_scope.discovered_classes.merge(discovered_classes) seeded_scope = default_scope .with_declared_types(declared_types) .with_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_class_ivars(class_ivars) # Slice 7 phase 6. Same pre-pass shape for cvars (per # class) and globals (program-wide). Globals are also # materialised into the top-level scope's `globals` map # so reads at the top level (and in CLI probes that do # not enter a method body) observe the precise type # without consulting the accumulator on every lookup. class_cvars = build_class_cvar_index(root, seeded_scope) seeded_scope = seeded_scope.with_class_cvars(class_cvars) program_globals = build_program_global_index(root, seeded_scope) seeded_scope = seeded_scope.with_program_globals(program_globals) program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) } # Slice 7 phase 9. In-source constant value tracking. # Walks every ConstantWriteNode/ConstantPathWriteNode in # the program and types its rvalue under a scope that # carries the surrounding qualified prefix as # `self_type`, so the rvalue typer sees in-class # references resolve correctly. Multiple writes to the # same qualified name union via `Type::Combinator.union`. in_source_constants = build_in_source_constants(root, seeded_scope) seeded_scope = seeded_scope.with_in_source_constants(in_source_constants) # Slice 7 phase 12. In-source method discovery. Walks # every class/module body for `Prism::DefNode` and # recognised `define_method` calls and records the # introduced method names. `rigor check` consults the # table to suppress false positives for methods the # user has defined but no RBS sig describes. discovered_methods = build_discovered_methods(root) seeded_scope = seeded_scope.with_discovered_methods(discovered_methods) # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method # def nodes, the class -> superclass map, and the # class/module -> included-modules map, each merged under # the cross-file pre-pass seed (see below). # v0.1.2 — per-class table of method visibilities # (`:public` / `:private` / `:protected`). The # `def.method-visibility-mismatch` and ADR-35 # `def.override-visibility-reduced` CheckRules consult the # table. Seeded inside `merge_project_method_indexes` so the # per-file visibilities merge OVER the cross-file project seed # rather than overwriting it. seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root) table = {}.compare_by_identity table.default = seeded_scope # Last-visit-wins, not first: when `StatementEvaluator` # internally re-evaluates a subtree (notably `eval_begin`'s # retry-edge widening pass), the LATER visit carries the # corrected entry scope (e.g. a `tries` widened to # `Nominal[Integer]` after the rescue body's `tries += 1; # retry` is observed). The diagnostic layer reads # `table[node]` to type predicates; the second pass's # entry is the one that reflects all flow-derived # rebinds, so it MUST overwrite the first. on_enter = ->(node, scope) { table[node] = scope } StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root) propagate(root, table, seeded_scope) table end |
.literal_method_name(node) ⇒ Object
1310 1311 1312 1313 1314 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1310 def literal_method_name(node) return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode) node.unescaped&.to_sym 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).
1423 1424 1425 1426 1427 1428 1429 1430 1431 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1423 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.
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/rigor/inference/scope_indexer.rb', line 153 def merge_project_method_indexes(seeded_scope, default_scope, root) def_nodes = default_scope.discovered_def_nodes.merge( build_discovered_def_nodes(root) ) { |_class, cross_file, per_file| cross_file.merge(per_file) } superclasses = default_scope.discovered_superclasses.merge( build_discovered_superclasses(root) ) includes = default_scope.discovered_includes.merge( build_discovered_includes(root) ) { |_class, cross_file, per_file| (cross_file + per_file).uniq } # ADR-35 — per-file visibilities merged OVER the cross-file # seed (the current file is authoritative for its own classes; # sibling-file ancestors are preserved from the project seed). method_visibilities = default_scope.discovered_method_visibilities.merge( build_discovered_method_visibilities(root) ) { |_class, cross_file, per_file| cross_file.merge(per_file) } seeded_scope .with_discovered_def_nodes(def_nodes) .with_discovered_superclasses(superclasses) .with_discovered_includes(includes) .with_discovered_method_visibilities(method_visibilities) end |
.meta_call_with_name?(node, receiver_name, method_name) ⇒ Boolean
1580 1581 1582 1583 1584 1585 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1580 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
1591 1592 1593 1594 1595 1596 1597 1598 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1591 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_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.
882 883 884 885 886 887 888 889 890 891 892 |
# File 'lib/rigor/inference/scope_indexer.rb', line 882 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.
787 788 789 790 791 |
# File 'lib/rigor/inference/scope_indexer.rb', line 787 def (node, full) return nil unless (node) Type::Combinator.singleton_of(full) end |
.module_new_call?(node) ⇒ Boolean
Recognises ‘Module.new` and `Module.new(&block)` / `Module.new do … end` at constant-write rvalue position. The block body is the anonymous module’s ‘module_eval` body; defs inside it bind methods on the named constant (`Const = Module.new do …; def foo; …; end; end`). Arguments are NOT inspected because `Module.new` accepts no positionals — Ruby raises ArgumentError if any are passed — so a malformed call falls through the walker without affecting analysis.
1564 1565 1566 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1564 def module_new_call?(node) (node, :Module, :new) end |
.propagate(node, table, parent_scope) ⇒ Object
Walks ‘node`’s subtree DFS and fills in scope entries for every Prism node the StatementEvaluator did not visit (i.e. expression- interior nodes like the receiver/args of a CallNode). Those nodes inherit their nearest recorded ancestor’s scope.
‘IfNode` / `UnlessNode` are special-cased: the truthy and falsey branches each get their predicate’s narrowed scope before recursing. This handles expression-position conditionals (e.g. ‘cache = if cond; t; else; e; end` and conditionals nested as call arguments) which are typed by ExpressionTyper without going through `eval_if`’s narrowing path.
1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1630 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
1651 1652 1653 1654 1655 1656 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1651 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
1658 1659 1660 1661 1662 1663 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1658 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
1600 1601 1602 1603 1604 1605 1606 1607 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1600 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
1287 1288 1289 1290 1291 1292 1293 1294 1295 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1287 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`).
1234 1235 1236 1237 1238 1239 1240 1241 1242 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1234 def record_alias_method(alias_node, qualified_prefix, in_singleton_class, accumulator) return if qualified_prefix.empty? return unless alias_node.new_name.is_a?(Prism::SymbolNode) class_name = qualified_prefix.join("::") new_name = alias_node.new_name.unescaped.to_sym kind = in_singleton_class ? :singleton : :instance (accumulator[class_name] ||= {})[new_name] = kind end |
.record_class_or_module?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean
1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1487 def record_class_or_module?(node, qualified_prefix, identity_table, discovered) name = qualified_name_for(node.constant_path) return false unless name full = (qualified_prefix + [name]).join("::") singleton = Type::Combinator.singleton_of(full) identity_table[node.constant_path] = singleton discovered[full] = singleton child_prefix = qualified_prefix + [name] record_declarations(node.body, child_prefix, identity_table, discovered) if node.body true end |
.record_constant_write(node, qualified_prefix, default_scope, accumulator, base_name) ⇒ Object
767 768 769 770 771 772 773 774 775 776 |
# File 'lib/rigor/inference/scope_indexer.rb', line 767 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
694 695 696 697 698 699 700 |
# File 'lib/rigor/inference/scope_indexer.rb', line 694 def record_cvar_write(node, scope, class_name, accumulator) rvalue_type = scope.type_of(node.value) accumulator[class_name] ||= {} existing = accumulator[class_name][node.name] accumulator[class_name][node.name] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type end |
.record_declarations(node, qualified_prefix, identity_table, discovered) ⇒ Object
1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1472 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
894 895 896 897 898 899 900 901 902 |
# File 'lib/rigor/inference/scope_indexer.rb', line 894 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
1002 1003 1004 1005 1006 1007 1008 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1002 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
1179 1180 1181 1182 1183 1184 1185 1186 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1179 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
1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1297 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
721 722 723 724 725 726 |
# File 'lib/rigor/inference/scope_indexer.rb', line 721 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.
505 506 507 508 509 510 511 512 513 514 |
# File 'lib/rigor/inference/scope_indexer.rb', line 505 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
609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/rigor/inference/scope_indexer.rb', line 609 def record_ivar_write(node, scope, class_name, accumulator, guarded: false) rvalue_type = scope.type_of(node.value) # `@x = nil unless @x` / `@y = false unless @y` — # follow-up to the polarity-aware defensive-init guard # fix (ROADMAP § Future cycles — "Defensive ivar-init # with nil / false rvalue"). When the rvalue is itself a # falsey Constant, `union(rvalue, Constant[nil])` # collapses (for `nil`) or doesn't widen the type's # truthiness profile (for `false`) — the predicate # `unless @x` then folds to a single `Constant[nil]` / # `Constant[false]` and the # `flow.always-truthy-condition` / `-always-falsey-` # rule false-fires on the no-op-but-documented-default # idiom. Skip the seed contribution for this write # (matches the existing skip for `@x ||= v`, which the # pre-pass also does not seed). Other writes to the # same ivar still contribute; the falsey-default write # carries no useful precision the predicate hasn't # already given us. See tdiary-core HEAD `ee40c2b` # `lib/tdiary/configuration.rb:157` for the worked site. return if guarded && falsey_constant?(rvalue_type) rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded accumulator[class_name] ||= {} existing = accumulator[class_name][node.name] accumulator[class_name][node.name] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type end |
.record_meta_new_constant?(node, qualified_prefix, identity_table, discovered) ⇒ Boolean
Recognises class-creating meta calls at constant-write rvalue position and registers ‘Const` (qualified by the surrounding class/module path) as a discovered class. `Const.new(…)` then resolves to a fresh `Nominal` via `meta_new`, instead of the un-narrowed `Dynamic` returned by the default `Class#new` envelope.
Two recognised meta forms:
-
‘Const = Data.define(*Symbol) [do … end]`
-
‘Const = Struct.new(*Symbol [, keyword_init: …]) [do … end]`
The block body, if present, is recursed into so any nested class/module declarations in the override block (rare but legal) still feed the discovered table.
1515 1516 1517 1518 1519 1520 1521 1522 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1515 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_mixin_call(node, current_class, accumulator) ⇒ Object
1088 1089 1090 1091 1092 1093 1094 1095 1096 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1088 def record_mixin_call(node, current_class, accumulator) return unless current_class && node.receiver.nil? return unless MIXIN_CALL_NAMES.include?(node.name) node.arguments&.arguments&.each do |arg| mod = qualified_name_for(arg) (accumulator[current_class] ||= []) << mod if mod end end |
.render_constant_path(node) ⇒ Object
1609 1610 1611 1612 1613 1614 1615 1616 1617 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1609 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.
854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 |
# File 'lib/rigor/inference/scope_indexer.rb', line 854 def singleton_class_prefix(node, qualified_prefix) case node.expression when Prism::SelfNode qualified_prefix when Prism::ConstantReadNode, Prism::ConstantPathNode rendered = qualified_name_for(node.expression) return nil unless rendered if !qualified_prefix.empty? && qualified_prefix.last == rendered qualified_prefix else rendered.split("::") end end end |
.struct_new_call?(node) ⇒ Boolean
Recognises ‘Struct.new(*Symbol)` and `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write rvalue position. A trailing `KeywordHashNode` (the `keyword_init: …` form) is accepted but does not contribute to member discovery; every other argument MUST be a `Prism::SymbolNode`. At least one Symbol member is required —`Struct.new()` is a degenerate form callers don’t typically use.
1545 1546 1547 1548 1549 1550 1551 1552 1553 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1545 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
1587 1588 1589 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1587 def struct_new_positionals(args) args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args 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
557 558 559 560 561 562 563 564 565 566 |
# File 'lib/rigor/inference/scope_indexer.rb', line 557 def then_body_guarded_ivars(node) names = Set.new if node.is_a?(Prism::UnlessNode) collect_truthy_test_ivars(node.predicate, names) collect_defined_test_ivars(node.predicate, names) else collect_nil_test_ivars(node.predicate, names) end names end |
.visibility_target_name(arg) ⇒ Object
1223 1224 1225 1226 1227 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1223 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
656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 |
# File 'lib/rigor/inference/scope_indexer.rb', line 656 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
1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1068 def walk_class_includes(node, qualified_prefix, current_class, accumulator) return unless node.is_a?(Prism::Node) case node when Prism::ClassNode, Prism::ModuleNode name = qualified_name_for(node.constant_path) if name full = (qualified_prefix + [name]).join("::") walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body return end when Prism::CallNode record_mixin_call(node, current_class, accumulator) end node.compact_child_nodes.each do |child| walk_class_includes(child, qualified_prefix, current_class, accumulator) end end |
.walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil) ⇒ Object
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
# File 'lib/rigor/inference/scope_indexer.rb', line 318 def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write = nil, init_writes = nil) return unless node.is_a?(Prism::Node) case node when Prism::ClassNode, Prism::ModuleNode name = qualified_name_for(node.constant_path) if name child_prefix = qualified_prefix + [name] if node.body # Class-body level `@x = nil` writes don't # initialise instance ivars at runtime (the # class's own singleton ivars and the instance's # ivars are separate stores), but they signal # "the author KNOWS @x could be nil" and extend # the B2.3 soundness gate: an ivar with a # class-body write is exempted from the # read-before-write nil contribution because the # seed already reflects the author's acknowledged # nullability via the def-body writes' union. # Without this exemption, code that explicitly # `@x = nil`s at class-body level then writes # `@x = SomeClass.new` inside an instance method # gains an unjustified nil widening at every # read. collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes walk_class_ivars(node.body, child_prefix, default_scope, accumulator, mutated_ivars, read_before_write, init_writes) end return end when Prism::DefNode collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write, init_writes) return end node.compact_child_nodes.each do |child| walk_class_ivars(child, qualified_prefix, default_scope, accumulator, mutated_ivars, read_before_write, init_writes) end end |
.walk_class_superclasses(node, qualified_prefix, accumulator) ⇒ Object
1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1024 def walk_class_superclasses(node, qualified_prefix, accumulator) return unless node.is_a?(Prism::Node) case node when Prism::ClassNode name = qualified_name_for(node.constant_path) if name full = (qualified_prefix + [name]).join("::") superclass = node.superclass && qualified_name_for(node.superclass) accumulator[full] = superclass if superclass walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body return end when Prism::ModuleNode name = qualified_name_for(node.constant_path) if name walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body return end end node.compact_child_nodes.each do |child| walk_class_superclasses(child, qualified_prefix, accumulator) end end |
.walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil) ⇒ Object
Walk an ‘IfNode` / `UnlessNode` so writes inside the THEN body that look like defensive ivar initialisation gain a `nil` union in the seeded type. Without this, `@x = v unless @x` records `Constant` for `@x`, then the predicate folds to that same constant and `flow.always-truthy-condition` fires against a working program. Mirrors the existing skip for `@x ||= v` (`Prism::InstanceVariableOrWriteNode`, which the pre-pass does not seed at all).
Polarity-aware on purpose: only the THEN body picks up the guard. The ELSE branch of ‘if @x; …; else; @x = init; end` would otherwise be marked too — but that pattern (write @x in the else of `if @x`) is a separate idiom whose surrounding reads of `@x` would then surface a nil-receiver FP. The ELSE branch is left ungarded so those reads continue to type as they did before this fix.
532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/rigor/inference/scope_indexer.rb', line 532 def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil) then_guards = then_body_guarded_ivars(node) then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards) gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if node.statements gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars) end branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if branch end |
.walk_constant_writes(node, qualified_prefix, default_scope, accumulator) ⇒ Object
742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 |
# File 'lib/rigor/inference/scope_indexer.rb', line 742 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_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) ⇒ Object
959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 |
# File 'lib/rigor/inference/scope_indexer.rb', line 959 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
1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 |
# File 'lib/rigor/inference/scope_indexer.rb', line 1128 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
806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 |
# File 'lib/rigor/inference/scope_indexer.rb', line 806 def walk_methods(node, qualified_prefix, in_singleton_class, accumulator) return unless node.is_a?(Prism::Node) case node when Prism::ClassNode, Prism::ModuleNode name = qualified_name_for(node.constant_path) if name child_prefix = qualified_prefix + [name] walk_methods(node.body, child_prefix, false, accumulator) if node.body return end when Prism::SingletonClassNode if node.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 end node.compact_child_nodes.each do |child| walk_methods(child, qualified_prefix, in_singleton_class, accumulator) end end |
.widen_member_for_observed_mutators(member, observed_methods) ⇒ Object
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/rigor/inference/scope_indexer.rb', line 302 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.
267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/rigor/inference/scope_indexer.rb', line 267 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`).
296 297 298 299 300 |
# File 'lib/rigor/inference/scope_indexer.rb', line 296 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 |