Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions graphify/ruby_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
224 changes: 224 additions & 0 deletions tests/test_ruby_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading