From 5f28f2bb3ea21db65a0ca89f8c88b0424e5d988e Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 15 Sep 2026 04:16:00 -0500 Subject: [PATCH] fix: guard Ruby constant rebindings --- graphify/extractors/engine.py | 32 +++++++++------ tests/test_ruby_resolution.py | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 134dff8bf..8d3ba2232 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3141,21 +3141,28 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: """Return explicit constant owners modified outside their class body.""" owners: set[str] = set() + def collect_constant_refs(node) -> None: + if node.type in {"constant", "scope_resolution"}: + raw = _ruby_const_full_name(node, source) + if raw: + owners.add(raw) + return + for child in node.children: + if child.is_named: + collect_constant_refs(child) + def visit(node) -> None: - if node.type == "assignment": + if node.type in {"assignment", "operator_assignment"}: left = node.child_by_field_name("left") + if left is not None: + # Any write can change which superclass a constant reference + # denotes, including dynamic RHS and multiple assignment. + collect_constant_refs(left) right = node.child_by_field_name("right") - if ( - left is not None - and left.type in {"constant", "scope_resolution"} - and right is not None - and right.type in {"constant", "scope_resolution"} - ): - raw = _ruby_const_full_name(left, source) - if raw: - # Constant aliases can change which superclass an - # unqualified reference denotes without changing its text. - owners.add(raw) + if node.type == "assignment" and right is not None: + # Treat both ends of a potential constant alias as unsafe. A + # later mutation through the alias affects the original owner. + collect_constant_refs(right) if node.type in {"class", "module"}: name = node.child_by_field_name("name") if name is not None and name.type == "scope_resolution": @@ -3178,7 +3185,6 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: raw = _ruby_const_full_name(owner, source) if raw: owners.add(raw) - return if node.type == "call": receiver = node.child_by_field_name("receiver") method = node.child_by_field_name("method") diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index e25cb00a5..cae9ebe7e 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -780,6 +780,38 @@ def test_inherited_call_stays_inferred_for_superclass_constant_alias( assert _only_call(graph)["confidence"] == "INFERRED" +def test_inherited_call_stays_inferred_for_dynamic_superclass_constant_write( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :stale; end\nend\n") + _write( + tmp_path, + "child.rb", + "Base = resolve_base()\nclass Child < Base\n" + " def call\n helper()\n end\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_inherited_call_stays_inferred_for_multiple_constant_write( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :stale; end\nend\n") + _write( + tmp_path, + "child.rb", + "Base, Spare = ExternalBase, Object\nclass Child < Base\n" + " def call\n helper()\n end\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + def test_inherited_call_stays_inferred_under_enclosing_refinement( tmp_path: Path, ) -> None: @@ -863,6 +895,48 @@ def test_external_owner_mutator_inside_singleton_class_stays_inferred( assert _only_call(graph)["confidence"] == "INFERRED" +def test_external_owner_mutator_inside_external_eigenclass_stays_inferred( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "class Child < Base\n def self.call\n helper()\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "class Installer; end\nclass << Installer\n" + " Child.define_singleton_method(:helper) { :child }\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + +def test_external_owner_mutator_through_constant_alias_stays_inferred( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "class Child < Base\n def self.call\n helper()\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "AliasChild = Child\n" + "AliasChild.define_singleton_method(:helper) { :child }\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + + assert _only_call(graph)["confidence"] == "INFERRED" + + def test_inherited_call_stays_inferred_for_compact_reopening( tmp_path: Path, ) -> None: