From 7e857e020b8d5abe3abb46d4e2712e6ded28e9b4 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 15 Sep 2026 04:22:08 -0500 Subject: [PATCH] fix: guard Ruby namespace rebinding (cherry picked from commit 1a7262e2a85797b672d29139a7771c24552b1baf) --- graphify/extractors/engine.py | 48 ++++++++++++++++----- graphify/ruby_resolution.py | 30 +++++++++++-- tests/test_ruby_resolution.py | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 13 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e7a9b0f81..a2fb4cfe1 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3207,28 +3207,56 @@ 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: + def constant_refs(node) -> set[str]: if node.type in {"constant", "scope_resolution"}: raw = _ruby_const_full_name(node, source) - if raw: - owners.add(raw) - return + return {raw} if raw else set() + if node.type not in { + "left_assignment_list", + "parenthesized_statements", + "splat_argument", + }: + # Attribute/index writes such as `Namespace.config = value` + # contain a constant receiver but do not rebind that constant. + return set() + refs: set[str] = set() for child in node.children: if child.is_named: - collect_constant_refs(child) + refs.update(constant_refs(child)) + return refs + + def alias_rhs_refs(node) -> set[str]: + if node.type in {"constant", "scope_resolution"}: + raw = _ruby_const_full_name(node, source) + return {raw} if raw else set() + if node.type in {"parenthesized_statements", "right_assignment_list"}: + refs: set[str] = set() + for child in node.children: + if child.is_named: + refs.update(alias_rhs_refs(child)) + return refs + return set() + + def prefix_marker(raw: str) -> str: + return f"{raw}::*" def visit(node) -> None: if node.type in {"assignment", "operator_assignment"}: left = node.child_by_field_name("left") - if left is not None: + lhs_refs = constant_refs(left) if left is not None else set() + if lhs_refs: # Any write can change which superclass a constant reference # denotes, including dynamic RHS and multiple assignment. - collect_constant_refs(left) + owners.update(prefix_marker(raw) for raw in lhs_refs) right = node.child_by_field_name("right") - if node.type == "assignment" and right is not None: + if node.type == "assignment" and lhs_refs 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) + # later mutation through the alias affects descendant owners + # too. Only direct/parenthesized constant RHS forms qualify; + # ordinary values containing constant references do not. + owners.update( + prefix_marker(raw) for raw in alias_rhs_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": diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index d0d5dbe45..c69fc61df 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -223,6 +223,18 @@ def resolve_ruby_member_calls( return next(iter(candidates)) if len(candidates) > 1: return None + if scope and len(ref_parts) > 1: + prefix_label = "::".join( + [ + *[part for part in scope.split("::") if part], + *ref_parts[:-1], + ] + ) + if class_labels.get(prefix_label): + # Ruby bound the qualified prefix in this nearer lexical + # scope. Its inherited constants are not modelled, so do + # not retry the same prefix in an outer scope. + return None return None inheritance: dict[str, set[str]] = {} @@ -266,14 +278,26 @@ def resolve_ruby_member_calls( def _has_external_method_owner(label: str) -> bool: label_parts = tuple(part for part in label.split("::") if part) for raw_owner in external_method_owners: + is_prefix = raw_owner.endswith("::*") + owner_ref = raw_owner.removesuffix("::*") if is_prefix else raw_owner owner_parts = tuple( - part for part in raw_owner.removeprefix("::").split("::") if part + part for part in owner_ref.removeprefix("::").split("::") if part ) if not owner_parts: continue - if raw_owner.startswith("::") and label_parts == owner_parts: + if is_prefix: + if owner_ref.startswith("::"): + if label_parts[: len(owner_parts)] == owner_parts: + return True + elif any( + label_parts[index : index + len(owner_parts)] == owner_parts + for index in range(len(label_parts) - len(owner_parts) + 1) + ): + return True + continue + if owner_ref.startswith("::") and label_parts == owner_parts: return True - if not raw_owner.startswith("::") and label_parts[-len(owner_parts):] == owner_parts: + if not owner_ref.startswith("::") and label_parts[-len(owner_parts):] == owner_parts: return True return False diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index cae9ebe7e..b32b0d0b0 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -937,6 +937,55 @@ def test_external_owner_mutator_through_constant_alias_stays_inferred( assert _only_call(graph)["confidence"] == "INFERRED" +def test_qualified_external_owner_through_namespace_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", + "module Namespace\n class Child < Base\n" + " def self.call\n helper()\n end\n end\nend\n", + ) + _write( + tmp_path, + "override.rb", + "AliasNS = Namespace\n" + "AliasNS::Child.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_qualified_superclass_under_rebound_namespace_stays_inferred( + tmp_path: Path, +) -> None: + _write( + tmp_path, + "base.rb", + "module Namespace\n class Base\n" + " def self.helper; :wrong; end\n end\nend\n", + ) + _write( + tmp_path, + "other.rb", + "module Other\n class Base\n class << self\n" + " attr_reader :helper\n end\n end\nend\n", + ) + _write( + tmp_path, + "child.rb", + "Namespace = Other\nclass Child < Namespace::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_for_compact_reopening( tmp_path: Path, ) -> None: @@ -980,6 +1029,36 @@ def test_absolute_class_declaration_inside_namespace_stays_inferred( assert _only_call(graph)["confidence"] == "INFERRED" +def test_qualified_superclass_stops_at_nearer_lexical_prefix( + tmp_path: Path, +) -> None: + _write( + tmp_path, + "base.rb", + "module Outer\n module Services\n class Base\n" + " def self.helper; :wrong; end\n end\n end\nend\n", + ) + _write( + tmp_path, + "provider.rb", + "class Provider\n class Base\n class << self\n" + " attr_reader :helper\n end\n end\nend\n", + ) + _write( + tmp_path, + "child.rb", + "module Outer\n module Inner\n" + " class Services < ::Provider; end\n" + " class Child < Services::Base\n" + " def self.call\n helper()\n end\n end\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_stops_at_ambiguous_nearer_method(tmp_path: Path) -> None: _write(tmp_path, "grand.rb", "class Grand\n def helper; :grand; end\nend\n") _write(