Files
graphify/tests/test_ruby_resolution.py
T
vamsi pavan mahesh gunturu 86ecb769b6 feat(ruby): type-aware member-call resolution via a resolver framework (#1499)
Resolve Ruby `obj.method()` calls by the inferred type of the receiver
instead of by globally-unique method name. `p = Processor.new; p.run`
now emits a `calls` edge to `Processor#run` and survives name collisions
with unrelated `Worker#run` definitions, where the old name-based match
either resolved by luck or dropped the edge as ambiguous.

Introduces graphify/resolver_registry.py, a behavior-identical
formalization of the existing tail-of-extract() language resolution
passes (Swift #1356, Python #1446 become registered entries), and
graphify/ruby_resolution.py, its first new consumer. Receiver type is
inferred only from unambiguous local `var = ClassName.new` bindings;
ambiguous or unknown receivers resolve to nothing (no false positives).

Note: Ruby member calls are now excluded from name-based cross-file
resolution and resolved by inferred type only. This is an intentional
precision-over-recall change scoped to Ruby: a cross-file `var.method`
whose receiver type cannot be proven from a local `X.new` binding no
longer resolves by name-luck (it produces no edge rather than a possibly
wrong one), matching the project's confidence model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:40:22 +01:00

196 lines
6.4 KiB
Python

"""TDD specs for type-aware Ruby call-graph resolution.
These drive the "improved Ruby graph" work:
* member calls capture their receiver (extraction)
* `var = ClassName.new` local bindings give the receiver a type (extraction)
* the cross-file resolver turns `var.method` into a precise edge BY TYPE,
not by globally-unique name — so it survives name collisions and never
emits a false positive when the type is unknown (resolution)
* `require_relative` links files (resolution)
Every resolved edge must be EXTRACTED (1.0) confidence: resolve only when
certain, bail otherwise.
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract, extract_ruby
# ── helpers ────────────────────────────────────────────────────────────────────
def _write(tmp_path: Path, name: str, body: str) -> Path:
p = tmp_path / name
p.write_text(body)
return p
def _raw_calls(result: dict) -> list[dict]:
return result.get("raw_calls", [])
def _find_raw_call(result: dict, callee: str) -> dict | None:
for rc in _raw_calls(result):
if rc.get("callee") == callee:
return rc
return None
def _labels(nodes: list[dict]) -> dict[str, str]:
return {n["id"]: str(n.get("label", "")) for n in nodes}
def _has_call_edge(graph: dict, src_label_sub: str, tgt_label_sub: str) -> dict | None:
"""Return the `calls` edge whose source/target labels contain the given
substrings, or None."""
labels = _labels(graph["nodes"])
for e in graph["edges"]:
if e.get("relation") != "calls":
continue
s = labels.get(e.get("source"), "")
t = labels.get(e.get("target"), "")
if src_label_sub in s and tgt_label_sub in t:
return e
return None
HELPER_RB = """\
def transform(data)
data.upcase
end
class Processor
def run(items)
items.map { |i| transform(i) }
end
end
"""
MAIN_RB = """\
require_relative "helper"
def handle(values)
transform(values)
end
def process_all(items)
p = Processor.new
p.run(items)
end
"""
WORKER_RB = """\
class Worker
def run(jobs)
jobs.each { |j| j }
end
end
"""
# ── extraction level ───────────────────────────────────────────────────────────
def test_member_call_captures_receiver(tmp_path: Path) -> None:
main = _write(tmp_path, "main.rb", MAIN_RB)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None, "p.run should produce a raw_call with callee 'run'"
assert rc["is_member_call"] is True
assert rc["receiver"] == "p"
def test_local_binding_gives_receiver_a_type(tmp_path: Path) -> None:
main = _write(tmp_path, "main.rb", MAIN_RB)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None
# `p = Processor.new` in the same method => p has type Processor.
assert rc.get("receiver_type") == "Processor"
def test_ambiguous_binding_yields_no_type(tmp_path: Path) -> None:
main = _write(
tmp_path,
"main.rb",
"""\
def process_all(items)
p = Processor.new
p = Worker.new
p.run(items)
end
""",
)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None
# reassigned to a different class => not certain => no type attached.
assert rc.get("receiver_type") is None
# ── resolution level ───────────────────────────────────────────────────────────
def test_resolves_member_call_by_type(tmp_path: Path) -> None:
_write(tmp_path, "helper.rb", HELPER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
edge = _has_call_edge(graph, "process_all", "run")
assert edge is not None, "process_all should resolve a call to Processor#run"
assert edge["confidence"] == "EXTRACTED"
def test_resolution_is_type_based_not_name_luck(tmp_path: Path) -> None:
"""The differentiator: adding an unrelated Worker#run must NOT break the edge.
Name-match resolvers drop this (two `run` definitions => ambiguous). A
type-based resolver keeps resolving p.run -> Processor#run, and never points
it at Worker#run.
"""
_write(tmp_path, "helper.rb", HELPER_RB)
_write(tmp_path, "worker.rb", WORKER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract(
[main, tmp_path / "helper.rb", tmp_path / "worker.rb"],
cache_root=tmp_path,
parallel=False,
)
to_processor_run = _has_call_edge(graph, "process_all", "run")
assert to_processor_run is not None, "edge must survive the name collision"
assert to_processor_run["confidence"] == "EXTRACTED"
# And it must be the RIGHT run: the target must be owned by Processor, not Worker.
labels = _labels(graph["nodes"])
tgt_id = to_processor_run["target"]
# the method node id is prefixed by its owning class (helper_processor_run)
assert "processor" in tgt_id.lower(), f"expected Processor#run, got {tgt_id}"
assert "worker" not in tgt_id.lower()
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)
main = _write(
tmp_path,
"main.rb",
"""\
require_relative "helper"
def process_all(thing)
thing.run(1)
end
""",
)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
# `thing` is a parameter of unknown type => no precise target => no edge.
assert _has_call_edge(graph, "process_all", "run") is None
def test_class_new_creates_instantiation_edge(tmp_path: Path) -> None:
"""`p = Processor.new` should link the caller to the Processor class."""
_write(tmp_path, "helper.rb", HELPER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
edge = _has_call_edge(graph, "process_all", "Processor")
assert edge is not None, "Processor.new should resolve a call to the Processor class"
assert edge["confidence"] == "EXTRACTED"