From 8f576afaccbe80c5e126ae0e924a4bcfa7a32daa Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 15 Sep 2026 15:36:02 -0500 Subject: [PATCH] fix: resolve inherited Ruby member calls --- graphify/ruby_resolution.py | 15 ++- tests/test_ruby_resolution.py | 224 ++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 3 deletions(-) diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index 5edb214c07..b6bd56ae0d 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -11,7 +11,8 @@ target is certain (single owning class, single owned method) — bail otherwise: * ``Processor.new`` -> a ``calls`` edge to the ``Processor`` class - * ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to ``Processor#run`` + * ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to the direct + or safely inherited ``Processor#run`` instance method * bare ``run`` in a subclass -> promote the existing inferred edge when the same-kind method is uniquely proven by the extracted inheritance chain @@ -22,9 +23,10 @@ from __future__ import annotations import re -from pathlib import Path from typing import Any +from .build import _is_file_node_label + def _key(label: str) -> str: """Normalize a class/method label to a comparison key (drop punctuation).""" @@ -91,7 +93,7 @@ def resolve_ruby_member_calls( node for node in all_nodes if str(node.get("source_file", "")).endswith((".rb", ".rake")) - and node.get("label") == Path(str(node.get("source_file"))).name + and _is_file_node_label(node.get("label"), node.get("source_file")) ] ruby_context_complete = bool(ruby_file_nodes) and all( isinstance(node.get("metadata"), dict) @@ -510,6 +512,13 @@ def _promote_inferred(caller: str, target: str, callee: str, rc: dict) -> None: if class_nid is None: continue method_nid = method_index.get((class_nid, str(callee))) + if ( + method_nid is None + and ruby_context_complete + and not unsafe_ruby_files + and not external_method_owners + ): + method_nid = _inherited_method(class_nid, str(callee), "instance") if method_nid is None: continue _emit(caller, method_nid, rc) diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index a95e16814a..624c7923fd 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -69,6 +69,19 @@ def _call_edges_matching(graph: dict, src_label: str, tgt_label: str) -> list[di ] +def _owned_method_nid(graph: dict, owner_label: str, method_label: str) -> str: + labels = _labels(graph["nodes"]) + matches = [ + str(edge["target"]) + for edge in graph["edges"] + if edge.get("relation") == "method" + and labels.get(edge.get("source")) == owner_label + and labels.get(edge.get("target")) == method_label + ] + assert len(matches) == 1 + return matches[0] + + def _only_call(graph: dict, target_label: str = ".helper()") -> dict: matches = _call_edges_matching(graph, ".call()", target_label) assert len(matches) == 1 @@ -184,6 +197,217 @@ def test_resolution_is_type_based_not_name_luck(tmp_path: Path) -> None: assert "worker" not in tgt_id.lower() +def test_typed_member_call_resolves_to_inherited_instance_method( + tmp_path: Path, +) -> None: + _write(tmp_path, "parent.rb", "class Parent\n def run; :parent; end\nend\n") + _write(tmp_path, "child.rb", "class Child < Parent\nend\n") + _write(tmp_path, "other.rb", "class Other\n def run; :other; end\nend\n") + _write( + tmp_path, + "main.rb", + "def process_all\n worker = Child.new\n worker.run\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + parent_run = _owned_method_nid(graph, "Parent", ".run()") + labels = _labels(graph["nodes"]) + matches = [ + edge + for edge in graph["edges"] + if edge.get("relation") == "calls" + and labels.get(edge.get("source")) == "process_all()" + and edge.get("target") == parent_run + ] + + assert len(matches) == 1 + assert matches[0]["confidence"] == "EXTRACTED" + assert matches[0]["confidence_score"] == 1.0 + + +def test_typed_member_call_prefers_direct_override(tmp_path: Path) -> None: + _write(tmp_path, "parent.rb", "class Parent\n def run; :parent; end\nend\n") + _write( + tmp_path, + "child.rb", + "class Child < Parent\n def run; :child; end\nend\n", + ) + _write( + tmp_path, + "main.rb", + "def process_all\n worker = Child.new\n worker.run\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + child_run = _owned_method_nid(graph, "Child", ".run()") + labels = _labels(graph["nodes"]) + + assert any( + edge.get("relation") == "calls" + and labels.get(edge.get("source")) == "process_all()" + and edge.get("target") == child_run + and edge.get("confidence") == "EXTRACTED" + for edge in graph["edges"] + ) + + +def test_typed_member_call_stays_unresolved_when_any_ruby_file_is_unsafe( + tmp_path: Path, +) -> None: + _write(tmp_path, "parent.rb", "class Parent\n def run; :parent; end\nend\n") + _write(tmp_path, "child.rb", "class Child < Parent\nend\n") + _write( + tmp_path, + "refinement.rb", + "module R\n refine Parent do\n def other; :refined; end\n end\nend\n", + ) + _write( + tmp_path, + "main.rb", + "def process_all\n worker = Child.new\n worker.run\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + parent_run = _owned_method_nid(graph, "Parent", ".run()") + labels = _labels(graph["nodes"]) + + assert not any( + edge.get("relation") == "calls" + and labels.get(edge.get("source")) == "process_all()" + and edge.get("target") == parent_run + for edge in graph["edges"] + ) + + +def test_typed_member_call_stays_unresolved_with_external_owner_alias( + tmp_path: Path, +) -> None: + _write(tmp_path, "parent.rb", "class Parent\n def run; :parent; end\nend\n") + _write(tmp_path, "child.rb", "class Child < Parent\nend\n") + _write( + tmp_path, + "patch.rb", + "AliasChild ||= Child\nAliasChild.class_eval { attr_reader :run }\n", + ) + _write( + tmp_path, + "main.rb", + "def process_all\n worker = Child.new\n worker.run\nend\n", + ) + + graph = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + parent_run = _owned_method_nid(graph, "Parent", ".run()") + + assert not any( + edge.get("relation") == "calls" and edge.get("target") == parent_run + for edge in graph["edges"] + ) + + +def _typed_member_inheritance_edges( + file_nodes: list[dict], + *, + caller_source: str = "child.rb", +) -> list[dict]: + nodes = [ + *file_nodes, + { + "id": "parent", + "label": "Parent", + "source_file": "parent.rb", + }, + { + "id": "child", + "label": "Child", + "source_file": "child.rb", + }, + {"id": "caller", "label": ".process_all()", "source_file": caller_source}, + { + "id": "run", + "label": ".run()", + "source_file": "parent.rb", + "metadata": {"ruby_method_kind": "instance"}, + }, + ] + edges = [ + {"source": "parent", "target": "run", "relation": "method"}, + { + "source": "child", + "target": "parent", + "relation": "inherits", + "metadata": { + "ruby_superclass_ref": "Parent", + "ruby_lexical_scopes": [], + }, + }, + ] + raw_call = { + "caller_nid": "caller", + "callee": "run", + "is_member_call": True, + "receiver_type": "Child", + "source_file": caller_source, + "source_location": "L3", + } + + resolve_ruby_member_calls([{"raw_calls": [raw_call]}], nodes, edges) + return edges + + +def test_typed_member_inheritance_requires_complete_ruby_schema() -> None: + file_nodes = [ + { + "id": "parent_file", + "label": "parent.rb", + "source_file": "parent.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + { + "id": "child_file", + "label": "child.rb", + "source_file": "child.rb", + "metadata": {}, + }, + ] + + edges = _typed_member_inheritance_edges(file_nodes) + + assert not any(edge.get("relation") == "calls" for edge in edges) + + +def test_typed_member_inheritance_recognizes_disambiguated_unsafe_file() -> None: + file_nodes = [ + { + "id": "parent_file", + "label": "parent.rb", + "source_file": "parent.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + { + "id": "child_file", + "label": "child.rb", + "source_file": "child.rb", + "metadata": {"ruby_resolution_schema": 1}, + }, + { + "id": "caller_file", + "label": "app/main.rb", + "source_file": "project/app/main.rb", + "metadata": { + "ruby_resolution_schema": 1, + "ruby_lookup_unsafe": True, + }, + }, + ] + + edges = _typed_member_inheritance_edges( + file_nodes, + caller_source="project/app/main.rb", + ) + + assert not any(edge.get("relation") == "calls" for edge in edges) + + def test_no_false_positive_when_type_unknown(tmp_path: Path) -> None: """A member call on a receiver with no known type must NOT be resolved.""" _write(tmp_path, "helper.rb", HELPER_RB)