diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 26f061de4..04ca65eb2 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3159,15 +3159,18 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: refs.update(constant_refs(child)) return refs - def alias_rhs_refs(node) -> set[str]: + def alias_rhs_refs(node, *, allow_array: bool = False) -> 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"}: + if node.type in { + "parenthesized_statements", + "right_assignment_list", + } or (allow_array and node.type == "array"): refs: set[str] = set() for child in node.children: if child.is_named: - refs.update(alias_rhs_refs(child)) + refs.update(alias_rhs_refs(child, allow_array=allow_array)) return refs return set() @@ -3189,7 +3192,12 @@ def _ruby_external_method_owners(root_node, source: bytes) -> list[str]: # 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) + prefix_marker(raw) + for raw in alias_rhs_refs( + right, + allow_array=left is not None + and left.type == "left_assignment_list", + ) ) if node.type in {"class", "module"}: name = node.child_by_field_name("name") diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index c69fc61df..5edb214c0 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -224,13 +224,13 @@ def resolve_ruby_member_calls( if len(candidates) > 1: return None if scope and len(ref_parts) > 1: - prefix_label = "::".join( + first_prefix_label = "::".join( [ *[part for part in scope.split("::") if part], - *ref_parts[:-1], + ref_parts[0], ] ) - if class_labels.get(prefix_label): + if class_labels.get(first_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. diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index b32b0d0b0..a95e16814 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -812,6 +812,25 @@ def test_inherited_call_stays_inferred_for_multiple_constant_write( assert _only_call(graph)["confidence"] == "INFERRED" +def test_destructured_constant_alias_owner_stays_inferred(tmp_path: Path) -> None: + _write(tmp_path, "base.rb", "class Base\n def self.helper; :wrong; 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, Spare = [Child, Object]\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_under_enclosing_refinement( tmp_path: Path, ) -> None: @@ -1059,6 +1078,36 @@ def test_qualified_superclass_stops_at_nearer_lexical_prefix( assert _only_call(graph)["confidence"] == "INFERRED" +def test_deep_qualified_superclass_stops_at_first_lexical_segment( + tmp_path: Path, +) -> None: + _write( + tmp_path, + "base.rb", + "module Outer\n module Services\n module API\n class Base\n" + " def self.helper; :wrong; end\n end\n end\n end\nend\n", + ) + _write( + tmp_path, + "provider.rb", + "class Provider\n module API\n class Base\n class << self\n" + " attr_reader :helper\n end\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::API::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(