mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
6631af7936
#1668: Ruby `include`/`extend`/`prepend <Const>` in a class/module body now emits a `mixes_in` edge to the module. The mixin is captured during the node walk and resolved cross-file by resolve_ruby_member_calls (single-owner guard, reusing the #1640 module nodes as targets). The shared call pass skips these markers so they are not mislabeled as `calls`. `extend self` and non-constant args are skipped; ambiguous/undefined modules produce no edge. Rails concern composition is now visible to affected/explain. #1669: affected <Class> seeds the reverse walk with the root's own member nodes (one method/contains hop) so callers that bind at method granularity (e.g. Service.call -> the def self.call node, #1634) are reachable from the class. method/contains stay out of the general relation-filtered walk (no forward noise), and the seeded member nodes are not reported as hits. Full suite: 2924 passed, 3 skipped. Verified end-to-end (Rails-shaped repros) plus edge cases: extend self / undefined / ambiguous mixins emit nothing, mixins are not emitted as calls, member methods aren't reported, class-level callers still resolve, and one-hop seeding does not pull in downstream classes' methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
Python
351 lines
14 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"
|
|
|
|
|
|
# ── #1640 node extraction + #1634 constant-receiver resolution ───────────────
|
|
|
|
|
|
def _node_labels(result: dict) -> set[str]:
|
|
return {str(n.get("label", "")) for n in result["nodes"]}
|
|
|
|
|
|
def _method_edges(result: dict) -> set[tuple[str, str]]:
|
|
labels = _labels(result["nodes"])
|
|
return {
|
|
(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
|
for e in result["edges"] if e.get("relation") == "method"
|
|
}
|
|
|
|
|
|
def test_plain_module_gets_a_node_with_methods(tmp_path: Path) -> None:
|
|
"""#1640 shape 1: `module Foo` must get a node and own its methods."""
|
|
r = extract_ruby(_write(tmp_path, "tax.rb",
|
|
"module TaxCalculator\n module_function\n def rate_for(order)\n 0.2\n end\nend\n"))
|
|
assert "TaxCalculator" in _node_labels(r)
|
|
# method attaches to the module (dot label), not the file (dot-less).
|
|
assert ("TaxCalculator", ".rate_for()") in _method_edges(r)
|
|
|
|
|
|
def test_nested_modules_each_get_a_node(tmp_path: Path) -> None:
|
|
"""#1640 shape 1, nested."""
|
|
r = extract_ruby(_write(tmp_path, "n.rb",
|
|
"module Billing\n module Rounding\n def round(x)\n x.round(2)\n end\n end\nend\n"))
|
|
labels = _node_labels(r)
|
|
assert "Billing" in labels and "Rounding" in labels
|
|
assert ("Rounding", ".round()") in _method_edges(r)
|
|
|
|
|
|
def test_struct_new_constant_creates_class_with_methods(tmp_path: Path) -> None:
|
|
"""#1640 shape 2: `Foo = Struct.new(...) do ... end`."""
|
|
r = extract_ruby(_write(tmp_path, "invoice.rb",
|
|
"Invoice = Struct.new(:total, :tax) do\n def grand_total\n total + tax\n end\nend\n"))
|
|
assert "Invoice" in _node_labels(r)
|
|
assert ("Invoice", ".grand_total()") in _method_edges(r)
|
|
|
|
|
|
def test_class_new_constant_creates_class_and_inherits(tmp_path: Path) -> None:
|
|
"""#1640 shape 3: `Foo = Class.new(Super)` — node + inherits edge."""
|
|
r = extract_ruby(_write(tmp_path, "err.rb", "ApiError = Class.new(StandardError)\n"))
|
|
assert "ApiError" in _node_labels(r)
|
|
labels = _labels(r["nodes"])
|
|
inh = {(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
|
for e in r["edges"] if e.get("relation") == "inherits"}
|
|
assert ("ApiError", "StandardError") in inh
|
|
|
|
|
|
def test_data_define_constant_creates_class(tmp_path: Path) -> None:
|
|
r = extract_ruby(_write(tmp_path, "res.rb", "Result = Data.define(:ok, :value)\n"))
|
|
assert "Result" in _node_labels(r)
|
|
|
|
|
|
def test_constant_receiver_singleton_call_resolves(tmp_path: Path) -> None:
|
|
"""#1634: `Processor.call` (def self.call) resolves to the singleton method."""
|
|
_write(tmp_path, "processor.rb", "class Processor\n def self.call; end\nend\n")
|
|
runner = _write(tmp_path, "runner.rb",
|
|
"class Runner\n def run\n Processor.call\n end\nend\n")
|
|
graph = extract([runner, tmp_path / "processor.rb"], cache_root=tmp_path, parallel=False)
|
|
assert _has_call_edge(graph, "run", "call") is not None
|
|
|
|
|
|
def test_constant_receiver_module_function_call_resolves(tmp_path: Path) -> None:
|
|
"""#1634 + #1640: `TaxCalculator.rate_for` resolves across files to a
|
|
module_function — needs both the module node (#1640) and the resolver (#1634)."""
|
|
_write(tmp_path, "tax.rb",
|
|
"module TaxCalculator\n module_function\n def rate_for(o)\n 0.2\n end\nend\n")
|
|
pp = _write(tmp_path, "pp.rb",
|
|
"class PaymentProcessor\n def process(order)\n TaxCalculator.rate_for(order)\n end\nend\n")
|
|
graph = extract([pp, tmp_path / "tax.rb"], cache_root=tmp_path, parallel=False)
|
|
assert _has_call_edge(graph, "process", "rate_for") is not None
|
|
|
|
|
|
def test_constant_receiver_unknown_class_method_falls_back_to_class(tmp_path: Path) -> None:
|
|
"""#1634: `Model.where` (no `where` def, e.g. ActiveRecord) still links to the
|
|
class node for blast-radius, rather than dropping the edge."""
|
|
_write(tmp_path, "model.rb", "class Model\n def self.create; end\nend\n")
|
|
caller = _write(tmp_path, "svc.rb",
|
|
"class Svc\n def run\n Model.where(id: 1)\n end\nend\n")
|
|
graph = extract([caller, tmp_path / "model.rb"], cache_root=tmp_path, parallel=False)
|
|
# No `where` method node exists, so the edge lands on the class node itself.
|
|
assert _has_call_edge(graph, "run", "Model") is not None
|
|
|
|
|
|
def test_ambiguous_constant_receiver_emits_no_edge(tmp_path: Path) -> None:
|
|
"""Two classes named `Processor` => ambiguous receiver => bail (no wrong edge)."""
|
|
_write(tmp_path, "a.rb", "module A\n class Processor\n def self.call; end\n end\nend\n")
|
|
_write(tmp_path, "b.rb", "module B\n class Processor\n def self.call; end\n end\nend\n")
|
|
caller = _write(tmp_path, "c.rb",
|
|
"class Runner\n def run\n Processor.call\n end\nend\n")
|
|
graph = extract([caller, tmp_path / "a.rb", tmp_path / "b.rb"], cache_root=tmp_path, parallel=False)
|
|
assert _has_call_edge(graph, "run", "call") is None
|
|
|
|
|
|
# ── #1668 include/extend/prepend -> mixes_in ─────────────────────────────────
|
|
|
|
|
|
def _mixes_in(graph: dict) -> set[tuple[str, str]]:
|
|
labels = _labels(graph["nodes"])
|
|
return {
|
|
(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
|
for e in graph["edges"] if e.get("relation") == "mixes_in"
|
|
}
|
|
|
|
|
|
def test_include_emits_mixes_in_edge(tmp_path: Path) -> None:
|
|
_write(tmp_path, "concern.rb", "module SealedProtection\n def sealed?; true; end\nend\n")
|
|
_write(tmp_path, "model.rb",
|
|
"class Roster < ApplicationRecord\n include SealedProtection\nend\n")
|
|
g = extract([tmp_path / "model.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
|
|
assert ("Roster", "SealedProtection") in _mixes_in(g)
|
|
|
|
|
|
def test_extend_and_prepend_emit_mixes_in(tmp_path: Path) -> None:
|
|
_write(tmp_path, "helpers.rb", "module Helpers\n def h; end\nend\n")
|
|
_write(tmp_path, "audit.rb", "module Audit\n def a; end\nend\n")
|
|
_write(tmp_path, "svc.rb",
|
|
"class Svc\n extend Helpers\n prepend Audit\nend\n")
|
|
mix = _mixes_in(extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False))
|
|
assert ("Svc", "Helpers") in mix
|
|
assert ("Svc", "Audit") in mix
|
|
|
|
|
|
def test_extend_self_and_nonconstant_args_emit_no_mixin(tmp_path: Path) -> None:
|
|
# `extend self` and `include some_var` are not constant module references.
|
|
_write(tmp_path, "m.rb",
|
|
"module M\n extend self\n def go; end\nend\n")
|
|
mix = _mixes_in(extract([tmp_path / "m.rb"], cache_root=tmp_path, parallel=False))
|
|
assert not any(t == "self" for _s, t in mix)
|
|
assert not mix
|
|
|
|
|
|
def test_include_of_undefined_or_ambiguous_module_emits_no_edge(tmp_path: Path) -> None:
|
|
# Undefined module (no node) -> no edge, under the single-owner guard.
|
|
_write(tmp_path, "x.rb", "class X\n include NotDefinedAnywhere\nend\n")
|
|
mix = _mixes_in(extract([tmp_path / "x.rb"], cache_root=tmp_path, parallel=False))
|
|
assert not any(t == "NotDefinedAnywhere" for _s, t in mix)
|
|
|
|
|
|
def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None:
|
|
# Regression: the shared cross-file call pass must not turn a mixin into a
|
|
# `calls` edge (which would mislabel it and block the mixes_in emit).
|
|
_write(tmp_path, "concern.rb", "module C\n def m; end\nend\n")
|
|
_write(tmp_path, "k.rb", "class K\n include C\nend\n")
|
|
g = extract([tmp_path / "k.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
|
|
labels = _labels(g["nodes"])
|
|
calls = {(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
|
for e in g["edges"] if e.get("relation") == "calls"}
|
|
assert ("K", "C") not in calls
|
|
assert ("K", "C") in _mixes_in(g)
|