From bfde4e195708f3d07f6876c75a5b26332b9948fc Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Tue, 7 Apr 2026 17:19:43 +0900 Subject: [PATCH] Prioritize RBS ivar declarations over inferred types on read When an instance variable has an RBS type declaration, IVarReadBox now returns the declared type instead of the union of all assigned types. This prevents nil pollution from base-class initializations like @x = nil from leaking into subclass reads that have declarations. To make this work regardless of file load order, IVarReadBox subscribes to every ive it visits via add_depended_value_entity, and ValueEntity re-runs those subscribers from on_decl_changed when a declaration is added or removed by SigInstanceVariableNode. Without this, an RBS file loaded after the corresponding Ruby file would leave stale edges from the inferred path in the type graph. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/typeprof/core/ast/sig_decl.rb | 14 +++++------ lib/typeprof/core/env/module_entity.rb | 13 ++++++++++ lib/typeprof/core/env/value_entity.rb | 8 ++++++ lib/typeprof/core/graph/box.rb | 35 ++++++++++++++++++++------ scenario/rbs/ivar_decl_late_load.rb | 21 ++++++++++++++++ scenario/rbs/ivar_decl_priority.rb | 34 +++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 scenario/rbs/ivar_decl_late_load.rb create mode 100644 scenario/rbs/ivar_decl_priority.rb diff --git a/lib/typeprof/core/ast/sig_decl.rb b/lib/typeprof/core/ast/sig_decl.rb index d040f5125..324f514ee 100644 --- a/lib/typeprof/core/ast/sig_decl.rb +++ b/lib/typeprof/core/ast/sig_decl.rb @@ -505,20 +505,20 @@ def attrs = { cpath:, class_scope: } def define0(genv) @type.define(genv) - mod = genv.resolve_ivar(cpath, @class_scope, @var) - mod.add_decl(self) - mod + mod = genv.resolve_cpath(cpath) + mod.add_ivar_decl(genv, @class_scope, @var, self) end def define_copy(genv) - mod = genv.resolve_ivar(cpath, @class_scope, @var) - mod.add_decl(self) - mod.remove_decl(@prev_node) + mod = genv.resolve_cpath(cpath) + mod.add_ivar_decl(genv, @class_scope, @var, self) + mod.remove_ivar_decl(genv, @class_scope, @var, @prev_node) super(genv) end def undefine0(genv) - genv.resolve_ivar(cpath, @class_scope, @var).remove_decl(self) + mod = genv.resolve_cpath(cpath) + mod.remove_ivar_decl(genv, @class_scope, @var, self) @type.undefine(genv) end diff --git a/lib/typeprof/core/env/module_entity.rb b/lib/typeprof/core/env/module_entity.rb index 6ad0a9715..2783f6126 100644 --- a/lib/typeprof/core/env/module_entity.rb +++ b/lib/typeprof/core/env/module_entity.rb @@ -436,6 +436,19 @@ def get_ivar(singleton, name) @ivars[singleton][name] ||= ValueEntity.new end + def add_ivar_decl(genv, singleton, name, decl) + ive = get_ivar(singleton, name) + ive.add_decl(decl) + ive.on_decl_changed(genv) + ive + end + + def remove_ivar_decl(genv, singleton, name, decl) + ive = get_ivar(singleton, name) + ive.remove_decl(decl) + ive.on_decl_changed(genv) + end + def get_cvar(name) @cvars[name] ||= ValueEntity.new end diff --git a/lib/typeprof/core/env/value_entity.rb b/lib/typeprof/core/env/value_entity.rb index 6bf20a0a8..27f95bfb8 100644 --- a/lib/typeprof/core/env/value_entity.rb +++ b/lib/typeprof/core/env/value_entity.rb @@ -17,6 +17,14 @@ def remove_decl(decl) @decls.delete(decl) || raise end + # Re-run all read boxes that depend on this entity. Used when a + # declaration is added or removed so that dependents (e.g. an + # IVarReadBox that previously fell back to the inferred type) can + # observe the new state. + def on_decl_changed(genv) + @read_boxes.each {|box| genv.add_run(box) } + end + def add_def(def_) @defs << def_ end diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 91fb388d3..86822ebd5 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -1280,23 +1280,44 @@ def run0(genv, changes) singleton = @singleton cur_ive = mod.get_ivar(singleton, @name) target_vtx = nil + target_decls = nil genv.each_direct_superclass(mod, singleton) do |mod, singleton| ive = mod.get_ivar(singleton, @name) + # Subscribe to every visited ive so that, if one later acquires an + # RBS declaration, this box is re-run and switches to the declared + # type instead of the inferred one. + changes.add_depended_value_entity(ive) if ive.exist? target_vtx = ive.vtx + target_decls = ive.decls unless ive.decls.empty? + break if target_decls end end - edges = [] - if target_vtx + + if target_decls + # When declarations exist, return declared types instead of assigned types + target_decls.each do |decl| + subst = {} + if decl.cpath + decl_mod = genv.resolve_cpath(decl.cpath) + if decl_mod.type_params && !decl_mod.type_params.empty? + subst = decl_mod.type_params.to_h do |param, _default_ty| + [param, Vertex.new(@node)] + end + end + end + vtx = decl.type.covariant_vertex(genv, changes, subst) + changes.add_edge(genv, vtx, @ret) + end + elsif target_vtx + edges = [] if target_vtx != cur_ive.vtx edges << [cur_ive.vtx, @proxy] << [@proxy, target_vtx] end edges << [target_vtx, @ret] - else - # TODO: error? - end - edges.each do |src, dst| - changes.add_edge(genv, src, dst) + edges.each do |src, dst| + changes.add_edge(genv, src, dst) + end end end end diff --git a/scenario/rbs/ivar_decl_late_load.rb b/scenario/rbs/ivar_decl_late_load.rb new file mode 100644 index 000000000..37ae34838 --- /dev/null +++ b/scenario/rbs/ivar_decl_late_load.rb @@ -0,0 +1,21 @@ +## update: test.rb +class Foo + def initialize + @x = nil + end + + def get_x + @x + end +end + +## update: test.rbs +class Foo + @x: Integer +end + +## assert: test.rb +class Foo + def initialize: -> void + def get_x: -> Integer +end diff --git a/scenario/rbs/ivar_decl_priority.rb b/scenario/rbs/ivar_decl_priority.rb new file mode 100644 index 000000000..ceab11893 --- /dev/null +++ b/scenario/rbs/ivar_decl_priority.rb @@ -0,0 +1,34 @@ +## update: test.rbs +class Base +end + +class Child < Base + @x: Integer +end + +## update: test.rb +class Base + def initialize + @x = nil + end +end + +class Child < Base + def initialize + super + @x = 1 + end + + def get_x + @x + end +end + +## assert +class Base + def initialize: -> void +end +class Child < Base + def initialize: -> void + def get_x: -> Integer +end