fix: resolve inherited Ruby member calls

This commit is contained in:
oleksii-tumanov committed 2026-09-15 15:52:40 -05:00
1 parent c7ec108208
commit 8f576afacc
2 files changed
+236 -3

No files matched your search

+12 -3
View File
@@ -11,7 +11,8 @@ It resolves three shapes, at EXTRACTED (1.0) confidence and only when the
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 @@ disambiguation, so node ids and raw_call caller_nids are final.
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 resolve_ruby_member_calls(
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)
+224
View File
@@ -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)