From ff75ca10299effd44213b674240b5fd10895cccc Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 15 Sep 2026 04:11:41 -0500 Subject: [PATCH] fix: reject ambiguous Ruby constant mutations (cherry picked from commit 891ebfae53e8de822fbb02d5f8ca7944bce38425) --- graphify/extractors/engine.py | 23 +++++++++++-- tests/test_ruby_resolution.py | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 9349924bc..ac7221ced 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3208,6 +3208,20 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: owners: set[str] = set() def visit(node) -> None: + if node.type == "assignment": + left = node.child_by_field_name("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 in {"class", "module"}: name = node.child_by_field_name("name") if name is not None and name.type == "scope_resolution": @@ -3230,7 +3244,7 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: raw = _ruby_const_full_name(owner, source) if raw: owners.add(raw) - return + return if node.type == "call": receiver = node.child_by_field_name("receiver") method = node.child_by_field_name("method") @@ -3659,6 +3673,9 @@ def _extract_generic( if not name_node: return class_name = _read_text(name_node, source) + ruby_absolute_declaration = ( + config.ts_module == "tree_sitter_ruby" and class_name.startswith("::") + ) # Ruby: fully qualify the module/class label with its enclosing # scope, splitting compact `Foo::Bar` names into segments so both # declaration styles converge on one `Foo::Bar` label (#2302). @@ -3693,11 +3710,13 @@ def _extract_generic( metadata["is_partial"] = True if config.ts_module == "tree_sitter_ruby": ruby_body = _find_body(node, config) - if ruby_reopened or ( + if ruby_absolute_declaration or ruby_reopened or ( ruby_body is not None and _ruby_body_has_lookup_barrier(ruby_body, source) ): metadata = dict(metadata or {}) + if ruby_absolute_declaration: + metadata["ruby_lookup_unsafe"] = True if ruby_reopened: metadata["ruby_reopened"] = True if ruby_body is not None and _ruby_body_has_lookup_barrier( diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index 31223cd9f..e25cb00a5 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -759,6 +759,27 @@ def test_inherited_constant_lookup_does_not_fall_back_past_lexical_owner( assert _only_call(graph)["confidence"] == "INFERRED" +def test_inherited_call_stays_inferred_for_superclass_constant_alias( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :wrong; end\nend\n") + _write( + tmp_path, + "other.rb", + "class Other\n class << self\n attr_reader :helper\n end\nend\n", + ) + _write( + tmp_path, + "child.rb", + "Base = Other\nclass Child < Base\n" + " def self.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: @@ -821,6 +842,27 @@ def test_inherited_call_stays_inferred_for_external_owner_eval( assert _only_call(graph)["confidence"] == "INFERRED" +def test_external_owner_mutator_inside_singleton_class_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", + "module Installer\n class << self\n" + " Child.define_singleton_method(:helper) { :child }\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_compact_reopening( tmp_path: Path, ) -> None: @@ -843,6 +885,27 @@ def test_inherited_call_stays_inferred_for_compact_reopening( assert _only_call(graph)["confidence"] == "INFERRED" +def test_absolute_class_declaration_inside_namespace_stays_inferred( + tmp_path: Path, +) -> None: + _write(tmp_path, "base.rb", "class Base\n def helper; :base; end\nend\n") + _write( + tmp_path, + "child.rb", + "module Wrapper\n class ::Child < ::Base\n" + " def call\n helper()\n end\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "class Child\n attr_reader :helper\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_stops_at_ambiguous_nearer_method(tmp_path: Path) -> None: _write(tmp_path, "grand.rb", "class Grand\n def helper; :grand; end\nend\n") _write(